feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005)

* feat(nodes): add stable panel GUID identity (multi-hop phase 0)

Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983).

Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on.

Refs #4983

* feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1)

Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched.

The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id.

Refs #4983

* feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2)

Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983).

xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly.

Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983

Refs #4983

* feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a)

Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983).

The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next.

Refs #4983

* feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b)

The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983).

Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983

Refs #4983

* i18n(nodes): translate subNode/subNodeTip across all locales

Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation.

Refs #4983
This commit is contained in:
Sanaei
2026-06-06 12:33:39 +02:00
committed by GitHub
parent 6ed6f57b5c
commit e6c1ce9aa9
37 changed files with 1007 additions and 227 deletions

View File

@@ -72,7 +72,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/onlinesByNode", a.onlinesByNode)
g.POST("/onlinesByGuid", a.onlinesByGuid)
g.POST("/activeInbounds", a.activeInbounds)
g.POST("/lastOnline", a.lastOnline)
}
@@ -417,12 +417,12 @@ func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
func (a *ClientController) onlinesByNode(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
func (a *ClientController) onlinesByGuid(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
}
func (a *ClientController) activeInbounds(c *gin.Context) {
jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {

View File

@@ -43,7 +43,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
}
func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetAll()
nodes, err := a.nodeService.GetNodeTree()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return

View File

@@ -56,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/getMigration", a.getMigration)
g.GET("/getNewUUID", a.getNewUUID)
g.GET("/getWebCertFiles", a.getWebCertFiles)
g.GET("/descendants", a.descendants)
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
g.GET("/getNewmldsa65", a.getNewmldsa65)
g.GET("/getNewmlkem768", a.getNewmlkem768)
@@ -334,6 +335,14 @@ func (a *ServerController) importDB(c *gin.Context) {
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
}
// descendants publishes read-only summaries of the nodes this panel manages so
// a parent panel can surface them as transitive sub-nodes in a chained
// topology. Called by the parent via the node's API token (#4983).
func (a *ServerController) descendants(c *gin.Context) {
data, err := (&service.NodeService{}).LocalDescendants()
jsonObj(c, data, err)
}
// getWebCertFiles returns this panel's own web TLS certificate and key file
// paths. The central panel calls it on a node (via the node's API token) so
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist

View File

@@ -59,7 +59,7 @@ func (j *NodeHeartbeatJob) Run() {
if !websocket.HasClients() {
return
}
updated, err := j.nodeService.GetAll()
updated, err := j.nodeService.GetNodeTree()
if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return
@@ -79,4 +79,14 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
}
// Learn the nodes this node manages so the panel can surface them as
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
// be spent. Drop them when the node is unreachable.
if patch.Status == "online" {
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
j.nodeService.RefreshDescendants(dctx, n)
dcancel()
} else {
j.nodeService.ClearDescendants(n.Id)
}
}

View File

@@ -125,8 +125,8 @@ func (j *NodeTrafficSyncJob) Run() {
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnline,
})

View File

@@ -107,8 +107,8 @@ func (j *XrayTrafficJob) Run() {
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnlineMap,
})

View File

@@ -370,6 +370,24 @@ func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
return &files, nil
}
// GetDescendants fetches the node's read-only summaries of the nodes IT
// manages, so this panel can surface them as transitive sub-nodes in a chained
// topology (#4983). Best-effort: an old-build node without the endpoint returns
// an error the caller ignores.
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
if err != nil {
return nil, err
}
var out []model.NodeSummary
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &out); err != nil {
return nil, fmt.Errorf("decode descendants: %w", err)
}
}
return out, nil
}
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
_, err := r.do(ctx, http.MethodPost,
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
@@ -382,8 +400,14 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
}
type TrafficSnapshot struct {
Inbounds []*model.Inbound
OnlineEmails []string
Inbounds []*model.Inbound
OnlineEmails []string
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
// its panelGuid plus every descendant under theirs). Preferred over the flat
// OnlineEmails so the master can attribute deeply nested clients to the real
// node across a chain (#4983). Empty when the node is an old build without
// the per-GUID endpoint — OnlineEmails is the fallback then.
OnlineTree map[string][]string
LastOnlineMap map[string]int64
}
@@ -398,11 +422,19 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
return nil, fmt.Errorf("decode inbound list: %w", err)
}
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
// node is an old build without the per-GUID endpoint (#4983).
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
if err == nil && len(envTree.Obj) > 0 {
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
}
if len(snap.OnlineTree) == 0 {
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
}
}
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)

View File

@@ -231,9 +231,30 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
}
s.enrichClientStats(db, inbounds)
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
return inbounds, nil
}
// annotateLocalOriginGuid fills OriginNodeGuid for this panel's OWN inbounds
// (NodeID == nil) with the panel's stable GUID; inbounds synced from a node
// already carry the originating node's GUID. Read-time only (not persisted) so
// the per-inbound online view can scope by GUID uniformly across a chain of
// nodes (#4983).
func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
if len(inbounds) == 0 {
return
}
guid := s.panelGuid()
if guid == "" {
return
}
for _, ib := range inbounds {
if ib.OriginNodeGuid == "" && ib.NodeID == nil {
ib.OriginNodeGuid = guid
}
}
}
// 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
@@ -252,6 +273,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
return nil, err
}
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
for _, ib := range inbounds {
ib.Settings = slimSettingsClients(ib.Settings)
}
@@ -1453,6 +1475,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
db := database.GetDB()
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 GUID. An
// empty result (old-build node with no GUID yet) leaves attribution to the
// node_id fallback downstream (#4983).
var nodeRow model.Node
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
originGuidFor := func(snapIb *model.Inbound) string {
if snapIb.OriginNodeGuid != "" {
return snapIb.OriginNodeGuid
}
return nodeRow.Guid
}
var central []model.Inbound
if err := db.Model(model.Inbound{}).
Where("node_id = ?", nodeID).
@@ -1598,6 +1635,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
newIb := model.Inbound{
UserId: defaultUserId,
NodeID: &nodeID,
OriginNodeGuid: originGuidFor(snapIb),
Tag: chosenTag,
Listen: snapIb.Listen,
Port: snapIb.Port,
@@ -1645,6 +1683,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["up"] = snapIb.Up
updates["down"] = snapIb.Down
}
// Physical-home attribution is independent of config-dirty state, so
// keep it current even while the node has pending offline edits. Writes
// once to backfill an existing row, then stays equal (#4983).
if og := originGuidFor(snapIb); c.OriginNodeGuid != og {
updates["origin_node_guid"] = og
}
if !dirty && (c.Settings != snapIb.Settings ||
c.Remark != snapIb.Remark ||
@@ -1933,7 +1977,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
committed = true
if p != nil {
p.SetNodeOnlineClients(nodeID, snap.OnlineEmails)
tree := snap.OnlineTree
if len(tree) == 0 && len(snap.OnlineEmails) > 0 {
// Old-build node (no GUID tree): key its flat online list under its
// own effective identity so attribution still works for that branch.
effectiveGuid := nodeRow.Guid
if effectiveGuid == "" {
effectiveGuid = synthNodeGuid(nodeID)
}
tree = map[string][]string{effectiveGuid: snap.OnlineEmails}
}
p.SetNodeOnlineTree(nodeID, tree)
}
return structuralChange, nil
@@ -3634,23 +3688,46 @@ func (s *InboundService) GetOnlineClients() []string {
return p.GetOnlineClients()
}
func (s *InboundService) GetOnlineClientsByNode() map[int][]string {
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
// node that physically hosts each set: this panel's own clients under its own
// GUID, plus every node in the tree under its GUID (#4983). Replaces the old
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
if p == nil {
return map[int][]string{}
return map[string][]string{}
}
return p.GetOnlineClientsByNode()
out := p.GetMergedNodeTrees()
if local := p.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
}
return out
}
func (s *InboundService) GetActiveInboundsByNode() map[int][]string {
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
// the grace window for THIS panel, under its own GUID. Remote nodes don't
// report per-inbound activity, so a GUID missing from the map means "don't
// gate" for that node's inbounds.
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
if p == nil {
return map[int][]string{}
return map[string][]string{}
}
return p.GetActiveInboundsByNode()
active := p.GetLocalActiveInbounds()
if len(active) == 0 {
return map[string][]string{}
}
guid := s.panelGuid()
if guid == "" {
return map[string][]string{}
}
return map[string][]string{guid: active}
}
func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
if p != nil {
p.SetNodeOnlineClients(nodeID, emails)
p.SetNodeOnlineTree(nodeID, tree)
}
}
@@ -3660,6 +3737,43 @@ func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
}
}
// panelGuid returns this panel's stable self-identifier, used to key the local
// panel's own clients in the per-node online maps (#4983).
func (s *InboundService) panelGuid() string {
guid, _ := (&SettingService{}).GetPanelGuid()
return guid
}
// synthNodeGuid is the stable per-node fallback identity for a directly-attached
// node whose panel hasn't reported a panelGuid yet (old build). Node ids are
// master-local, so this only composes for direct nodes — exactly the pre-#4983
// flat-topology case where an old-build node appears.
func synthNodeGuid(nodeID int) string {
return fmt.Sprintf("node:%d", nodeID)
}
// mergeEmails returns the deduped union of two email slices.
func mergeEmails(a, b []string) []string {
if len(a) == 0 {
return b
}
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]string, 0, len(a)+len(b))
for _, e := range a {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
for _, e := range b {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
return out
}
func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
db := database.GetDB()
var rows []xray.ClientTraffic

View File

@@ -30,6 +30,7 @@ type HeartbeatPatch struct {
LatencyMs int
XrayVersion string
PanelVersion string
Guid string
CpuPct float64
MemPct float64
UptimeSecs uint64
@@ -224,9 +225,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
Select("inbound_id, email, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", inboundIDs).
Scan(&trafficRows).Error; err == nil {
onlineByNodeSet := s.onlineEmailsByNode()
depletedByNode := make(map[int]int)
onlineByNode := make(map[int]int)
for _, row := range trafficRows {
nodeID, ok := nodeByInbound[row.InboundID]
if !ok {
@@ -237,38 +236,45 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
if expired || exhausted || !row.Enable {
depletedByNode[nodeID]++
}
// Scope online by the node the inbound lives on: a client online
// on one node must not count as online on another.
if set, ok := onlineByNodeSet[nodeID]; ok {
if _, isOnline := set[row.Email]; isOnline {
onlineByNode[nodeID]++
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
for _, n := range nodes {
n.InboundCount = len(inboundsByNode[n.Id])
n.DepletedCount = depletedByNode[n.Id]
n.OnlineCount = onlineByNode[n.Id]
// Online is attributed to the node that physically hosts the client
// (by GUID): a client on a sub-node counts under the sub-node, not
// the intermediate node it syncs through (#4983).
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
}
}
return nodes, nil
}
func (s *NodeService) onlineEmailsByNode() map[int]map[string]struct{} {
func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
svc := InboundService{}
byNode := svc.GetOnlineClientsByNode()
out := make(map[int]map[string]struct{}, len(byNode))
for nodeID, emails := range byNode {
byGuid := svc.GetOnlineClientsByGuid()
out := make(map[string]map[string]struct{}, len(byGuid))
for guid, emails := range byGuid {
set := make(map[string]struct{}, len(emails))
for _, email := range emails {
set[email] = struct{}{}
}
out[nodeID] = set
out[guid] = set
}
return out
}
// effectiveNodeGuid is a node's stable online-attribution key: its reported
// panelGuid, or a master-local synthetic id when the node is an old build that
// hasn't reported one yet (#4983).
func effectiveNodeGuid(n *model.Node) string {
if n.Guid != "" {
return n.Guid
}
return synthNodeGuid(n.Id)
}
func (s *NodeService) GetById(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
@@ -469,6 +475,11 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"uptime_secs": p.UptimeSecs,
"last_error": p.LastError,
}
// Only learn the GUID; never clear a known one if an old-build node (or a
// failed probe) reports none, so the stable identity survives blips.
if p.Guid != "" {
updates["guid"] = p.Guid
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
@@ -599,6 +610,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
Version string `json:"version"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
} `json:"obj"`
}
@@ -617,6 +629,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
}
patch.XrayVersion = o.Xray.Version
patch.PanelVersion = o.PanelVersion
patch.Guid = o.PanelGuid
patch.UptimeSecs = o.Uptime
return patch, nil
}

View File

@@ -0,0 +1,71 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
)
// #4983: a synced inbound's OriginNodeGuid must point at the panel that
// physically hosts it. A node's own local inbound (empty origin in its
// snapshot) is attributed to the node's own GUID; an inbound the node forwards
// from its own sub-node (non-empty origin) keeps that deeper GUID across the
// hop — so a chained Node1->Node2->Node3 attributes Node3's inbounds to Node3.
func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 1
if err := db.Create(&model.Node{
Id: nodeID,
Name: "node2",
Address: "10.0.0.2",
Port: 2053,
ApiToken: "t",
Guid: "node2-guid",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{ // node2's own local inbound — reports no origin
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
},
{ // forwarded from node2's sub-node (node3) — carries node3's guid
Tag: "in-8443-tcp",
Enable: true,
Port: 8443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
OriginNodeGuid: "node3-guid",
},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(nodeID, 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("in-443-tcp"); og != "node2-guid" {
t.Fatalf("local inbound origin = %q, want node2-guid (the node's own GUID)", og)
}
if og := origin("in-8443-tcp"); og != "node3-guid" {
t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og)
}
}

226
web/service/node_tree.go Normal file
View File

@@ -0,0 +1,226 @@
package service
import (
"context"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
)
// LocalDescendants returns this panel's read-only summaries of the nodes it
// directly manages, so a parent panel can surface them as transitive sub-nodes
// (#4983). Only nodes with a known GUID are included — a stable identity is
// required to attribute them one hop up. Not recursive: each panel reports its
// own direct nodes, and a master walks one level via each direct node's
// endpoint, which covers the Node1 -> Node2 -> Node3 case.
func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
selfGuid, _ := (&SettingService{}).GetPanelGuid()
db := database.GetDB()
var nodes []*model.Node
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
return nil, err
}
out := make([]model.NodeSummary, 0, len(nodes))
for _, n := range nodes {
if n.Guid == "" {
continue
}
out = append(out, model.NodeSummary{
Guid: n.Guid,
ParentGuid: selfGuid,
Name: n.Name,
Address: n.Address,
Scheme: n.Scheme,
Port: n.Port,
Status: n.Status,
LastHeartbeat: n.LastHeartbeat,
LatencyMs: n.LatencyMs,
PanelVersion: n.PanelVersion,
XrayVersion: n.XrayVersion,
})
}
return out, nil
}
var (
nodeDescendantsMu sync.RWMutex
nodeDescendantsCache = map[int][]model.NodeSummary{}
)
// RefreshDescendants pulls a direct node's published sub-node summaries and
// caches them keyed by node id. Best-effort: a fetch error keeps the last good
// set (the node may be briefly unreachable). Called from the heartbeat job.
func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) {
if n == nil {
return
}
mgr := runtime.GetManager()
if mgr == nil {
return
}
rt, err := mgr.RemoteFor(n)
if err != nil {
return
}
summaries, err := rt.GetDescendants(ctx)
if err != nil {
return
}
nodeDescendantsMu.Lock()
if len(summaries) == 0 {
delete(nodeDescendantsCache, n.Id)
} else {
nodeDescendantsCache[n.Id] = summaries
}
nodeDescendantsMu.Unlock()
}
// ClearDescendants drops a node's cached sub-node summaries (its probe failed).
func (s *NodeService) ClearDescendants(nodeID int) {
nodeDescendantsMu.Lock()
delete(nodeDescendantsCache, nodeID)
nodeDescendantsMu.Unlock()
}
func cachedDescendants() []model.NodeSummary {
nodeDescendantsMu.RLock()
defer nodeDescendantsMu.RUnlock()
out := make([]model.NodeSummary, 0)
for _, list := range nodeDescendantsCache {
out = append(out, list...)
}
return out
}
// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned
// from them, with per-GUID counts so each node shows only the inbounds/online
// it physically hosts (#4983). Direct nodes carry the master's own GUID as
// ParentGuid; a transitive node carries its parent node's GUID. Transitive
// nodes are read-only projections (Id == 0). Used by the Nodes page and the
// heartbeat broadcast — never for probing/syncing, which stay on GetAll.
func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
nodes, err := s.GetAll()
if err != nil {
return nodes, err
}
selfGuid, _ := (&SettingService{}).GetPanelGuid()
directGuids := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
n.ParentGuid = selfGuid
if n.Guid != "" {
directGuids[n.Guid] = struct{}{}
}
}
seen := make(map[string]struct{})
var transitive []*model.Node
for _, sum := range cachedDescendants() {
if sum.Guid == "" {
continue
}
if _, ok := directGuids[sum.Guid]; ok {
continue // already shown as a direct node
}
if _, ok := seen[sum.Guid]; ok {
continue
}
seen[sum.Guid] = struct{}{}
transitive = append(transitive, &model.Node{
Guid: sum.Guid,
ParentGuid: sum.ParentGuid,
Name: sum.Name,
Address: sum.Address,
Scheme: sum.Scheme,
Port: sum.Port,
Status: sum.Status,
LastHeartbeat: sum.LastHeartbeat,
LatencyMs: sum.LatencyMs,
PanelVersion: sum.PanelVersion,
XrayVersion: sum.XrayVersion,
Transitive: true,
})
}
if len(transitive) == 0 {
return nodes, nil
}
all := make([]*model.Node, 0, len(nodes)+len(transitive))
all = append(all, nodes...)
all = append(all, transitive...)
s.recountByGuid(all, selfGuid)
return all, nil
}
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
// in the tree, keyed by the GUID that physically hosts each inbound, so a direct
// node shows only its own inbounds and each transitive node shows its own
// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so
// this only changes behaviour once a transitive node exists.
func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
db := database.GetDB()
type ibRow struct {
Id int
NodeID *int `gorm:"column:node_id"`
OriginNodeGuid string `gorm:"column:origin_node_guid"`
}
var ibRows []ibRow
if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
return
}
effByInbound := make(map[int]string, len(ibRows))
inboundCountByGuid := make(map[string]int)
ids := make([]int, 0, len(ibRows))
for _, r := range ibRows {
guid := r.OriginNodeGuid
if guid == "" {
if r.NodeID != nil {
guid = synthNodeGuid(*r.NodeID)
} else {
guid = selfGuid
}
}
effByInbound[r.Id] = guid
inboundCountByGuid[guid]++
ids = append(ids, r.Id)
}
now := time.Now().UnixMilli()
depletedByGuid := make(map[string]int)
if len(ids) > 0 {
type tRow struct {
InboundID int `gorm:"column:inbound_id"`
Enable bool
Total int64
Up int64
Down int64
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var tRows []tRow
if err := db.Table("client_traffics").
Select("inbound_id, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", ids).Scan(&tRows).Error; err == nil {
for _, row := range tRows {
guid, ok := effByInbound[row.InboundID]
if !ok {
continue
}
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
if expired || exhausted || !row.Enable {
depletedByGuid[guid]++
}
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
for _, n := range nodes {
guid := effectiveNodeGuid(n)
n.InboundCount = inboundCountByGuid[guid]
n.OnlineCount = len(onlineByGuid[guid])
n.DepletedCount = depletedByGuid[guid]
}
}

View File

@@ -0,0 +1,81 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
// #4983: a transitive sub-node learned from a direct node must surface as its
// own read-only entry nested under its parent, and per-GUID counts must split a
// direct node's own inbounds from its sub-nodes'.
func TestGetNodeTree_SurfacesTransitiveNodeNestedUnderParent(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
svc := NodeService{}
selfGuid, _ := (&SettingService{}).GetPanelGuid()
if err := db.Create(&model.Node{
Id: 1, Name: "Node2", Address: "10.0.0.2", Port: 2053,
ApiToken: "t", Guid: "node2-guid", Status: "online",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
// Node2's own inbound and a transitive inbound physically on Node3
// (managed through Node2, so node_id = Node2 but origin = Node3).
nid := 1
if err := db.Create(&model.Inbound{Tag: "n1-own", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node2-guid"}).Error; err != nil {
t.Fatalf("create own inbound: %v", err)
}
if err := db.Create(&model.Inbound{Tag: "n1-via", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node3-guid"}).Error; err != nil {
t.Fatalf("create transitive inbound: %v", err)
}
// The heartbeat learned that Node2 manages Node3.
nodeDescendantsMu.Lock()
nodeDescendantsCache[1] = []model.NodeSummary{{
Guid: "node3-guid", ParentGuid: "node2-guid", Name: "Node3", Address: "10.0.0.3", Status: "online",
}}
nodeDescendantsMu.Unlock()
t.Cleanup(func() {
nodeDescendantsMu.Lock()
nodeDescendantsCache = map[int][]model.NodeSummary{}
nodeDescendantsMu.Unlock()
})
tree, err := svc.GetNodeTree()
if err != nil {
t.Fatalf("GetNodeTree: %v", err)
}
var node2, node3 *model.Node
for _, n := range tree {
switch n.Guid {
case "node2-guid":
node2 = n
case "node3-guid":
node3 = n
}
}
if node2 == nil || node3 == nil {
t.Fatalf("expected Node2 + transitive Node3, got %d nodes", len(tree))
}
if node2.ParentGuid != selfGuid {
t.Errorf("Node2 parent = %q, want this panel's GUID %q", node2.ParentGuid, selfGuid)
}
if !node3.Transitive || node3.ParentGuid != "node2-guid" {
t.Errorf("Node3 should be transitive under node2-guid, got transitive=%v parent=%q", node3.Transitive, node3.ParentGuid)
}
if node3.Id != 0 {
t.Errorf("transitive node must be a read-only projection (Id 0), got Id=%d", node3.Id)
}
if node2.InboundCount != 1 {
t.Errorf("Node2 should host only its own inbound, got InboundCount=%d", node2.InboundCount)
}
if node3.InboundCount != 1 {
t.Errorf("transitive Node3 should host its 1 inbound, got %d", node3.InboundCount)
}
}

View File

@@ -81,6 +81,7 @@ type Status struct {
Version string `json:"version"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
@@ -532,6 +533,9 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
}
status.Xray.Version = s.xrayService.GetXrayVersion()
status.PanelVersion = config.GetVersion()
if guid, err := s.settingService.GetPanelGuid(); err == nil {
status.PanelGuid = guid
}
// Application stats
var rtm runtime.MemStats

View File

@@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
@@ -34,6 +35,7 @@ var defaultValueMap = map[string]string{
"webCertFile": "",
"webKeyFile": "",
"secret": random.Seq(32),
"panelGuid": uuid.NewString(),
"apiToken": "",
"webBasePath": "/",
"sessionMaxAge": "360",
@@ -508,6 +510,24 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err
}
// GetPanelGuid returns this panel's stable self-identifier, persisting a
// freshly generated UUID on first read. It is the globally stable node
// identity used to attribute online clients and inbounds to the physical
// node that hosts them across a chain of nodes (#4983), where per-panel
// autoincrement node ids are meaningless one hop away.
func (s *SettingService) GetPanelGuid() (string, error) {
guid, err := s.getString("panelGuid")
if err != nil {
return "", err
}
if guid == defaultValueMap["panelGuid"] {
if saveErr := s.saveSetting("panelGuid", guid); saveErr != nil {
logger.Warning("save panelGuid failed:", saveErr)
}
}
return guid, nil
}
func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath

View File

@@ -881,6 +881,8 @@
"connectionFailed": "فشل الاتصال",
"never": "أبدًا",
"justNow": "دلوقتي",
"subNode": "نود فرعي",
"subNodeTip": "للقراءة فقط: نود تابع يتم الوصول إليه عبر {parent}. تتم إدارته من لوحة {parent} نفسها.",
"deleteConfirmTitle": "تحذف النود \"{name}\"؟",
"deleteConfirmContent": "ده هيوقّف مراقبة النود. البانل البعيد نفسه مش هيتأثر.",
"statusValues": {

View File

@@ -887,6 +887,8 @@
"connectionFailed": "Connection failed",
"never": "never",
"justNow": "just now",
"subNode": "Sub-node",
"subNodeTip": "Read-only: a downstream node reached through {parent}. Manage it from {parent}'s own panel.",
"deleteConfirmTitle": "Delete node \"{name}\"?",
"deleteConfirmContent": "This stops monitoring the node. The remote panel itself is unaffected.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Conexión fallida",
"never": "nunca",
"justNow": "ahora mismo",
"subNode": "Subnodo",
"subNodeTip": "Solo lectura: un nodo descendente al que se llega a través de {parent}. Gestiónalo desde el propio panel de {parent}.",
"deleteConfirmTitle": "¿Eliminar el nodo \"{name}\"?",
"deleteConfirmContent": "Esto detiene la monitorización del nodo. El panel remoto en sí no se ve afectado.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "اتصال ناموفق",
"never": "هرگز",
"justNow": "هم‌اکنون",
"subNode": "نود فرعی",
"subNodeTip": "فقط‌خواندنی: یک نود پایین‌دستی که از طریق {parent} در دسترس است. آن را از پنل خودِ {parent} مدیریت کنید.",
"deleteConfirmTitle": "نود «{name}» حذف شود؟",
"deleteConfirmContent": "نظارت روی این نود متوقف می‌شود. خود پنل ریموت تغییری نمی‌کند.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Koneksi gagal",
"never": "tidak pernah",
"justNow": "baru saja",
"subNode": "Sub-node",
"subNodeTip": "Hanya-baca: node turunan yang dijangkau melalui {parent}. Kelola dari panel {parent} sendiri.",
"deleteConfirmTitle": "Hapus node \"{name}\"?",
"deleteConfirmContent": "Ini menghentikan pemantauan node. Panel jarak jauh itu sendiri tidak terpengaruh.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "接続に失敗しました",
"never": "なし",
"justNow": "たった今",
"subNode": "サブノード",
"subNodeTip": "読み取り専用: {parent} を経由して到達する下位ノードです。{parent} 自身のパネルから管理してください。",
"deleteConfirmTitle": "ノード「{name}」を削除しますか?",
"deleteConfirmContent": "ノードの監視を停止します。リモートパネル自体には影響しません。",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Falha na conexão",
"never": "nunca",
"justNow": "agora mesmo",
"subNode": "Subnó",
"subNodeTip": "Somente leitura: um nó descendente acessado através de {parent}. Gerencie-o pelo próprio painel de {parent}.",
"deleteConfirmTitle": "Excluir o nó \"{name}\"?",
"deleteConfirmContent": "Isso interrompe o monitoramento do nó. O painel remoto em si não é afetado.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Не удалось подключиться",
"never": "никогда",
"justNow": "только что",
"subNode": "Подузел",
"subNodeTip": "Только для чтения: подчинённый узел, доступный через {parent}. Управляйте им из собственной панели {parent}.",
"deleteConfirmTitle": "Удалить узел \"{name}\"?",
"deleteConfirmContent": "Это остановит мониторинг узла. Сама удалённая панель не будет затронута.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Bağlantı başarısız",
"never": "asla",
"justNow": "şimdi",
"subNode": "Alt düğüm",
"subNodeTip": "Salt okunur: {parent} üzerinden erişilen bir alt düğüm. Bunu {parent} panelinden yönetin.",
"deleteConfirmTitle": "\"{name}\" düğümü silinsin mi?",
"deleteConfirmContent": "Bu, düğüm izlemeyi durdurur. Uzak panelin kendisi etkilenmez.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Помилка з'єднання",
"never": "ніколи",
"justNow": "щойно",
"subNode": "Підвузол",
"subNodeTip": "Лише для читання: підлеглий вузол, доступний через {parent}. Керуйте ним із власної панелі {parent}.",
"deleteConfirmTitle": "Видалити вузол \"{name}\"?",
"deleteConfirmContent": "Це зупинить моніторинг вузла. Сама віддалена панель не зазнає змін.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "Kết nối thất bại",
"never": "chưa bao giờ",
"justNow": "vừa xong",
"subNode": "Nút con",
"subNodeTip": "Chỉ đọc: một nút phía dưới được kết nối qua {parent}. Quản lý nó từ bảng điều khiển của chính {parent}.",
"deleteConfirmTitle": "Xóa nút \"{name}\"?",
"deleteConfirmContent": "Việc này dừng giám sát nút. Panel từ xa không bị ảnh hưởng.",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "连接失败",
"never": "从未",
"justNow": "刚刚",
"subNode": "子节点",
"subNodeTip": "只读:通过 {parent} 接入的下游节点。请在 {parent} 自己的面板中管理。",
"deleteConfirmTitle": "删除节点 \"{name}\"",
"deleteConfirmContent": "这将停止监控该节点。远程面板本身不受影响。",
"statusValues": {

View File

@@ -881,6 +881,8 @@
"connectionFailed": "連線失敗",
"never": "從未",
"justNow": "剛剛",
"subNode": "子節點",
"subNodeTip": "唯讀:透過 {parent} 連接的下游節點。請在 {parent} 自己的面板中管理。",
"deleteConfirmTitle": "刪除節點「{name}」?",
"deleteConfirmContent": "這將停止監控該節點。遠端面板本身不受影響。",
"statusValues": {