agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
126 lines
4.3 KiB
Go
126 lines
4.3 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// V2.1.0: a probe-only agent's config poll carries the flag and NO
|
|
// core payload (singbox_config null, hash empty), while the probe
|
|
// keys and a pending update intent still ride along — a probe-only
|
|
// node must stay reachable for probes and one-click agent updates.
|
|
// The control agent on a normal node keeps receiving its payload.
|
|
func TestConfigProbeOnlyWithholdsCorePayload(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()
|
|
|
|
seed := func(name, token string, probeOnly bool) {
|
|
node := &model.Node{Name: name, Address: "10.0.9.4"}
|
|
if err := db.Create(node).Error; err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
agent := &model.AgentNode{
|
|
TokenHash: hashAgentToken(token),
|
|
TokenPrefix: token[:12],
|
|
Name: name,
|
|
NodeID: &node.Id,
|
|
AgentVersion: "A2.0.3",
|
|
ProbeOnly: probeOnly,
|
|
}
|
|
if probeOnly {
|
|
agent.UpdateRequestedAt = time.Now().Unix()
|
|
agent.UpdateState = "pending"
|
|
agent.UpdateTarget = "A2.1.0"
|
|
}
|
|
if err := db.Create(agent).Error; err != nil {
|
|
t.Fatalf("seed agent: %v", err)
|
|
}
|
|
inbound := &model.Inbound{
|
|
Enable: true,
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
Tag: "inbound-" + name,
|
|
NodeID: &node.Id,
|
|
Settings: `{"clients":[],"decryption":"none","encryption":"none","fallbacks":[]}`,
|
|
StreamSettings: `{"network":"tcp","security":"reality","realitySettings":` +
|
|
`{"serverNames":["www.amd.com"],"shortIds":["ab12cd34"],"privateKey":"FAKEPRIV",` +
|
|
`"settings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`,
|
|
SingboxConfig: `{"type":"vless","tag":"inbound-` + name + `","listen_port":443,` +
|
|
`"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,` +
|
|
`"handshake":{"server":"www.amd.com","server_port":443},"private_key":"FAKEPRIV",` +
|
|
`"short_id":["ab12cd34"]}},"users":[]}`,
|
|
}
|
|
if err := db.Create(inbound).Error; err != nil {
|
|
t.Fatalf("seed inbound: %v", err)
|
|
}
|
|
}
|
|
const probeToken = "probe-only-token-0000000000000000000001"
|
|
const normalToken = "probe-normal-token-00000000000000000001"
|
|
seed("probe-node", probeToken, true)
|
|
seed("normal-node", normalToken, false)
|
|
|
|
engine := gin.New()
|
|
NewAgentController(engine.Group("/panel/api/agent"))
|
|
get := func(token string) map[string]any {
|
|
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
engine.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("config response not JSON: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
resp := get(probeToken)
|
|
if resp["probe_only"] != true {
|
|
t.Fatalf("probe_only = %v, want true", resp["probe_only"])
|
|
}
|
|
if cfg, present := resp["singbox_config"]; !present || cfg != nil {
|
|
t.Fatalf("singbox_config = %v (present=%v), want JSON null", cfg, present)
|
|
}
|
|
if h := resp["singbox_config_hash"]; h != "" {
|
|
t.Fatalf("singbox_config_hash = %v, want empty", h)
|
|
}
|
|
if _, present := resp["probe_tasks"]; !present {
|
|
t.Fatal("probe_tasks missing: probe-only nodes still run latency probes")
|
|
}
|
|
if _, present := resp["probe_fast_interval"]; !present {
|
|
t.Fatal("probe_fast_interval missing on probe-only config")
|
|
}
|
|
upd, _ := resp["agent_update"].(map[string]any)
|
|
if upd == nil || upd["target"] != "A2.1.0" {
|
|
t.Fatalf("agent_update = %v, want the pending intent (probe-only nodes still update)", resp["agent_update"])
|
|
}
|
|
|
|
resp = get(normalToken)
|
|
if resp["probe_only"] != false {
|
|
t.Fatalf("probe_only = %v, want false for a normal node", resp["probe_only"])
|
|
}
|
|
if cfg, _ := resp["singbox_config"].(map[string]any); cfg == nil {
|
|
t.Fatalf("normal node lost its singbox payload: %v", resp["singbox_config"])
|
|
}
|
|
if h, _ := resp["singbox_config_hash"].(string); h == "" {
|
|
t.Fatal("normal node singbox_config_hash empty")
|
|
}
|
|
}
|