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,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)
}