CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
- 0: sing-box gen overrides fragment listen/port from the inbound row (empty listen -> "::"), Update/Add recompute the stored fragment; non-loopback E2E against the real core binary. - 1: agent fetches the sing-box core from the Gitea release when missing, self-checks it, and only then latches the switch. - 2: node list shows the sing-box core version (heartbeat field, persisted on the node row), xray version as fallback. - 3: public-page fallback returns 404 to anonymous visitors when the page is disabled. - 4: settings restart hint bar removed, dead i18n keys cleaned. - 5: two PUT calls missing JSON headers fixed (audit: only those two), batch probe-task creation endpoint + UI. - 6: cf-probe retirement card and coverage pipeline removed. - 7: generated vless links no longer carry spx (panel export + sub); parser still accepts spx; golden fixtures updated. - probe batch: dedicated probe settings tab (public-page settings, tasks, per-node meta), retention_days editable, per-node traffic calibration + stat-interface pinning, runtime metrics split into a lightweight fast report (default 3s, 1-60s adjustable). - 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50. - 9: dead /getTwoFactorEnable docs entries removed (frontend call and contract entry already gone at base; verified by grep).
258 lines
8.8 KiB
Go
258 lines
8.8 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
|
)
|
|
|
|
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
|
|
// metrics without touching liveness (last-seen stays the
|
|
// heartbeat's), and the config poll carries the cadence + the
|
|
// node's counted-interface pin.
|
|
func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.CloseDB() })
|
|
db := database.GetDB()
|
|
|
|
const token = "fast-report-token"
|
|
node := &model.Node{Name: "fast-node", Address: "192.0.2.1", Port: 8443, Enable: true}
|
|
if err := db.Create(node).Error; err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
agent := &model.AgentNode{TokenHash: hashAgentToken(token), TokenPrefix: "fast-", Name: "fast-node", NodeID: &node.Id}
|
|
if err := db.Create(agent).Error; err != nil {
|
|
t.Fatalf("seed agent: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
NewAgentController(engine.Group("/panel/api/agent"))
|
|
|
|
call := func(method, path, body string) (int, []byte) {
|
|
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
engine.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Body.Bytes()
|
|
}
|
|
load := func() model.AgentNode {
|
|
var row model.AgentNode
|
|
if err := db.First(&row, agent.Id).Error; err != nil {
|
|
t.Fatalf("reload agent: %v", err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
// One heartbeat establishes last-seen.
|
|
if code, _ := call(http.MethodPost, "/panel/api/agent/heartbeat", `{"token":"`+token+`","timestamp":1}`); code != http.StatusOK {
|
|
t.Fatalf("heartbeat: %d", code)
|
|
}
|
|
seenAfterHeartbeat := load().LastSeen
|
|
if seenAfterHeartbeat == 0 {
|
|
t.Fatal("heartbeat did not stamp last-seen")
|
|
}
|
|
|
|
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
|
|
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
|
|
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
|
|
t.Fatalf("probe-report: %d (%s)", code, body)
|
|
}
|
|
row := load()
|
|
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
|
|
t.Errorf("fast report metrics not stored: %+v", row)
|
|
}
|
|
if row.LastSeen != seenAfterHeartbeat {
|
|
t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen)
|
|
}
|
|
|
|
// Unauthorized callers are refused.
|
|
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/probe-report", bytes.NewBufferString(metrics))
|
|
req.Header.Set("Authorization", "Bearer wrong")
|
|
rec := httptest.NewRecorder()
|
|
engine.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("probe-report with bad token: %d, want 401", rec.Code)
|
|
}
|
|
|
|
configObj := func() map[string]any {
|
|
code, body := call(http.MethodGet, "/panel/api/agent/config", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("config poll: %d (%s)", code, body)
|
|
}
|
|
// getConfig answers with the bare payload (no envelope).
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(body, &obj); err != nil {
|
|
t.Fatalf("config payload: %v", err)
|
|
}
|
|
return obj
|
|
}
|
|
obj := configObj()
|
|
if obj["probe_fast_interval"] != float64(3) {
|
|
t.Errorf("default probe_fast_interval = %v, want 3", obj["probe_fast_interval"])
|
|
}
|
|
if obj["probe_stat_iface"] != "" {
|
|
t.Errorf("default probe_stat_iface = %v, want empty", obj["probe_stat_iface"])
|
|
}
|
|
|
|
// Operator changes: cadence via the probe settings block, pin
|
|
// via the node's probe meta — both must ride the next poll.
|
|
settings := service.GetProbePublicSettings()
|
|
settings.FastIntervalSec = 9
|
|
if err := service.SetProbePublicSettings(settings); err != nil {
|
|
t.Fatalf("SetProbePublicSettings: %v", err)
|
|
}
|
|
st := probe.Default()
|
|
meta := probe.DefaultMeta(int64(agent.Id))
|
|
meta.StatIface = "eth9"
|
|
if err := st.UpsertMeta(meta); err != nil {
|
|
t.Fatalf("UpsertMeta: %v", err)
|
|
}
|
|
obj = configObj()
|
|
if obj["probe_fast_interval"] != float64(9) {
|
|
t.Errorf("probe_fast_interval = %v, want 9", obj["probe_fast_interval"])
|
|
}
|
|
if obj["probe_stat_iface"] != "eth9" {
|
|
t.Errorf("probe_stat_iface = %v, want eth9", obj["probe_stat_iface"])
|
|
}
|
|
|
|
// Settings validation clamps the ranges the page offers.
|
|
bad := service.GetProbePublicSettings()
|
|
bad.RetentionDays = 0
|
|
if err := service.SetProbePublicSettings(bad); err == nil {
|
|
t.Error("retention_days = 0 accepted, want refusal")
|
|
}
|
|
bad = service.GetProbePublicSettings()
|
|
bad.FastIntervalSec = 61
|
|
if err := service.SetProbePublicSettings(bad); err == nil {
|
|
t.Error("fast interval = 61 accepted, want refusal")
|
|
}
|
|
}
|
|
|
|
// V2.0.1 ⑤: batch task creation — shared fields, auto names,
|
|
// duplicate collapse, all-or-nothing validation.
|
|
func TestProbeTasksBatch(t *testing.T) {
|
|
rig := newProbeTestRig(t)
|
|
cleanPingTasks(t)
|
|
const batchPath = "/panel/api/agent/probe-tasks/batch"
|
|
|
|
rec := rig.do(http.MethodPost, batchPath, []byte(
|
|
`{"targets":["1.1.1.1:443","8.8.8.8:53"," 1.1.1.1:443 "],"autoJoin":true}`), true)
|
|
env := decodeEnvelope(t, rec.Body.Bytes())
|
|
if !env.Success {
|
|
t.Fatalf("batch create refused: %s", env.Msg)
|
|
}
|
|
var obj struct {
|
|
IDs []int64 `json:"ids"`
|
|
}
|
|
if err := json.Unmarshal(env.Obj, &obj); err != nil || len(obj.IDs) != 2 {
|
|
t.Fatalf("batch ids = %s (err %v), want 2 ids (duplicate collapsed)", env.Obj, err)
|
|
}
|
|
tasks, err := probe.PingTasks()
|
|
if err != nil || len(tasks) != 2 {
|
|
t.Fatalf("tasks after batch = %d (err %v)", len(tasks), err)
|
|
}
|
|
for _, task := range tasks {
|
|
if task.Name != task.Target {
|
|
t.Errorf("auto name = %q, want the target %q", task.Name, task.Target)
|
|
}
|
|
if task.Interval != 60 {
|
|
t.Errorf("default interval = %d, want 60", task.Interval)
|
|
}
|
|
if !task.AutoJoin {
|
|
t.Errorf("autoJoin not carried into task %d", task.ID)
|
|
}
|
|
}
|
|
|
|
cleanPingTasks(t)
|
|
rec = rig.do(http.MethodPost, batchPath, []byte(
|
|
`{"targets":["1.1.1.1:443","8.8.8.8:53"],"name":"线路","interval":30}`), true)
|
|
env = decodeEnvelope(t, rec.Body.Bytes())
|
|
if !env.Success {
|
|
t.Fatalf("named batch refused: %s", env.Msg)
|
|
}
|
|
tasks, _ = probe.PingTasks()
|
|
if len(tasks) != 2 {
|
|
t.Fatalf("named batch tasks = %d", len(tasks))
|
|
}
|
|
for _, task := range tasks {
|
|
if !strings.HasPrefix(task.Name, "线路 · ") || task.Interval != 30 {
|
|
t.Errorf("named batch task = %+v", task)
|
|
}
|
|
}
|
|
|
|
// One bad line refuses the whole paste; nothing is created.
|
|
cleanPingTasks(t)
|
|
rec = rig.do(http.MethodPost, batchPath, []byte(
|
|
`{"targets":["1.1.1.1:443","not-a-target"]}`), true)
|
|
env = decodeEnvelope(t, rec.Body.Bytes())
|
|
if env.Success {
|
|
t.Fatal("batch with a bad target succeeded")
|
|
}
|
|
if tasks, _ := probe.PingTasks(); len(tasks) != 0 {
|
|
t.Fatalf("refused batch leaked %d tasks", len(tasks))
|
|
}
|
|
}
|
|
|
|
// V2.0.1 probe batch: the meta editor's calibration + interface pin
|
|
// fields round-trip and validate.
|
|
func TestProbeMetaCalibrationFields(t *testing.T) {
|
|
rig := newProbeTestRig(t)
|
|
db := database.GetDB()
|
|
nodeID := 77
|
|
node := &model.Node{Id: nodeID, Name: "meta-node", Address: "192.0.2.1", Port: 8443, Enable: true}
|
|
if err := db.Create(node).Error; err != nil {
|
|
t.Fatalf("create node: %v", err)
|
|
}
|
|
agent := &model.AgentNode{TokenHash: hashAgentToken("meta-token"), TokenPrefix: "meta-", Name: "meta-agent", NodeID: &nodeID}
|
|
if err := db.Create(agent).Error; err != nil {
|
|
t.Fatalf("create agent: %v", err)
|
|
}
|
|
path := "/panel/api/agent/probe-meta/77"
|
|
|
|
put := func(body string) envelope {
|
|
rec := rig.do(http.MethodPut, path, []byte(body), true)
|
|
return decodeEnvelope(t, rec.Body.Bytes())
|
|
}
|
|
base := `"price":0,"currency":"USD","billingCycle":"monthly",` +
|
|
`"expiresAt":null,"trafficLimit":0,"trafficMode":"sum","trafficResetDay":1,"public":true,"sort":0,` +
|
|
`"group":"","publicRemark":"","notify":false,"countryPin":"","ipv4Pin":"","ipv6Pin":""`
|
|
|
|
env := put(`{` + base + `,"trafficCalUpGb":2.5,"trafficCalDownGb":-1,"statIface":" eth0 "}`)
|
|
if !env.Success {
|
|
t.Fatalf("meta save with calibration refused: %s", env.Msg)
|
|
}
|
|
meta, found, err := probe.MetaFor(int64(agent.Id))
|
|
if err != nil || !found {
|
|
t.Fatalf("MetaFor: found=%v err=%v", found, err)
|
|
}
|
|
if meta.TrafficCalUpGB != 2.5 || meta.TrafficCalDownGB != -1 || meta.StatIface != "eth0" {
|
|
t.Errorf("stored meta = up %v down %v iface %q (pin must be trimmed)", meta.TrafficCalUpGB, meta.TrafficCalDownGB, meta.StatIface)
|
|
}
|
|
|
|
env = put(`{` + base + `,"statIface":"eth 0"}`)
|
|
if env.Success {
|
|
t.Error("interface name with a space accepted")
|
|
}
|
|
env = put(`{` + base + `,"statIface":"eth/0"}`)
|
|
if env.Success {
|
|
t.Error("interface name with a slash accepted")
|
|
}
|
|
}
|