diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 09d8080a2..f761d900b 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -209,19 +209,20 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi now := time.Now().UnixMilli() // originGuidFor attributes a synced inbound to the panel that physically - // hosts it: inbounds the node forwards from its own sub-nodes already carry - // a non-empty OriginNodeGuid (kept as-is across hops); the node's own local - // inbounds report empty, so they are attributed to the node's own key — - // effectiveNodeKey, which is the node's panelGuid unless that GUID is - // ambiguous (shared with another node or the master, i.e. a cloned server), - // in which case it falls back to the node-unique id so #4983 attribution - // doesn't collapse two physical nodes into one bucket. + // hosts it. A node's OWN inbounds report either an empty origin or — on + // builds that set it locally — the node's own panelGuid; both resolve to + // selfKey, which is the node's panelGuid unless that GUID is ambiguous + // (shared with another node or the master, i.e. a cloned server), in which + // case it falls back to the node-unique id so #4983 attribution doesn't + // collapse two physical nodes into one bucket. Only a DIFFERENT, non-empty + // origin (an inbound the node forwards from its own sub-node) is kept as-is, + // so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3. var nodeRow model.Node db.Select("guid").Where("id = ?", nodeID).First(&nodeRow) selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid}) guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid originGuidFor := func(snapIb *model.Inbound) string { - if snapIb.OriginNodeGuid != "" { + if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid { return snapIb.OriginNodeGuid } return selfKey diff --git a/internal/web/service/node_origin_guid_test.go b/internal/web/service/node_origin_guid_test.go index fa155aa75..765398c9d 100644 --- a/internal/web/service/node_origin_guid_test.go +++ b/internal/web/service/node_origin_guid_test.go @@ -70,6 +70,67 @@ func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) { } } +// A cloned node reports its OWN inbound with its own (duplicated) panelGuid as +// the origin. That must be remapped to the node-unique key, not stored verbatim +// — otherwise origin_node_guid keeps the shared GUID while online is keyed by +// the node-unique key, and the inbound page reads an empty bucket (shows +// offline). A genuinely forwarded sub-node GUID is still kept across the hop. +func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + // Two nodes share one panelGuid (cloned servers). + for _, n := range []*model.Node{ + {Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"}, + {Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"}, + } { + if err := db.Create(n).Error; err != nil { + t.Fatalf("create node %s: %v", n.Name, err) + } + } + + snap := &runtime.TrafficSnapshot{ + Inbounds: []*model.Inbound{ + { // node 1's OWN inbound, reporting its own (shared) panelGuid as origin + Tag: "own-443-tcp", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + OriginNodeGuid: "dup", + }, + { // forwarded from a sub-node with a distinct guid — kept across the hop + Tag: "fwd-8443-tcp", + Enable: true, + Port: 8443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + OriginNodeGuid: "child-guid", + }, + }, + } + + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + origin := func(tag string) string { + var ib model.Inbound + if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil { + t.Fatalf("load inbound %q: %v", tag, err) + } + return ib.OriginNodeGuid + } + + if og := origin("own-443-tcp"); og != "node:1" { + t.Fatalf("cloned node's own inbound origin = %q, want node:1 (remapped from shared GUID)", og) + } + if og := origin("fwd-8443-tcp"); og != "child-guid" { + t.Fatalf("forwarded inbound origin = %q, want child-guid (kept across the hop)", og) + } +} + func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) { setupConflictDB(t) db := database.GetDB()