fix(v1.0.2): agent placeholder in inbound export links + sync job wiping agent onlines

Fix 1: InboundsPage hostOverrideFor read NodeView.address raw; agent
nodes store the literal placeholder "agent" there, so exported inbound
links came out as vless://...@agent:<port>. Route the pick through the
new shareHostForNode (lib/xray/inbound-link): resolved ipv4/ipv6 first,
raw address only as a non-placeholder fallback; the placeholder can
never reach a link and the fallback chain continues when an agent has
no telemetry yet. Traditional nodes (address only) are unchanged.

Fix 2: agent nodes showed no online users on the nodes page. The
attribution keys were NOT misaligned (write effectiveNodeKey ==
read effectiveNodeGuid == "node:{id}" for guid-less agent nodes,
pinned by TestAgentNodeOnlineEmailsInNodeList). The node traffic sync
job (every 5s) pull-syncs every enabled+online node as if it were a
panel; agent nodes have no panel API, so FetchTrafficSnapshot always
failed and its failure branch ClearNodeOnlineClients wiped the online
tree the agent had just pushed via SetAgentOnlineClients. syncOne now
skips agent-managed nodes (push-only), mirroring the heartbeat job.
Pinned by TestSyncOne_AgentNodeKeepsReportedOnlines (fails pre-fix
with byGuid emptied after one tick, passes post-fix).
This commit is contained in:
Muse
2026-10-22 20:11:08 +00:00
parent 8e89960474
commit 0e9c2031fb
6 changed files with 270 additions and 1 deletions
@@ -0,0 +1,89 @@
package job
import (
"testing"
"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/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// REPRO (V1.0.2 fix 2): agent nodes are push-only — their online clients
// arrive via agent traffic reports (InboundService.SetAgentOnlineClients)
// and are keyed exactly where the node list looks them up (the service-level
// alignment is pinned by TestReproAgentNodeOnlineEmailsInNodeList). But the
// traffic sync job's pull loop treats every enabled+online node as a panel:
// for an agent node the snapshot fetch can never succeed (address is the
// literal placeholder "agent", there is no panel API behind it), and the
// fetch-failure branch calls ClearNodeOnlineClients — wiping the agent's
// pushed online state on every tick. The heartbeat job already skips
// agent-managed nodes for probing; the sync job must not pull them either.
func TestSyncOne_AgentNodeKeepsReportedOnlines(t *testing.T) {
setupIntegrationDB(t)
db := database.GetDB()
node := &model.Node{
Name: "agent-sync-node",
Address: "agent",
Port: 2053,
Enable: true,
Status: "online",
}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
if err := db.Create(&model.AgentNode{
TokenHash: "hash-agent-sync-node",
Name: "agent-sync-node",
NodeID: &node.Id,
}).Error; err != nil {
t.Fatalf("create agent binding: %v", err)
}
// Production form: the agent node actually hosts inbounds (without any
// node-bound inbound in the DB, NodeService.GetAll takes its early
// return and never fills per-node stats at all).
if err := db.Create(&model.Inbound{
Tag: "agent-sync-in-1", Enable: true, Port: 46994, Protocol: model.VLESS,
Settings: `{"clients":[]}`, NodeID: &node.Id,
}).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
inboundSvc := service.InboundService{}
inboundSvc.ClearNodeOnlineClients(node.Id)
t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(node.Id) })
inboundSvc.SetAgentOnlineClients(node.Id, []string{"agent-online@example.com"})
if got := inboundSvc.GetOnlineClients(); len(got) != 1 {
t.Fatalf("precondition: online union = %v, want the agent-reported email", got)
}
j := NewNodeTrafficSyncJob()
j.syncOne(runtime.NewManager(), node, false)
byGuid := inboundSvc.GetOnlineClientsByGuid()
found := false
for _, emails := range byGuid {
for _, e := range emails {
if e == "agent-online@example.com" {
found = true
}
}
}
if !found {
t.Fatalf("agent onlines wiped by sync tick: byGuid = %v", byGuid)
}
views, err := (&service.NodeService{}).GetNodeTreeView()
if err != nil {
t.Fatalf("GetNodeTreeView: %v", err)
}
for _, v := range views {
if v.Id == node.Id {
if len(v.OnlineEmails) != 1 || v.OnlineEmails[0] != "agent-online@example.com" {
t.Fatalf("node view online emails = %v, want [agent-online@example.com]", v.OnlineEmails)
}
}
}
}
+13
View File
@@ -349,6 +349,19 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
// emails online on that node this tick, feeding the delta broadcast above the
// snapshot threshold; nil on any failure path.
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
// Agent-managed nodes are push-only: their traffic and online state
// arrive via agent reports (SetAgentOnlineClients), and there is no
// panel API to pull a snapshot from — the node address is the literal
// "agent" placeholder. Pulling one always fails, and the failure
// branch below clears the node's whole online subtree, wiping the
// state the agent just pushed on every 5s tick (the node list then
// shows agent nodes permanently dark while panel nodes, whose fetch
// succeeds and re-files a fresh tree in the same tick, are fine).
// The heartbeat job already treats agent nodes the same way: no
// probing, agent-driven state only.
if j.nodeService.IsAgentManaged(n.Id) {
return nil
}
rt, err := mgr.RemoteFor(n)
if err != nil {
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
@@ -0,0 +1,78 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// An agent node's reported online clients must surface in the node list
// aggregation (NodeView.OnlineEmails), like they do on the inbounds page.
// Production shape: the agent node row carries the create form's placeholder
// address and no panel GUID of its own; an agent_nodes binding row ties the
// agent token to the node; the node hosts inbounds.
//
// This pins the write/read key alignment: SetAgentOnlineClients files the
// list under effectiveNodeKey of the guid-only node row, and the node list
// looks it up under effectiveNodeGuid of the full row — for a guid-less
// agent node both are the synthetic "node:{id}" key. (The node page going
// dark in production was NOT a key mismatch: the traffic sync job kept
// deleting the filed tree — see the job package's
// TestSyncOne_AgentNodeKeepsReportedOnlines.)
func TestAgentNodeOnlineEmailsInNodeList(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 7601
ibSvc := InboundService{}
ibSvc.ClearNodeOnlineClients(nodeID)
t.Cleanup(func() { ibSvc.ClearNodeOnlineClients(nodeID) })
if err := db.Create(&model.Node{
Id: nodeID, Name: "agent-node-7601", Address: "agent", Enable: true, Status: "online",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
nid := nodeID
if err := db.Create(&model.AgentNode{
TokenHash: "hash-7601", NodeID: &nid,
}).Error; err != nil {
t.Fatalf("create agent binding: %v", err)
}
if err := db.Create(&model.Inbound{
Tag: "agent-in-1", Enable: true, Port: 46994, Protocol: model.VLESS,
Settings: `{"clients":[]}`, NodeID: &nid,
}).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
ibSvc.SetAgentOnlineClients(nodeID, []string{"online@example.com"})
var row model.Node
if err := db.Select("guid").Where("id = ?", nodeID).First(&row).Error; err != nil {
t.Fatalf("load node guid: %v", err)
}
row.Id = nodeID
if writeKey := effectiveNodeKey(&row); writeKey != "node:7601" {
t.Fatalf("write key = %q, want node:7601", writeKey)
}
assertStringSet(t, "byGuid[node:7601]", ibSvc.GetOnlineClientsByGuid()["node:7601"],
[]string{"online@example.com"})
views, err := (&NodeService{}).GetNodeTreeView()
if err != nil {
t.Fatalf("GetNodeTreeView: %v", err)
}
for _, v := range views {
if v.Id == nodeID {
if v.OnlineCount != 1 {
t.Fatalf("agent node OnlineCount = %d, want 1", v.OnlineCount)
}
assertStringSet(t, "agent node OnlineEmails", v.OnlineEmails,
[]string{"online@example.com"})
return
}
}
t.Fatalf("agent node %d missing from node list views", nodeID)
}