Files
3x-ui-agent/internal/web/controller/agent_fast_report_test.go
T
Muse db6f666ff7
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
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
probe: fast report stamps last-seen; snapshot TTL 900ms (V2.0.3)
2026-10-23 18:38:56 +08:00

265 lines
9.2 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, and the config poll carries the cadence + the node's
// counted-interface pin. Since V2.0.3 the fast report also stamps
// last-seen (the hub's themes gate batches on its freshness); it
// originally did not — see the probeReport handler comment.
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}}`
// Age the stamp first so the fast report's touch is observable
// even within the same millisecond as the heartbeat's.
stale := seenAfterHeartbeat - 120_000
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
t.Fatalf("age last-seen: %v", err)
}
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 <= stale {
t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, 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")
}
}