fix(inbounds): slim list stamps agent-hosted inbounds with the agent node's key

Agent inbounds defined centrally store an empty originNodeGuid, and
inbounds re-homed from a panel node keep the previous host's GUID, so
the inbounds page looked the agent's online set up under a key it is
never filed under. GetInboundsSlim now annotates agent-hosted
inbounds at read time with the node's effective attribution key
(the key SetAgentOnlineClients files onlines under).
This commit is contained in:
Muse
2026-10-22 15:33:51 +00:00
parent c91fb454a8
commit 3df652a67d
2 changed files with 149 additions and 0 deletions
+54
View File
@@ -196,6 +196,59 @@ func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
}
}
// annotateAgentOriginGuid fills OriginNodeGuid (read-time only, like
// annotateLocalOriginGuid) for inbounds hosted on agent-managed nodes with
// the node's effective attribution key — the exact key
// SetAgentOnlineClients files the node's reported online clients under.
//
// Agent inbounds are defined centrally on the master, so their stored
// OriginNodeGuid is either empty (created straight onto the agent node) or
// STALE: an inbound re-homed from a panel node keeps the GUID the snapshot
// merge stamped for its previous host, and nothing on the move path rewrites
// it. The inbounds page looks the online set up by that stored GUID first,
// so a re-homed agent inbound's online list never matched and its clients
// never lit up. An agent has no sub-node chain to preserve attribution for —
// the hosting node physically terminates every one of its inbounds — so the
// node's own key is always the right value, and computing it at read time
// also heals rows already carrying a stale GUID without a storage rewrite.
func (s *InboundService) annotateAgentOriginGuid(db *gorm.DB, inbounds []*model.Inbound) {
nodeIDs := make(map[int]struct{})
for _, ib := range inbounds {
if ib.NodeID != nil {
nodeIDs[*ib.NodeID] = struct{}{}
}
}
if len(nodeIDs) == 0 {
return
}
agents := agentManagedNodeIDs()
keyByNode := make(map[int]string, len(nodeIDs))
for id := range nodeIDs {
if !agents[id] {
continue
}
var nodeRow model.Node
if err := db.Select("guid").Where("id = ?", id).First(&nodeRow).Error; err != nil {
continue
}
nodeRow.Id = id
if key := effectiveNodeKey(&nodeRow); key != "" {
keyByNode[id] = key
}
}
if len(keyByNode) == 0 {
return
}
for _, ib := range inbounds {
if ib.NodeID == nil {
continue
}
if key, ok := keyByNode[*ib.NodeID]; ok {
ib.OriginNodeGuid = key
}
}
}
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
// strips every per-client field other than email / enable / comment from
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
@@ -215,6 +268,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
}
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
s.annotateAgentOriginGuid(db, inbounds)
// Top up stats rows owned by sibling inbounds (multi-attached clients)
// so the list's depleted/expiring badges see every client; the UUID/SubId
// enrichment stays skipped. Must run before slimming strips the settings.
@@ -0,0 +1,95 @@
package service
import (
"fmt"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// The inbounds page looks a node's online set up under the inbound's
// originNodeGuid (falling back to node:{id}). Agent inbounds are defined
// centrally: a fresh one stores no origin GUID, and one re-homed from a
// panel node keeps the GUID the snapshot merge stamped for its PREVIOUS
// host — while the agent's onlines are filed under the agent node's own
// effective key. The slim list must therefore stamp agent-hosted inbounds
// with the hosting node's effective key at read time, or their clients
// never show as online on the inbounds page (production: every converted
// agent node was affected). Traditional inbounds' stored origins are
// chain attribution (#4983) and must pass through untouched.
func TestGetInboundsSlim_AnnotatesAgentOriginGuid(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
svc := InboundService{}
const agentNodeID = 8201
const tradNodeID = 8202
const userID = 1
if err := db.Create(&model.Node{Id: agentNodeID, Name: "agent-node", Address: "10.0.8.1"}).Error; err != nil {
t.Fatalf("create agent node: %v", err)
}
if err := db.Create(&model.Node{Id: tradNodeID, Name: "trad-node", Address: "10.0.8.2", Port: 2053, ApiToken: "t", Guid: "trad-guid-8202"}).Error; err != nil {
t.Fatalf("create trad node: %v", err)
}
// The agent binding is what makes the node "agent-managed".
agentID := agentNodeID
if err := db.Create(&model.AgentNode{
TokenHash: fmt.Sprintf("%064d", agentNodeID),
Name: "agent-node",
NodeID: &agentID,
}).Error; err != nil {
t.Fatalf("create agent binding: %v", err)
}
mkInbound := func(tag string, nodeID *int, origin string) {
ib := &model.Inbound{
UserId: userID,
Tag: tag,
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"a@example.com","enable":true}]}`,
NodeID: nodeID,
OriginNodeGuid: origin,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", tag, err)
}
}
agentIDCopy := agentNodeID
tradIDCopy := tradNodeID
mkInbound("agent-stale", &agentIDCopy, "guid-of-previous-panel-host")
mkInbound("agent-fresh", &agentIDCopy, "")
mkInbound("trad-synced", &tradIDCopy, "trad-guid-8202")
slim, err := svc.GetInboundsSlim(userID)
if err != nil {
t.Fatalf("GetInboundsSlim: %v", err)
}
byTag := make(map[string]*model.Inbound, len(slim))
for _, ib := range slim {
byTag[ib.Tag] = ib
}
wantKey := fmt.Sprintf("node:%d", agentNodeID)
if got := byTag["agent-stale"].OriginNodeGuid; got != wantKey {
t.Errorf("agent-stale originNodeGuid = %q, want %q (stale host GUID healed to the agent's key)", got, wantKey)
}
if got := byTag["agent-fresh"].OriginNodeGuid; got != wantKey {
t.Errorf("agent-fresh originNodeGuid = %q, want %q", got, wantKey)
}
if got := byTag["trad-synced"].OriginNodeGuid; got != "trad-guid-8202" {
t.Errorf("trad-synced originNodeGuid = %q, want stored GUID untouched", got)
}
// Read-time only: the stored value must not be rewritten.
var stored model.Inbound
if err := db.Where("tag = ?", "agent-stale").First(&stored).Error; err != nil {
t.Fatalf("reload agent-stale: %v", err)
}
if stored.OriginNodeGuid != "guid-of-previous-panel-host" {
t.Errorf("stored origin_node_guid = %q, want the stale value preserved (annotation is read-time only)", stored.OriginNodeGuid)
}
}