4 Commits 6ed6f57b5c ... 483952cfa0

Tác giả SHA1 Thông báo Ngày
  MHSanaei 483952cfa0 fix(finalmask): validate fragment mask length so empty/zero-min can't crash xray 9 giờ trước cách đây
  MHSanaei 668c0922ca fix(sub): restore standard base64 for Shadowrocket sub link (#5001) 9 giờ trước cách đây
  MHSanaei 1b2a17f7e3 i18n: translate #4988 sockopt/REALITY-target/Freedom strings for all locales 10 giờ trước cách đây
  Sanaei e6c1ce9aa9 feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005) 10 giờ trước cách đây
39 tập tin đã thay đổi với 1118 bổ sung226 xóa
  1. 41 0
      database/model/model.go
  2. 49 7
      frontend/public/openapi.json
  3. 1 1
      frontend/src/api/queryKeys.ts
  4. 19 3
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  5. 3 0
      frontend/src/models/dbinbound.ts
  6. 11 5
      frontend/src/pages/api-docs/endpoints.ts
  7. 40 34
      frontend/src/pages/inbounds/useInbounds.ts
  8. 72 15
      frontend/src/pages/nodes/NodeList.tsx
  9. 1 1
      frontend/src/pages/sub/SubPage.tsx
  10. 5 0
      frontend/src/schemas/node.ts
  11. 4 4
      web/controller/client.go
  12. 1 1
      web/controller/node.go
  13. 9 0
      web/controller/server.go
  14. 11 1
      web/job/node_heartbeat_job.go
  15. 2 2
      web/job/node_traffic_sync_job.go
  16. 2 2
      web/job/xray_traffic_job.go
  17. 39 7
      web/runtime/remote.go
  18. 123 9
      web/service/inbound.go
  19. 28 15
      web/service/node.go
  20. 71 0
      web/service/node_origin_guid_test.go
  21. 226 0
      web/service/node_tree.go
  22. 81 0
      web/service/node_tree_test.go
  23. 4 0
      web/service/server.go
  24. 20 0
      web/service/setting.go
  25. 10 0
      web/translation/ar-EG.json
  26. 2 0
      web/translation/en-US.json
  27. 10 0
      web/translation/es-ES.json
  28. 10 0
      web/translation/fa-IR.json
  29. 10 0
      web/translation/id-ID.json
  30. 10 0
      web/translation/ja-JP.json
  31. 10 0
      web/translation/pt-BR.json
  32. 10 0
      web/translation/ru-RU.json
  33. 10 0
      web/translation/tr-TR.json
  34. 10 0
      web/translation/uk-UA.json
  35. 10 0
      web/translation/vi-VN.json
  36. 10 0
      web/translation/zh-CN.json
  37. 10 0
      web/translation/zh-TW.json
  38. 49 54
      xray/online_test.go
  39. 84 65
      xray/process.go

+ 41 - 0
database/model/model.go

@@ -63,6 +63,14 @@ type Inbound struct {
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
 
 
+	// OriginNodeGuid is the panelGuid of the node that physically hosts this
+	// inbound, propagated up across hops (#4983). Empty for an inbound that
+	// lives on this panel's own xray; set to the originating node's GUID when
+	// the inbound was synced from a node (kept as-is across further hops). Lets
+	// the master attribute a deeply nested inbound to the real node instead of
+	// the intermediate one it was fetched through.
+	OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
+
 	// FallbackParent is populated by the API layer when this inbound is
 	// FallbackParent is populated by the API layer when this inbound is
 	// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
 	// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
 	// The frontend uses it to rewrite client-share links so they advertise
 	// The frontend uses it to rewrite client-share links so they advertise
@@ -383,6 +391,13 @@ type Node struct {
 	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
 	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
 	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 
 
+	// Guid is the remote panel's stable self-identifier (its panelGuid),
+	// learned from each heartbeat. It is the globally stable node identity used
+	// to attribute online clients/inbounds to the physical node across a chain
+	// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
+	// Observed-state only — never user-edited.
+	Guid string `json:"guid" gorm:"column:guid;index"`
+
 	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
 	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
 	// the row is otherwise unchanged so the UI's "last seen" tooltip is
 	// the row is otherwise unchanged so the UI's "last seen" tooltip is
 	// truthful without us having to read LastHeartbeat separately.
 	// truthful without us having to read LastHeartbeat separately.
@@ -404,10 +419,36 @@ type Node struct {
 	OnlineCount   int `json:"onlineCount" gorm:"-"`
 	OnlineCount   int `json:"onlineCount" gorm:"-"`
 	DepletedCount int `json:"depletedCount" gorm:"-"`
 	DepletedCount int `json:"depletedCount" gorm:"-"`
 
 
+	// ParentGuid + Transitive are set only when a node is surfaced as part of a
+	// node tree (#4983): direct nodes carry the master panel's own GUID, a
+	// transitive sub-node carries its parent node's GUID. Transitive nodes are
+	// read-only projections (Id == 0, not persisted) — never edited or deployed.
+	ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
+	Transitive bool   `json:"transitive,omitempty" gorm:"-"`
+
 	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
 	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
 	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 }
 }
 
 
+// NodeSummary is the read-only identity of a node as published one hop up: the
+// view a panel exposes about the nodes it directly manages, so a master can
+// surface transitive sub-nodes in a chained topology (#4983). Counts are
+// computed by the consuming master from its own per-GUID data, never trusted
+// from the child, so this carries identity/health only.
+type NodeSummary struct {
+	Guid          string `json:"guid"`
+	ParentGuid    string `json:"parentGuid"`
+	Name          string `json:"name"`
+	Address       string `json:"address"`
+	Scheme        string `json:"scheme"`
+	Port          int    `json:"port"`
+	Status        string `json:"status"`
+	LastHeartbeat int64  `json:"lastHeartbeat"`
+	LatencyMs     int    `json:"latencyMs"`
+	PanelVersion  string `json:"panelVersion"`
+	XrayVersion   string `json:"xrayVersion"`
+}
+
 type CustomGeoResource struct {
 type CustomGeoResource struct {
 	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
 	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`

+ 49 - 7
frontend/public/openapi.json

@@ -1596,6 +1596,48 @@
         }
         }
       }
       }
     },
     },
+    "/panel/api/server/descendants": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.",
+        "operationId": "get_panel_api_server_descendants",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "guid": "c3d4-...",
+                      "parentGuid": "a1b2-...",
+                      "name": "Node3",
+                      "address": "10.0.0.3",
+                      "status": "online"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/getNewX25519Cert": {
     "/panel/api/server/getNewX25519Cert": {
       "get": {
       "get": {
         "tags": [
         "tags": [
@@ -3716,13 +3758,13 @@
         }
         }
       }
       }
     },
     },
-    "/panel/api/clients/onlinesByNode": {
+    "/panel/api/clients/onlinesByGuid": {
       "post": {
       "post": {
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "Online client emails grouped by the node that reported them. The local panel uses key \"0\"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.",
-        "operationId": "post_panel_api_clients_onlinesByNode",
+        "summary": "Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.",
+        "operationId": "post_panel_api_clients_onlinesByGuid",
         "responses": {
         "responses": {
           "200": {
           "200": {
             "description": "Successful response",
             "description": "Successful response",
@@ -3743,10 +3785,10 @@
                 "example": {
                 "example": {
                   "success": true,
                   "success": true,
                   "obj": {
                   "obj": {
-                    "0": [
+                    "a1b2-...": [
                       "user1"
                       "user1"
                     ],
                     ],
-                    "3": [
+                    "c3d4-...": [
                       "user1",
                       "user1",
                       "user2"
                       "user2"
                     ]
                     ]
@@ -3763,7 +3805,7 @@
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key \"0\"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.",
+        "summary": "Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node's panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.",
         "operationId": "post_panel_api_clients_activeInbounds",
         "operationId": "post_panel_api_clients_activeInbounds",
         "responses": {
         "responses": {
           "200": {
           "200": {
@@ -3785,7 +3827,7 @@
                 "example": {
                 "example": {
                   "success": true,
                   "success": true,
                   "obj": {
                   "obj": {
-                    "0": [
+                    "a1b2-...": [
                       "inbound-443",
                       "inbound-443",
                       "inbound-8443"
                       "inbound-8443"
                     ]
                     ]

+ 1 - 1
frontend/src/api/queryKeys.ts

@@ -21,7 +21,7 @@ export const keys = {
     list: (params: unknown) => ['clients', 'list', params] as const,
     list: (params: unknown) => ['clients', 'list', params] as const,
     all: () => ['clients', 'all'] as const,
     all: () => ['clients', 'all'] as const,
     onlines: () => ['clients', 'onlines'] as const,
     onlines: () => ['clients', 'onlines'] as const,
-    onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
+    onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
     activeInbounds: () => ['clients', 'activeInbounds'] as const,
     activeInbounds: () => ['clients', 'activeInbounds'] as const,
     lastOnline: () => ['clients', 'lastOnline'] as const,
     lastOnline: () => ['clients', 'lastOnline'] as const,
     groups: () => ['clients', 'groups'] as const,
     groups: () => ['clients', 'groups'] as const,

+ 19 - 3
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -26,7 +26,7 @@ function asPath(name: NamePath): (string | number)[] {
 function defaultTcpMaskSettings(type: string): Record<string, unknown> {
 function defaultTcpMaskSettings(type: string): Record<string, unknown> {
   switch (type) {
   switch (type) {
     case 'fragment':
     case 'fragment':
-      return { packets: '1-3', length: '', delay: '', maxSplit: '' };
+      return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
     case 'sudoku':
     case 'sudoku':
       return {
       return {
         password: '', ascii: '', customTable: '', customTables: [''],
         password: '', ascii: '', customTable: '', customTables: [''],
@@ -210,8 +210,12 @@ function TcpMaskItem({
                     ]}
                     ]}
                   />
                   />
                 </Form.Item>
                 </Form.Item>
-                <Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
-                  <Input />
+                <Form.Item
+                  label="Length"
+                  name={[fieldName, 'settings', 'length']}
+                  rules={[{ validator: validateFragmentLength }]}
+                >
+                  <Input placeholder="e.g. 100-200" />
                 </Form.Item>
                 </Form.Item>
                 <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
                 <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
                   <Input />
                   <Input />
@@ -259,6 +263,18 @@ function TcpMaskItem({
 // Walks a deep object path safely. Used inside shouldUpdate which gets
 // Walks a deep object path safely. Used inside shouldUpdate which gets
 // the whole form values blob; we need to compare a deep field across
 // the whole form values blob; we need to compare a deep field across
 // prev/curr without crashing on missing intermediates.
 // prev/curr without crashing on missing intermediates.
+function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  if (str.length === 0) {
+    return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
+  }
+  const min = Number(str.split('-')[0]);
+  if (!Number.isFinite(min) || min <= 0) {
+    return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
+  }
+  return Promise.resolve();
+}
+
 function getDeep(obj: unknown, path: (string | number)[]): unknown {
 function getDeep(obj: unknown, path: (string | number)[]): unknown {
   let cur: unknown = obj;
   let cur: unknown = obj;
   for (const key of path) {
   for (const key of path) {

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -40,6 +40,7 @@ export type DBInboundInit = Partial<{
     sniffing: RawJsonField;
     sniffing: RawJsonField;
     clientStats: ClientStats[];
     clientStats: ClientStats[];
     nodeId: number | null;
     nodeId: number | null;
+    originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
     fallbackParent: FallbackParentRef | null;
 }>;
 }>;
 
 
@@ -83,6 +84,7 @@ export class DBInbound {
     sniffing: RawJsonField;
     sniffing: RawJsonField;
     clientStats: ClientStats[];
     clientStats: ClientStats[];
     nodeId: number | null;
     nodeId: number | null;
+    originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
     fallbackParent: FallbackParentRef | null;
 
 
     private _clientStatsMap: Map<string, ClientStats> | null = null;
     private _clientStatsMap: Map<string, ClientStats> | null = null;
@@ -108,6 +110,7 @@ export class DBInbound {
         this.sniffing = "";
         this.sniffing = "";
         this.clientStats = [];
         this.clientStats = [];
         this.nodeId = null;
         this.nodeId = null;
+        this.originNodeGuid = "";
         this.fallbackParent = null;
         this.fallbackParent = null;
         if (data == null) {
         if (data == null) {
             return;
             return;

+ 11 - 5
frontend/src/pages/api-docs/endpoints.ts

@@ -324,6 +324,12 @@ export const sections: readonly Section[] = [
         summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
         summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
         response: '{\n  "success": true,\n  "obj": {\n    "webCertFile": "/root/cert/example.com/fullchain.pem",\n    "webKeyFile": "/root/cert/example.com/privkey.pem"\n  }\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "webCertFile": "/root/cert/example.com/fullchain.pem",\n    "webKeyFile": "/root/cert/example.com/privkey.pem"\n  }\n}',
       },
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/descendants',
+        summary: 'Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "guid": "c3d4-...",\n      "parentGuid": "a1b2-...",\n      "name": "Node3",\n      "address": "10.0.0.3",\n      "status": "online"\n    }\n  ]\n}',
+      },
       {
       {
         method: 'GET',
         method: 'GET',
         path: '/panel/api/server/getNewX25519Cert',
         path: '/panel/api/server/getNewX25519Cert',
@@ -682,15 +688,15 @@ export const sections: readonly Section[] = [
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
-        path: '/panel/api/clients/onlinesByNode',
-        summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
-        response: '{\n  "success": true,\n  "obj": {\n    "0": ["user1"],\n    "3": ["user1", "user2"]\n  }\n}',
+        path: '/panel/api/clients/onlinesByGuid',
+        summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
+        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["user1"],\n    "c3d4-...": ["user1", "user2"]\n  }\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/activeInbounds',
         path: '/panel/api/clients/activeInbounds',
-        summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
-        response: '{\n  "success": true,\n  "obj": {\n    "0": ["inbound-443", "inbound-8443"]\n  }\n}',
+        summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
+        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["inbound-443", "inbound-8443"]\n  }\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',

+ 40 - 34
frontend/src/pages/inbounds/useInbounds.ts

@@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise<string[]> {
   return Array.isArray(validated.obj) ? validated.obj : [];
   return Array.isArray(validated.obj) ? validated.obj : [];
 }
 }
 
 
-// Online emails grouped by node id (local panel = key 0), used to scope the
-// per-inbound online rollup so a client online on one node is not shown
-// online on every node's inbounds.
-async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
-  const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true });
-  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode');
-  const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode');
+// Online emails grouped by the panelGuid of the node that physically hosts each
+// client, used to scope the per-inbound online rollup so a client online on one
+// node is not shown online on every node's inbounds — and a client on a
+// sub-node is attributed to that sub-node, not the node it syncs through (#4983).
+async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
+  const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
+  const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
 }
 }
 
 
@@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
 }
 }
 
 
-function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
-  const map = new Map<number, Set<string>>();
+function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
+  const map = new Map<string, Set<string>>();
   for (const [key, emails] of Object.entries(data)) {
   for (const [key, emails] of Object.entries(data)) {
     if (!Array.isArray(emails)) continue;
     if (!Array.isArray(emails)) continue;
-    map.set(Number(key), new Set(emails));
+    map.set(key, new Set(emails));
   }
   }
   return map;
   return map;
 }
 }
@@ -117,9 +118,9 @@ export function useInbounds() {
     staleTime: Infinity,
     staleTime: Infinity,
   });
   });
 
 
-  const onlinesByNodeQuery = useQuery({
-    queryKey: keys.clients.onlinesByNode(),
-    queryFn: fetchOnlineClientsByNode,
+  const onlinesByGuidQuery = useQuery({
+    queryKey: keys.clients.onlinesByGuid(),
+    queryFn: fetchOnlineClientsByGuid,
     staleTime: Infinity,
     staleTime: Infinity,
   });
   });
 
 
@@ -182,16 +183,17 @@ export function useInbounds() {
   const onlineClientsRef = useRef<string[]>([]);
   const onlineClientsRef = useRef<string[]>([]);
   onlineClientsRef.current = onlineClients;
   onlineClientsRef.current = onlineClients;
 
 
-  // Online emails keyed by node id (local inbounds = key 0). The rollup
-  // reads this so each inbound only counts clients online on its own node.
-  const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
+  // Online emails keyed by the hosting node's panelGuid. The rollup reads this
+  // so each inbound only counts clients online on the node that physically
+  // hosts it, attributing a sub-node's clients to that sub-node (#4983).
+  const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
 
 
-  // Recently-active inbound tags keyed by node id. A node missing from this
-  // map means "no per-inbound activity reported" (e.g. remote nodes), so the
-  // rollup leaves that node's inbounds ungated and falls back to the email
-  // signal. A present node gates: a client only counts online on an inbound
-  // whose tag carried traffic this window.
-  const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
+  // Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
+  // missing from this map means "no per-inbound activity reported" (e.g. remote
+  // nodes), so the rollup leaves that node's inbounds ungated and falls back to
+  // the email signal. A present GUID gates: a client only counts online on an
+  // inbound whose tag carried traffic this window.
+  const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
 
 
   const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
   const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
 
 
@@ -209,13 +211,17 @@ export function useInbounds() {
       const comments = new Map<string, string>();
       const comments = new Map<string, string>();
       const now = Date.now();
       const now = Date.now();
 
 
-      const nodeId = dbInbound.nodeId ?? 0;
-      const nodeOnline = onlineByNodeRef.current.get(nodeId);
+      // Attribution key: the GUID of the node that physically hosts this
+      // inbound. Local inbounds carry the panel's own GUID (filled server-side);
+      // a node-managed inbound carries its origin node's GUID, or falls back to
+      // the master-local synthetic id for an old-build node without one (#4983).
+      const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
+      const nodeOnline = onlineByGuidRef.current.get(guid);
       // A node absent from the active map reports no per-inbound activity, so
       // A node absent from the active map reports no per-inbound activity, so
       // leave its inbounds ungated. When present, only mark a client online on
       // leave its inbounds ungated. When present, only mark a client online on
       // this inbound if its tag actually carried traffic — that's what stops a
       // this inbound if its tag actually carried traffic — that's what stops a
       // multi-inbound client lighting up every inbound it's attached to.
       // multi-inbound client lighting up every inbound it's attached to.
-      const activeForNode = activeByNodeRef.current.get(nodeId);
+      const activeForNode = activeByGuidRef.current.get(guid);
       const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
       const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
 
 
       if (dbInbound.enable) {
       if (dbInbound.enable) {
@@ -305,15 +311,15 @@ export function useInbounds() {
   }, [onlinesQuery.data]);
   }, [onlinesQuery.data]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (onlinesByNodeQuery.data) {
-      onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data);
+    if (onlinesByGuidQuery.data) {
+      onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
       rebuildClientCount();
       rebuildClientCount();
     }
     }
-  }, [onlinesByNodeQuery.data, rebuildClientCount]);
+  }, [onlinesByGuidQuery.data, rebuildClientCount]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (activeInboundsQuery.data) {
     if (activeInboundsQuery.data) {
-      activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
+      activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
       rebuildClientCount();
       rebuildClientCount();
     }
     }
   }, [activeInboundsQuery.data, rebuildClientCount]);
   }, [activeInboundsQuery.data, rebuildClientCount]);
@@ -336,7 +342,7 @@ export function useInbounds() {
     await Promise.all([
     await Promise.all([
       queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
       queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
-      queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
+      queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
       queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
       queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
@@ -367,16 +373,16 @@ export function useInbounds() {
   const applyTrafficEvent = useCallback(
   const applyTrafficEvent = useCallback(
     (payload: unknown) => {
     (payload: unknown) => {
       if (!payload || typeof payload !== 'object') return;
       if (!payload || typeof payload !== 'object') return;
-      const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
+      const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
       if (Array.isArray(p.onlineClients)) {
       if (Array.isArray(p.onlineClients)) {
         onlineClientsRef.current = p.onlineClients;
         onlineClientsRef.current = p.onlineClients;
         setOnlineClients(p.onlineClients);
         setOnlineClients(p.onlineClients);
       }
       }
-      if (p.onlineByNode && typeof p.onlineByNode === 'object') {
-        onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
+      if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
+        onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
       }
       }
       if (p.activeInbounds && typeof p.activeInbounds === 'object') {
       if (p.activeInbounds && typeof p.activeInbounds === 'object') {
-        activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
+        activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
       }
       }
       if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
       if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
         setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
         setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));

+ 72 - 15
frontend/src/pages/nodes/NodeList.tsx

@@ -15,6 +15,7 @@ import {
 import type { BadgeProps } from 'antd';
 import type { BadgeProps } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 import {
 import {
+  ApartmentOutlined,
   ClusterOutlined,
   ClusterOutlined,
   CloudDownloadOutlined,
   CloudDownloadOutlined,
   DeleteOutlined,
   DeleteOutlined,
@@ -56,7 +57,7 @@ function isUpdateEligible(n: NodeRecord): boolean {
 
 
 interface NodeRow extends NodeRecord {
 interface NodeRow extends NodeRecord {
   url: string;
   url: string;
-  key: number;
+  key: string | number;
 }
 }
 
 
 function badgeStatus(status?: string): BadgeProps['status'] {
 function badgeStatus(status?: string): BadgeProps['status'] {
@@ -131,14 +132,49 @@ export default function NodeList({
   const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
   const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
   const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
   const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
 
 
-  const dataSource = useMemo<NodeRow[]>(
-    () => nodes.map((n) => ({
+  // Map a node GUID to its display name so a transitive sub-node can show which
+  // parent it is reached through (#4983).
+  const nameByGuid = useMemo(() => {
+    const m = new Map<string, string>();
+    for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
+    return m;
+  }, [nodes]);
+
+  // Order direct nodes first, each immediately followed by its transitive
+  // sub-nodes, so the table reads as a parent -> child tree without colliding
+  // with the per-row history expander (transitive nodes carry id 0).
+  const dataSource = useMemo<NodeRow[]>(() => {
+    const toRow = (n: NodeRecord): NodeRow => ({
       ...n,
       ...n,
       url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
       url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
-      key: n.id,
-    })),
-    [nodes],
-  );
+      key: n.transitive ? `t-${n.guid || ''}` : n.id,
+    });
+    const childrenByParent = new Map<string, NodeRecord[]>();
+    for (const n of nodes) {
+      if (n.transitive && n.parentGuid) {
+        const arr = childrenByParent.get(n.parentGuid) || [];
+        arr.push(n);
+        childrenByParent.set(n.parentGuid, arr);
+      }
+    }
+    const ordered: NodeRow[] = [];
+    const added = new Set<string>();
+    const push = (n: NodeRecord) => {
+      const row = toRow(n);
+      ordered.push(row);
+      added.add(String(row.key));
+    };
+    for (const n of nodes) {
+      if (n.transitive) continue;
+      push(n);
+      if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
+    }
+    // Transitive nodes whose parent isn't in the list still get shown.
+    for (const n of nodes) {
+      if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
+    }
+    return ordered;
+  }, [nodes]);
 
 
   function toggleExpanded(id: number) {
   function toggleExpanded(id: number) {
     setExpandedIds((prev) => {
     setExpandedIds((prev) => {
@@ -153,7 +189,11 @@ export default function NodeList({
       title: t('pages.nodes.actions'),
       title: t('pages.nodes.actions'),
       align: 'center',
       align: 'center',
       width: 190,
       width: 190,
-      render: (_value, record) => (
+      render: (_value, record) => record.transitive ? (
+        <Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
+          <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
+        </Tooltip>
+      ) : (
         <Space>
         <Space>
           <Tooltip title={t('pages.nodes.probe')}>
           <Tooltip title={t('pages.nodes.probe')}>
             <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
             <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
@@ -177,7 +217,9 @@ export default function NodeList({
       dataIndex: 'enable',
       dataIndex: 'enable',
       align: 'center',
       align: 'center',
       width: 80,
       width: 80,
-      render: (_value, record) => (
+      render: (_value, record) => record.transitive ? (
+        <span style={{ opacity: 0.4 }}>—</span>
+      ) : (
         <Switch
         <Switch
           checked={!!record.enable}
           checked={!!record.enable}
           size="small"
           size="small"
@@ -190,8 +232,11 @@ export default function NodeList({
       dataIndex: 'name',
       dataIndex: 'name',
       ellipsis: true,
       ellipsis: true,
       render: (_value, record) => (
       render: (_value, record) => (
-        <div className="name-cell">
-          <span className="name">{record.name}</span>
+        <div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
+          <span className="name">
+            {record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
+            {record.name}
+          </span>
           {record.remark && <span className="remark">{record.remark}</span>}
           {record.remark && <span className="remark">{record.remark}</span>}
         </div>
         </div>
       ),
       ),
@@ -316,7 +361,7 @@ export default function NodeList({
       width: 120,
       width: 120,
       render: (_value, record) => relativeTime(record.lastHeartbeat),
       render: (_value, record) => relativeTime(record.lastHeartbeat),
     },
     },
-  ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
+  ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
 
 
   return (
   return (
     <Card size="small" hoverable>
     <Card size="small" hoverable>
@@ -340,7 +385,18 @@ export default function NodeList({
                 <div>{t('noData')}</div>
                 <div>{t('noData')}</div>
               </div>
               </div>
             ) : (
             ) : (
-              dataSource.map((record) => (
+              dataSource.map((record) => record.transitive ? (
+                <div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
+                  <div className="card-head">
+                    <ApartmentOutlined style={{ opacity: 0.6 }} />
+                    <StatusDot status={record.status} />
+                    <span className="node-name">{record.name}</span>
+                    <div className="card-actions">
+                      <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
+                    </div>
+                  </div>
+                </div>
+              ) : (
                 <div key={record.id} className="node-card">
                 <div key={record.id} className="node-card">
                   <div className="card-head" onClick={() => toggleExpanded(record.id)}>
                   <div className="card-head" onClick={() => toggleExpanded(record.id)}>
                     <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
                     <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
@@ -501,8 +557,8 @@ export default function NodeList({
           rowKey="id"
           rowKey="id"
           rowSelection={dataSource.length > 1 ? {
           rowSelection={dataSource.length > 1 ? {
             selectedRowKeys: selectedIds,
             selectedRowKeys: selectedIds,
-            onChange: (keys) => onSelectionChange(keys as number[]),
-            getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
+            onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
+            getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
           } : undefined}
           } : undefined}
           locale={{
           locale={{
             emptyText: (
             emptyText: (
@@ -514,6 +570,7 @@ export default function NodeList({
           }}
           }}
           expandable={{
           expandable={{
             expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
             expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
+            rowExpandable: (record) => !record.transitive,
           }}
           }}
         />
         />
       )}
       )}

+ 1 - 1
frontend/src/pages/sub/SubPage.tsx

@@ -120,7 +120,7 @@ export default function SubPage() {
     if (!subUrl) return '';
     if (!subUrl) return '';
     const separator = subUrl.includes('?') ? '&' : '?';
     const separator = subUrl.includes('?') ? '&' : '?';
     const rawUrl = subUrl + separator + 'flag=shadowrocket';
     const rawUrl = subUrl + separator + 'flag=shadowrocket';
-    const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+    const base64Url = btoa(rawUrl);
     const remark = encodeURIComponent(subTitle || sId || 'Subscription');
     const remark = encodeURIComponent(subTitle || sId || 'Subscription');
     return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
     return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
   }, []);
   }, []);

+ 5 - 0
frontend/src/schemas/node.ts

@@ -26,6 +26,11 @@ export const NodeRecordSchema = z.object({
   allowPrivateAddress: z.boolean().optional(),
   allowPrivateAddress: z.boolean().optional(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
   pinnedCertSha256: z.string().optional(),
   pinnedCertSha256: z.string().optional(),
+  // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
+  // whether it's a read-only transitive sub-node surfaced from a downstream node.
+  guid: z.string().optional(),
+  parentGuid: z.string().optional(),
+  transitive: z.boolean().optional(),
 }).loose();
 }).loose();
 
 
 export const NodeListSchema = z.array(NodeRecordSchema);
 export const NodeListSchema = z.array(NodeRecordSchema);

+ 4 - 4
web/controller/client.go

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

+ 1 - 1
web/controller/node.go

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

+ 9 - 0
web/controller/server.go

@@ -56,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getMigration", a.getMigration)
 	g.GET("/getMigration", a.getMigration)
 	g.GET("/getNewUUID", a.getNewUUID)
 	g.GET("/getNewUUID", a.getNewUUID)
 	g.GET("/getWebCertFiles", a.getWebCertFiles)
 	g.GET("/getWebCertFiles", a.getWebCertFiles)
+	g.GET("/descendants", a.descendants)
 	g.GET("/getNewX25519Cert", a.getNewX25519Cert)
 	g.GET("/getNewX25519Cert", a.getNewX25519Cert)
 	g.GET("/getNewmldsa65", a.getNewmldsa65)
 	g.GET("/getNewmldsa65", a.getNewmldsa65)
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
@@ -334,6 +335,14 @@ func (a *ServerController) importDB(c *gin.Context) {
 	jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
 	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
 // 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
 // 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
 // "Set Cert from Panel" can fill a node-assigned inbound with paths that exist

+ 11 - 1
web/job/node_heartbeat_job.go

@@ -59,7 +59,7 @@ func (j *NodeHeartbeatJob) Run() {
 	if !websocket.HasClients() {
 	if !websocket.HasClients() {
 		return
 		return
 	}
 	}
-	updated, err := j.nodeService.GetAll()
+	updated, err := j.nodeService.GetNodeTree()
 	if err != nil {
 	if err != nil {
 		logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
 		logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
 		return
 		return
@@ -79,4 +79,14 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 	if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
 	if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
 		logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
 		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)
+	}
 }
 }

+ 2 - 2
web/job/node_traffic_sync_job.go

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

+ 2 - 2
web/job/xray_traffic_job.go

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

+ 39 - 7
web/runtime/remote.go

@@ -370,6 +370,24 @@ func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
 	return &files, nil
 	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 {
 func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
 	_, err := r.do(ctx, http.MethodPost,
 	_, err := r.do(ctx, http.MethodPost,
 		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
 		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
@@ -382,8 +400,14 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
 }
 }
 
 
 type TrafficSnapshot struct {
 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
 	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)
 		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)
 	envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)

+ 123 - 9
web/service/inbound.go

@@ -231,9 +231,30 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
 	}
 	}
 	s.enrichClientStats(db, inbounds)
 	s.enrichClientStats(db, inbounds)
 	s.annotateFallbackParents(db, inbounds)
 	s.annotateFallbackParents(db, inbounds)
+	s.annotateLocalOriginGuid(inbounds)
 	return inbounds, nil
 	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
 // GetInboundsSlim returns the same list of inbounds as GetInbounds but
 // strips every per-client field other than email / enable / comment from
 // strips every per-client field other than email / enable / comment from
 // settings.clients and skips UUID/SubId enrichment on ClientStats. The
 // 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
 		return nil, err
 	}
 	}
 	s.annotateFallbackParents(db, inbounds)
 	s.annotateFallbackParents(db, inbounds)
+	s.annotateLocalOriginGuid(inbounds)
 	for _, ib := range inbounds {
 	for _, ib := range inbounds {
 		ib.Settings = slimSettingsClients(ib.Settings)
 		ib.Settings = slimSettingsClients(ib.Settings)
 	}
 	}
@@ -1453,6 +1475,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	db := database.GetDB()
 	db := database.GetDB()
 	now := time.Now().UnixMilli()
 	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
 	var central []model.Inbound
 	if err := db.Model(model.Inbound{}).
 	if err := db.Model(model.Inbound{}).
 		Where("node_id = ?", nodeID).
 		Where("node_id = ?", nodeID).
@@ -1598,6 +1635,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			newIb := model.Inbound{
 			newIb := model.Inbound{
 				UserId:         defaultUserId,
 				UserId:         defaultUserId,
 				NodeID:         &nodeID,
 				NodeID:         &nodeID,
+				OriginNodeGuid: originGuidFor(snapIb),
 				Tag:            chosenTag,
 				Tag:            chosenTag,
 				Listen:         snapIb.Listen,
 				Listen:         snapIb.Listen,
 				Port:           snapIb.Port,
 				Port:           snapIb.Port,
@@ -1645,6 +1683,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["up"] = snapIb.Up
 			updates["up"] = snapIb.Up
 			updates["down"] = snapIb.Down
 			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 ||
 		if !dirty && (c.Settings != snapIb.Settings ||
 			c.Remark != snapIb.Remark ||
 			c.Remark != snapIb.Remark ||
@@ -1933,7 +1977,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	committed = true
 	committed = true
 
 
 	if p != nil {
 	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
 	return structuralChange, nil
@@ -3634,23 +3688,46 @@ func (s *InboundService) GetOnlineClients() []string {
 	return p.GetOnlineClients()
 	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 {
 	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 {
 	if p == nil {
-		return map[int][]string{}
+		return map[string][]string{}
+	}
+	active := p.GetLocalActiveInbounds()
+	if len(active) == 0 {
+		return map[string][]string{}
+	}
+	guid := s.panelGuid()
+	if guid == "" {
+		return map[string][]string{}
 	}
 	}
-	return p.GetActiveInboundsByNode()
+	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 {
 	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) {
 func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	var rows []xray.ClientTraffic
 	var rows []xray.ClientTraffic

+ 28 - 15
web/service/node.go

@@ -30,6 +30,7 @@ type HeartbeatPatch struct {
 	LatencyMs     int
 	LatencyMs     int
 	XrayVersion   string
 	XrayVersion   string
 	PanelVersion  string
 	PanelVersion  string
+	Guid          string
 	CpuPct        float64
 	CpuPct        float64
 	MemPct        float64
 	MemPct        float64
 	UptimeSecs    uint64
 	UptimeSecs    uint64
@@ -224,9 +225,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 		Select("inbound_id, email, enable, total, up, down, expiry_time").
 		Select("inbound_id, email, enable, total, up, down, expiry_time").
 		Where("inbound_id IN ?", inboundIDs).
 		Where("inbound_id IN ?", inboundIDs).
 		Scan(&trafficRows).Error; err == nil {
 		Scan(&trafficRows).Error; err == nil {
-		onlineByNodeSet := s.onlineEmailsByNode()
 		depletedByNode := make(map[int]int)
 		depletedByNode := make(map[int]int)
-		onlineByNode := make(map[int]int)
 		for _, row := range trafficRows {
 		for _, row := range trafficRows {
 			nodeID, ok := nodeByInbound[row.InboundID]
 			nodeID, ok := nodeByInbound[row.InboundID]
 			if !ok {
 			if !ok {
@@ -237,38 +236,45 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 			if expired || exhausted || !row.Enable {
 			if expired || exhausted || !row.Enable {
 				depletedByNode[nodeID]++
 				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 {
 		for _, n := range nodes {
 			n.InboundCount = len(inboundsByNode[n.Id])
 			n.InboundCount = len(inboundsByNode[n.Id])
 			n.DepletedCount = depletedByNode[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
 	return nodes, nil
 }
 }
 
 
-func (s *NodeService) onlineEmailsByNode() map[int]map[string]struct{} {
+func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
 	svc := InboundService{}
 	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))
 		set := make(map[string]struct{}, len(emails))
 		for _, email := range emails {
 		for _, email := range emails {
 			set[email] = struct{}{}
 			set[email] = struct{}{}
 		}
 		}
-		out[nodeID] = set
+		out[guid] = set
 	}
 	}
 	return out
 	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) {
 func (s *NodeService) GetById(id int) (*model.Node, error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	n := &model.Node{}
 	n := &model.Node{}
@@ -469,6 +475,11 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		"uptime_secs":    p.UptimeSecs,
 		"uptime_secs":    p.UptimeSecs,
 		"last_error":     p.LastError,
 		"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 {
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
 		return err
 	}
 	}
@@ -599,6 +610,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 				Version string `json:"version"`
 				Version string `json:"version"`
 			} `json:"xray"`
 			} `json:"xray"`
 			PanelVersion string `json:"panelVersion"`
 			PanelVersion string `json:"panelVersion"`
+			PanelGuid    string `json:"panelGuid"`
 			Uptime       uint64 `json:"uptime"`
 			Uptime       uint64 `json:"uptime"`
 		} `json:"obj"`
 		} `json:"obj"`
 	}
 	}
@@ -617,6 +629,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	}
 	patch.XrayVersion = o.Xray.Version
 	patch.XrayVersion = o.Xray.Version
 	patch.PanelVersion = o.PanelVersion
 	patch.PanelVersion = o.PanelVersion
+	patch.Guid = o.PanelGuid
 	patch.UptimeSecs = o.Uptime
 	patch.UptimeSecs = o.Uptime
 	return patch, nil
 	return patch, nil
 }
 }

+ 71 - 0
web/service/node_origin_guid_test.go

@@ -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 - 0
web/service/node_tree.go

@@ -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]
+	}
+}

+ 81 - 0
web/service/node_tree_test.go

@@ -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)
+	}
+}

+ 4 - 0
web/service/server.go

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

+ 20 - 0
web/service/setting.go

@@ -12,6 +12,7 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/google/uuid"
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/logger"
@@ -34,6 +35,7 @@ var defaultValueMap = map[string]string{
 	"webCertFile":                 "",
 	"webCertFile":                 "",
 	"webKeyFile":                  "",
 	"webKeyFile":                  "",
 	"secret":                      random.Seq(32),
 	"secret":                      random.Seq(32),
+	"panelGuid":                   uuid.NewString(),
 	"apiToken":                    "",
 	"apiToken":                    "",
 	"webBasePath":                 "/",
 	"webBasePath":                 "/",
 	"sessionMaxAge":               "360",
 	"sessionMaxAge":               "360",
@@ -508,6 +510,24 @@ func (s *SettingService) GetSecret() ([]byte, error) {
 	return []byte(secret), err
 	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 {
 func (s *SettingService) SetBasePath(basePath string) error {
 	if !strings.HasPrefix(basePath, "/") {
 	if !strings.HasPrefix(basePath, "/") {
 		basePath = "/" + basePath
 		basePath = "/" + basePath

+ 10 - 0
web/translation/ar-EG.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "اتركها 0 لاستخدام الإعداد الافتراضي لنظام التشغيل. القيم غير الصفرية تحدّ من نافذة استقبال TCP المُعلَنة؛ وقيم مثل 600 (من مثال وثائق Xray) قد تنهار معها سرعة النقل على الوصلات عالية زمن الاستجابة.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "أدنى إصدار للعميل",
         "minClientVer": "أدنى إصدار للعميل",
         "maxClientVer": "أقصى إصدار للعميل",
         "maxClientVer": "أقصى إصدار للعميل",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
+        "realityTargetRequired": "هدف REALITY مطلوب",
+        "realityTargetNeedsPort": "يجب أن يتضمّن هدف REALITY منفذًا (مثل example.com:443)",
+        "realityTargetInvalidPort": "هدف REALITY يحتوي على منفذ غير صالح",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "احصل على شهادة جديدة",
         "getNewCert": "احصل على شهادة جديدة",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "فشل الاتصال",
       "connectionFailed": "فشل الاتصال",
       "never": "أبدًا",
       "never": "أبدًا",
       "justNow": "دلوقتي",
       "justNow": "دلوقتي",
+      "subNode": "نود فرعي",
+      "subNodeTip": "للقراءة فقط: نود تابع يتم الوصول إليه عبر {parent}. تتم إدارته من لوحة {parent} نفسها.",
       "deleteConfirmTitle": "تحذف النود \"{name}\"؟",
       "deleteConfirmTitle": "تحذف النود \"{name}\"؟",
       "deleteConfirmContent": "ده هيوقّف مراقبة النود. البانل البعيد نفسه مش هيتأثر.",
       "deleteConfirmContent": "ده هيوقّف مراقبة النود. البانل البعيد نفسه مش هيتأثر.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده.",
       "TemplateDesc": "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده.",
       "FreedomStrategy": "استراتيجية بروتوكول الحرية",
       "FreedomStrategy": "استراتيجية بروتوكول الحرية",
       "FreedomStrategyDesc": "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية.",
       "FreedomStrategyDesc": "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "اتصال ثنائي المكدّس للمخرج المباشر (freedom) — مفيد على خوادم الخروج التي تدعم IPv4 وIPv6 معًا.",
+      "FreedomHappyEyeballsTryDelayDesc": "عدد المللي ثانية قبل تجربة عائلة العناوين البديلة. 150–250 مللي ثانية نقطة بداية جيدة.",
       "RoutingStrategy": "استراتيجية التوجيه العامة",
       "RoutingStrategy": "استراتيجية التوجيه العامة",
       "RoutingStrategyDesc": "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات.",
       "RoutingStrategyDesc": "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات.",
       "outboundTestUrl": "رابط اختبار المخرج",
       "outboundTestUrl": "رابط اختبار المخرج",

+ 2 - 0
web/translation/en-US.json

@@ -887,6 +887,8 @@
       "connectionFailed": "Connection failed",
       "connectionFailed": "Connection failed",
       "never": "never",
       "never": "never",
       "justNow": "just now",
       "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}\"?",
       "deleteConfirmTitle": "Delete node \"{name}\"?",
       "deleteConfirmContent": "This stops monitoring the node. The remote panel itself is unaffected.",
       "deleteConfirmContent": "This stops monitoring the node. The remote panel itself is unaffected.",
       "statusValues": {
       "statusValues": {

+ 10 - 0
web/translation/es-ES.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Deja 0 para usar el valor predeterminado del sistema. Los valores distintos de cero limitan la ventana de recepción TCP anunciada; valores como 600 (del ejemplo de la documentación de Xray) pueden hundir el rendimiento en enlaces de alta latencia.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Mín. versión cliente",
         "minClientVer": "Mín. versión cliente",
         "maxClientVer": "Máx. versión cliente",
         "maxClientVer": "Máx. versión cliente",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
+        "realityTargetRequired": "El destino REALITY es obligatorio",
+        "realityTargetNeedsPort": "El destino REALITY debe incluir un puerto (p. ej. example.com:443)",
+        "realityTargetInvalidPort": "El destino REALITY tiene un puerto no válido",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Obtener nuevo cert",
         "getNewCert": "Obtener nuevo cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Conexión fallida",
       "connectionFailed": "Conexión fallida",
       "never": "nunca",
       "never": "nunca",
       "justNow": "ahora mismo",
       "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}\"?",
       "deleteConfirmTitle": "¿Eliminar el nodo \"{name}\"?",
       "deleteConfirmContent": "Esto detiene la monitorización del nodo. El panel remoto en sí no se ve afectado.",
       "deleteConfirmContent": "Esto detiene la monitorización del nodo. El panel remoto en sí no se ve afectado.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "Genera el archivo de configuración final de Xray basado en esta plantilla.",
       "TemplateDesc": "Genera el archivo de configuración final de Xray basado en esta plantilla.",
       "FreedomStrategy": "Configurar Estrategia para el Protocolo Freedom",
       "FreedomStrategy": "Configurar Estrategia para el Protocolo Freedom",
       "FreedomStrategyDesc": "Establece la estrategia de salida de la red en el Protocolo Freedom.",
       "FreedomStrategyDesc": "Establece la estrategia de salida de la red en el Protocolo Freedom.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Marcado de doble pila para la salida directa (freedom): útil en servidores de salida con IPv4 e IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Milisegundos antes de probar la otra familia de direcciones. 150–250 ms es un buen punto de partida.",
       "RoutingStrategy": "Configurar Estrategia de Enrutamiento de Dominios",
       "RoutingStrategy": "Configurar Estrategia de Enrutamiento de Dominios",
       "RoutingStrategyDesc": "Establece la estrategia general de enrutamiento para la resolución de DNS.",
       "RoutingStrategyDesc": "Establece la estrategia general de enrutamiento para la resolución de DNS.",
       "outboundTestUrl": "URL de prueba de outbound",
       "outboundTestUrl": "URL de prueba de outbound",

+ 10 - 0
web/translation/fa-IR.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "برای استفاده از پیش‌فرض سیستم‌عامل، مقدار را 0 بگذارید. مقادیر غیرصفر پنجرهٔ دریافت TCP اعلام‌شده را محدود می‌کنند؛ مقادیری مانند 600 (از مثال مستندات Xray) می‌توانند نرخ عبور را روی لینک‌های با تأخیر بالا به‌شدت کاهش دهند.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "حداقل نسخه کلاینت",
         "minClientVer": "حداقل نسخه کلاینت",
         "maxClientVer": "حداکثر نسخه کلاینت",
         "maxClientVer": "حداکثر نسخه کلاینت",
         "shortIds": "Short IDها",
         "shortIds": "Short IDها",
+        "realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمی‌شود.",
+        "realityTargetRequired": "هدف REALITY الزامی است",
+        "realityTargetNeedsPort": "هدف REALITY باید شامل پورت باشد (مثلاً example.com:443)",
+        "realityTargetInvalidPort": "پورت هدف REALITY نامعتبر است",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "دریافت گواهی جدید",
         "getNewCert": "دریافت گواهی جدید",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "اتصال ناموفق",
       "connectionFailed": "اتصال ناموفق",
       "never": "هرگز",
       "never": "هرگز",
       "justNow": "هم‌اکنون",
       "justNow": "هم‌اکنون",
+      "subNode": "نود فرعی",
+      "subNodeTip": "فقط‌خواندنی: یک نود پایین‌دستی که از طریق {parent} در دسترس است. آن را از پنل خودِ {parent} مدیریت کنید.",
       "deleteConfirmTitle": "نود «{name}» حذف شود؟",
       "deleteConfirmTitle": "نود «{name}» حذف شود؟",
       "deleteConfirmContent": "نظارت روی این نود متوقف می‌شود. خود پنل ریموت تغییری نمی‌کند.",
       "deleteConfirmContent": "نظارت روی این نود متوقف می‌شود. خود پنل ریموت تغییری نمی‌کند.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود",
       "TemplateDesc": "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود",
       "FreedomStrategy": "Freedom استراتژی پروتکل",
       "FreedomStrategy": "Freedom استراتژی پروتکل",
       "FreedomStrategyDesc": "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل",
       "FreedomStrategyDesc": "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "اتصال دو‌پشته‌ای برای خروجی مستقیم (freedom) — برای سرورهای خروج دارای هر دو IPv4 و IPv6 مفید است.",
+      "FreedomHappyEyeballsTryDelayDesc": "تعداد میلی‌ثانیه پیش از امتحان خانوادهٔ آدرس دیگر. مقدار 150 تا 250 میلی‌ثانیه نقطهٔ شروع خوبی است.",
       "RoutingStrategy": "استراتژی کلی مسیریابی",
       "RoutingStrategy": "استراتژی کلی مسیریابی",
       "RoutingStrategyDesc": "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند",
       "RoutingStrategyDesc": "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند",
       "outboundTestUrl": "آدرس تست خروجی",
       "outboundTestUrl": "آدرس تست خروجی",

+ 10 - 0
web/translation/id-ID.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Biarkan 0 untuk memakai bawaan OS. Nilai bukan nol membatasi jendela penerimaan TCP yang diiklankan; nilai seperti 600 (dari contoh dokumentasi Xray) bisa menjatuhkan throughput pada tautan berlatensi tinggi.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Min. versi klien",
         "minClientVer": "Min. versi klien",
         "maxClientVer": "Maks. versi klien",
         "maxClientVer": "Maks. versi klien",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
+        "realityTargetRequired": "Target REALITY wajib diisi",
+        "realityTargetNeedsPort": "Target REALITY harus menyertakan port (mis. example.com:443)",
+        "realityTargetInvalidPort": "Target REALITY memiliki port yang tidak valid",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Dapatkan sertifikat baru",
         "getNewCert": "Dapatkan sertifikat baru",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Koneksi gagal",
       "connectionFailed": "Koneksi gagal",
       "never": "tidak pernah",
       "never": "tidak pernah",
       "justNow": "baru saja",
       "justNow": "baru saja",
+      "subNode": "Sub-node",
+      "subNodeTip": "Hanya-baca: node turunan yang dijangkau melalui {parent}. Kelola dari panel {parent} sendiri.",
       "deleteConfirmTitle": "Hapus node \"{name}\"?",
       "deleteConfirmTitle": "Hapus node \"{name}\"?",
       "deleteConfirmContent": "Ini menghentikan pemantauan node. Panel jarak jauh itu sendiri tidak terpengaruh.",
       "deleteConfirmContent": "Ini menghentikan pemantauan node. Panel jarak jauh itu sendiri tidak terpengaruh.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "File konfigurasi Xray akhir akan dibuat berdasarkan template ini.",
       "TemplateDesc": "File konfigurasi Xray akhir akan dibuat berdasarkan template ini.",
       "FreedomStrategy": "Strategi Protokol Freedom",
       "FreedomStrategy": "Strategi Protokol Freedom",
       "FreedomStrategyDesc": "Atur strategi output untuk jaringan dalam Protokol Freedom.",
       "FreedomStrategyDesc": "Atur strategi output untuk jaringan dalam Protokol Freedom.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Panggilan dual-stack untuk outbound langsung (freedom) — berguna pada server keluar dengan IPv4 dan IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Milidetik sebelum mencoba keluarga alamat lainnya. 150–250 ms adalah titik awal yang baik.",
       "RoutingStrategy": "Strategi Pengalihan Keseluruhan",
       "RoutingStrategy": "Strategi Pengalihan Keseluruhan",
       "RoutingStrategyDesc": "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan.",
       "RoutingStrategyDesc": "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan.",
       "outboundTestUrl": "URL tes outbound",
       "outboundTestUrl": "URL tes outbound",

+ 10 - 0
web/translation/ja-JP.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "OS のデフォルトを使うには 0 のままにします。0 以外の値は通知される TCP 受信ウィンドウを制限し、600(Xray ドキュメントの例)のような値は高遅延リンクでスループットを大きく低下させることがあります。",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "最小クライアントバージョン",
         "minClientVer": "最小クライアントバージョン",
         "maxClientVer": "最大クライアントバージョン",
         "maxClientVer": "最大クライアントバージョン",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
+        "realityTargetRequired": "REALITY ターゲットは必須です",
+        "realityTargetNeedsPort": "REALITY ターゲットにはポートを含める必要があります(例: example.com:443)",
+        "realityTargetInvalidPort": "REALITY ターゲットのポートが無効です",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "新しい証明書を取得",
         "getNewCert": "新しい証明書を取得",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "接続に失敗しました",
       "connectionFailed": "接続に失敗しました",
       "never": "なし",
       "never": "なし",
       "justNow": "たった今",
       "justNow": "たった今",
+      "subNode": "サブノード",
+      "subNodeTip": "読み取り専用: {parent} を経由して到達する下位ノードです。{parent} 自身のパネルから管理してください。",
       "deleteConfirmTitle": "ノード「{name}」を削除しますか?",
       "deleteConfirmTitle": "ノード「{name}」を削除しますか?",
       "deleteConfirmContent": "ノードの監視を停止します。リモートパネル自体には影響しません。",
       "deleteConfirmContent": "ノードの監視を停止します。リモートパネル自体には影響しません。",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます",
       "TemplateDesc": "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます",
       "FreedomStrategy": "Freedom プロトコル戦略",
       "FreedomStrategy": "Freedom プロトコル戦略",
       "FreedomStrategyDesc": "Freedomプロトコル内のネットワークの出力戦略を設定する",
       "FreedomStrategyDesc": "Freedomプロトコル内のネットワークの出力戦略を設定する",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "直接(freedom)アウトバウンドのデュアルスタック接続。IPv4 と IPv6 の両方を持つ出口サーバーで便利です。",
+      "FreedomHappyEyeballsTryDelayDesc": "別のアドレスファミリを試すまでのミリ秒。150〜250 ms が目安です。",
       "RoutingStrategy": "ルーティングドメイン戦略設定",
       "RoutingStrategy": "ルーティングドメイン戦略設定",
       "RoutingStrategyDesc": "DNS解決の全体的なルーティング戦略を設定する",
       "RoutingStrategyDesc": "DNS解決の全体的なルーティング戦略を設定する",
       "outboundTestUrl": "アウトバウンドテスト URL",
       "outboundTestUrl": "アウトバウンドテスト URL",

+ 10 - 0
web/translation/pt-BR.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Deixe 0 para usar o padrão do sistema. Valores diferentes de zero limitam a janela de recepção TCP anunciada; valores como 600 (do exemplo da documentação do Xray) podem derrubar a taxa de transferência em enlaces de alta latência.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Mín. versão cliente",
         "minClientVer": "Mín. versão cliente",
         "maxClientVer": "Máx. versão cliente",
         "maxClientVer": "Máx. versão cliente",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
+        "realityTargetRequired": "O alvo REALITY é obrigatório",
+        "realityTargetNeedsPort": "O alvo REALITY deve incluir uma porta (ex.: example.com:443)",
+        "realityTargetInvalidPort": "O alvo REALITY tem uma porta inválida",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Obter novo certificado",
         "getNewCert": "Obter novo certificado",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Falha na conexão",
       "connectionFailed": "Falha na conexão",
       "never": "nunca",
       "never": "nunca",
       "justNow": "agora mesmo",
       "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}\"?",
       "deleteConfirmTitle": "Excluir o nó \"{name}\"?",
       "deleteConfirmContent": "Isso interrompe o monitoramento do nó. O painel remoto em si não é afetado.",
       "deleteConfirmContent": "Isso interrompe o monitoramento do nó. O painel remoto em si não é afetado.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "O arquivo final de configuração do Xray será gerado com base neste modelo.",
       "TemplateDesc": "O arquivo final de configuração do Xray será gerado com base neste modelo.",
       "FreedomStrategy": "Estratégia do Protocolo Freedom",
       "FreedomStrategy": "Estratégia do Protocolo Freedom",
       "FreedomStrategyDesc": "Definir a estratégia de saída para a rede no Protocolo Freedom.",
       "FreedomStrategyDesc": "Definir a estratégia de saída para a rede no Protocolo Freedom.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Discagem dual-stack para a saída direta (freedom) — útil em servidores de saída com IPv4 e IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Milissegundos antes de tentar a outra família de endereços. 150–250 ms é um bom ponto de partida.",
       "RoutingStrategy": "Estratégia Geral de Roteamento",
       "RoutingStrategy": "Estratégia Geral de Roteamento",
       "RoutingStrategyDesc": "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações.",
       "RoutingStrategyDesc": "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações.",
       "outboundTestUrl": "URL de teste de outbound",
       "outboundTestUrl": "URL de teste de outbound",

+ 10 - 0
web/translation/ru-RU.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Оставьте 0, чтобы использовать значение по умолчанию ОС. Ненулевые значения ограничивают объявляемое окно приёма TCP; значения вроде 600 (из примера в документации Xray) могут обрушить пропускную способность на каналах с высокой задержкой.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Мин. версия клиента",
         "minClientVer": "Мин. версия клиента",
         "maxClientVer": "Макс. версия клиента",
         "maxClientVer": "Макс. версия клиента",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Обязательно. Должно содержать порт (например, example.com:443). Без порта Xray-core не запускается.",
+        "realityTargetRequired": "Цель REALITY обязательна",
+        "realityTargetNeedsPort": "Цель REALITY должна содержать порт (например, example.com:443)",
+        "realityTargetInvalidPort": "У цели REALITY указан недопустимый порт",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Получить новый сертификат",
         "getNewCert": "Получить новый сертификат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Не удалось подключиться",
       "connectionFailed": "Не удалось подключиться",
       "never": "никогда",
       "never": "никогда",
       "justNow": "только что",
       "justNow": "только что",
+      "subNode": "Подузел",
+      "subNodeTip": "Только для чтения: подчинённый узел, доступный через {parent}. Управляйте им из собственной панели {parent}.",
       "deleteConfirmTitle": "Удалить узел \"{name}\"?",
       "deleteConfirmTitle": "Удалить узел \"{name}\"?",
       "deleteConfirmContent": "Это остановит мониторинг узла. Сама удалённая панель не будет затронута.",
       "deleteConfirmContent": "Это остановит мониторинг узла. Сама удалённая панель не будет затронута.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "На основе шаблона создаётся конфигурационный файл Xray.",
       "TemplateDesc": "На основе шаблона создаётся конфигурационный файл Xray.",
       "FreedomStrategy": "Настройка стратегии протокола Freedom",
       "FreedomStrategy": "Настройка стратегии протокола Freedom",
       "FreedomStrategyDesc": "Установка стратегии вывода сети в протоколе Freedom",
       "FreedomStrategyDesc": "Установка стратегии вывода сети в протоколе Freedom",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Двухстековый набор для прямого (freedom) исходящего — полезно на выходных серверах с IPv4 и IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Миллисекунды перед попыткой другого семейства адресов. 150–250 мс — хорошая отправная точка.",
       "RoutingStrategy": "Настройка маршрутизации доменов",
       "RoutingStrategy": "Настройка маршрутизации доменов",
       "RoutingStrategyDesc": "Установка общей стратегии маршрутизации разрешения DNS",
       "RoutingStrategyDesc": "Установка общей стратегии маршрутизации разрешения DNS",
       "outboundTestUrl": "URL для теста исходящего",
       "outboundTestUrl": "URL для теста исходящего",

+ 10 - 0
web/translation/tr-TR.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "İşletim sistemi varsayılanını kullanmak için 0 bırakın. Sıfır olmayan değerler ilan edilen TCP alım penceresini sınırlar; 600 gibi değerler (Xray belgelerindeki örnek) yüksek gecikmeli bağlantılarda verimi çökertebilir.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Min. istemci sürümü",
         "minClientVer": "Min. istemci sürümü",
         "maxClientVer": "Maks. istemci sürümü",
         "maxClientVer": "Maks. istemci sürümü",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Zorunlu. Bir bağlantı noktası içermeli (ör. example.com:443). Bağlantı noktası olmadan Xray-core başlamaz.",
+        "realityTargetRequired": "REALITY hedefi zorunludur",
+        "realityTargetNeedsPort": "REALITY hedefi bir bağlantı noktası içermelidir (ör. example.com:443)",
+        "realityTargetInvalidPort": "REALITY hedefinde geçersiz bir bağlantı noktası var",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Yeni sertifika al",
         "getNewCert": "Yeni sertifika al",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Bağlantı başarısız",
       "connectionFailed": "Bağlantı başarısız",
       "never": "asla",
       "never": "asla",
       "justNow": "şimdi",
       "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?",
       "deleteConfirmTitle": "\"{name}\" düğümü silinsin mi?",
       "deleteConfirmContent": "Bu, düğüm izlemeyi durdurur. Uzak panelin kendisi etkilenmez.",
       "deleteConfirmContent": "Bu, düğüm izlemeyi durdurur. Uzak panelin kendisi etkilenmez.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır.",
       "TemplateDesc": "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır.",
       "FreedomStrategy": "Freedom Protokol Stratejisi",
       "FreedomStrategy": "Freedom Protokol Stratejisi",
       "FreedomStrategyDesc": "Freedom Protokolünde ağın çıkış stratejisini ayarlayın.",
       "FreedomStrategyDesc": "Freedom Protokolünde ağın çıkış stratejisini ayarlayın.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Doğrudan (freedom) çıkış için çift yığınlı arama — hem IPv4 hem IPv6 olan çıkış sunucularında kullanışlıdır.",
+      "FreedomHappyEyeballsTryDelayDesc": "Diğer adres ailesini denemeden önceki milisaniye. 150–250 ms iyi bir başlangıç noktasıdır.",
       "RoutingStrategy": "Genel Yönlendirme Stratejisi",
       "RoutingStrategy": "Genel Yönlendirme Stratejisi",
       "RoutingStrategyDesc": "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın.",
       "RoutingStrategyDesc": "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın.",
       "outboundTestUrl": "Outbound test URL",
       "outboundTestUrl": "Outbound test URL",

+ 10 - 0
web/translation/uk-UA.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Залиште 0, щоб використовувати значення за умовчанням ОС. Ненульові значення обмежують оголошуване вікно приймання TCP; значення на кшталт 600 (з прикладу в документації Xray) можуть обвалити пропускну здатність на каналах із високою затримкою.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Мін. версія клієнта",
         "minClientVer": "Мін. версія клієнта",
         "maxClientVer": "Макс. версія клієнта",
         "maxClientVer": "Макс. версія клієнта",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Обов'язково. Має містити порт (напр., example.com:443). Без порту Xray-core не запускається.",
+        "realityTargetRequired": "Ціль REALITY обов'язкова",
+        "realityTargetNeedsPort": "Ціль REALITY має містити порт (напр., example.com:443)",
+        "realityTargetInvalidPort": "Ціль REALITY має недійсний порт",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Отримати новий сертифікат",
         "getNewCert": "Отримати новий сертифікат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Помилка з'єднання",
       "connectionFailed": "Помилка з'єднання",
       "never": "ніколи",
       "never": "ніколи",
       "justNow": "щойно",
       "justNow": "щойно",
+      "subNode": "Підвузол",
+      "subNodeTip": "Лише для читання: підлеглий вузол, доступний через {parent}. Керуйте ним із власної панелі {parent}.",
       "deleteConfirmTitle": "Видалити вузол \"{name}\"?",
       "deleteConfirmTitle": "Видалити вузол \"{name}\"?",
       "deleteConfirmContent": "Це зупинить моніторинг вузла. Сама віддалена панель не зазнає змін.",
       "deleteConfirmContent": "Це зупинить моніторинг вузла. Сама віддалена панель не зазнає змін.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону.",
       "TemplateDesc": "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону.",
       "FreedomStrategy": "Стратегія протоколу свободи",
       "FreedomStrategy": "Стратегія протоколу свободи",
       "FreedomStrategyDesc": "Установити стратегію виведення для мережі в протоколі свободи.",
       "FreedomStrategyDesc": "Установити стратегію виведення для мережі в протоколі свободи.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Двостековий набір для прямого (freedom) вихідного — корисно на вихідних серверах із IPv4 та IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Мілісекунди перед спробою іншої родини адрес. 150–250 мс — добра початкова точка.",
       "RoutingStrategy": "Загальна стратегія маршрутизації",
       "RoutingStrategy": "Загальна стратегія маршрутизації",
       "RoutingStrategyDesc": "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів.",
       "RoutingStrategyDesc": "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів.",
       "outboundTestUrl": "URL тесту outbound",
       "outboundTestUrl": "URL тесту outbound",

+ 10 - 0
web/translation/vi-VN.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Để 0 để dùng mặc định của hệ điều hành. Giá trị khác 0 sẽ giới hạn cửa sổ nhận TCP được quảng bá; các giá trị như 600 (theo ví dụ tài liệu Xray) có thể làm sụp thông lượng trên các liên kết độ trễ cao.",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "Phiên bản client tối thiểu",
         "minClientVer": "Phiên bản client tối thiểu",
         "maxClientVer": "Phiên bản client tối đa",
         "maxClientVer": "Phiên bản client tối đa",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Bắt buộc. Phải bao gồm cổng (ví dụ example.com:443). Không có cổng, Xray-core sẽ không khởi động.",
+        "realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
+        "realityTargetNeedsPort": "Mục tiêu REALITY phải bao gồm cổng (ví dụ example.com:443)",
+        "realityTargetInvalidPort": "Mục tiêu REALITY có cổng không hợp lệ",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "Lấy chứng chỉ mới",
         "getNewCert": "Lấy chứng chỉ mới",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "Kết nối thất bại",
       "connectionFailed": "Kết nối thất bại",
       "never": "chưa bao giờ",
       "never": "chưa bao giờ",
       "justNow": "vừa xong",
       "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}\"?",
       "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.",
       "deleteConfirmContent": "Việc này dừng giám sát nút. Panel từ xa không bị ảnh hưởng.",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này.",
       "TemplateDesc": "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này.",
       "FreedomStrategy": "Cấu hình Chiến lược cho Giao thức Freedom",
       "FreedomStrategy": "Cấu hình Chiến lược cho Giao thức Freedom",
       "FreedomStrategyDesc": "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom.",
       "FreedomStrategyDesc": "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Quay số dual-stack cho outbound trực tiếp (freedom) — hữu ích trên máy chủ thoát có cả IPv4 và IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Số mili-giây trước khi thử họ địa chỉ còn lại. 150–250 ms là điểm khởi đầu tốt.",
       "RoutingStrategy": "Cấu hình Chiến lược Định tuyến Tên miền",
       "RoutingStrategy": "Cấu hình Chiến lược Định tuyến Tên miền",
       "RoutingStrategyDesc": "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS.",
       "RoutingStrategyDesc": "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS.",
       "outboundTestUrl": "URL kiểm tra outbound",
       "outboundTestUrl": "URL kiểm tra outbound",

+ 10 - 0
web/translation/zh-CN.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "留 0 使用操作系统默认值。非零值会限制通告的 TCP 接收窗口;像 600 这样的值(来自 Xray 文档示例)在高延迟链路上可能导致吞吐量骤降。",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "最小客户端版本",
         "minClientVer": "最小客户端版本",
         "maxClientVer": "最大客户端版本",
         "maxClientVer": "最大客户端版本",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "必填。必须包含端口(例如 example.com:443)。没有端口时 Xray-core 将无法启动。",
+        "realityTargetRequired": "REALITY 目标为必填项",
+        "realityTargetNeedsPort": "REALITY 目标必须包含端口(例如 example.com:443)",
+        "realityTargetInvalidPort": "REALITY 目标的端口无效",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "获取新证书",
         "getNewCert": "获取新证书",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "连接失败",
       "connectionFailed": "连接失败",
       "never": "从未",
       "never": "从未",
       "justNow": "刚刚",
       "justNow": "刚刚",
+      "subNode": "子节点",
+      "subNodeTip": "只读:通过 {parent} 接入的下游节点。请在 {parent} 自己的面板中管理。",
       "deleteConfirmTitle": "删除节点 \"{name}\"?",
       "deleteConfirmTitle": "删除节点 \"{name}\"?",
       "deleteConfirmContent": "这将停止监控该节点。远程面板本身不受影响。",
       "deleteConfirmContent": "这将停止监控该节点。远程面板本身不受影响。",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "最终的 Xray 配置文件将基于此模板生成",
       "TemplateDesc": "最终的 Xray 配置文件将基于此模板生成",
       "FreedomStrategy": "Freedom 协议策略",
       "FreedomStrategy": "Freedom 协议策略",
       "FreedomStrategyDesc": "设置 Freedom 协议中网络的输出策略",
       "FreedomStrategyDesc": "设置 Freedom 协议中网络的输出策略",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "为直连(freedom)出站启用双栈拨号——在同时具备 IPv4 和 IPv6 的出口服务器上很有用。",
+      "FreedomHappyEyeballsTryDelayDesc": "尝试备用地址族之前等待的毫秒数。150–250 毫秒是不错的起点。",
       "RoutingStrategy": "配置路由域策略",
       "RoutingStrategy": "配置路由域策略",
       "RoutingStrategyDesc": "设置 DNS 解析的整体路由策略",
       "RoutingStrategyDesc": "设置 DNS 解析的整体路由策略",
       "outboundTestUrl": "出站测试 URL",
       "outboundTestUrl": "出站测试 URL",

+ 10 - 0
web/translation/zh-TW.json

@@ -559,6 +559,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "留 0 使用作業系統預設值。非零值會限制通告的 TCP 接收視窗;像 600 這樣的值(來自 Xray 文件範例)在高延遲連結上可能導致吞吐量驟降。",
         "tcpFastOpen": "TCP Fast Open",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
         "penetrate": "Penetrate",
@@ -597,6 +598,10 @@
         "minClientVer": "最小客戶端版本",
         "minClientVer": "最小客戶端版本",
         "maxClientVer": "最大客戶端版本",
         "maxClientVer": "最大客戶端版本",
         "shortIds": "Short IDs",
         "shortIds": "Short IDs",
+        "realityTargetHint": "必填。必須包含連接埠(例如 example.com:443)。沒有連接埠時 Xray-core 將無法啟動。",
+        "realityTargetRequired": "REALITY 目標為必填項",
+        "realityTargetNeedsPort": "REALITY 目標必須包含連接埠(例如 example.com:443)",
+        "realityTargetInvalidPort": "REALITY 目標的連接埠無效",
         "spiderX": "SpiderX",
         "spiderX": "SpiderX",
         "getNewCert": "取得新憑證",
         "getNewCert": "取得新憑證",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
       "connectionFailed": "連線失敗",
       "connectionFailed": "連線失敗",
       "never": "從未",
       "never": "從未",
       "justNow": "剛剛",
       "justNow": "剛剛",
+      "subNode": "子節點",
+      "subNodeTip": "唯讀:透過 {parent} 連接的下游節點。請在 {parent} 自己的面板中管理。",
       "deleteConfirmTitle": "刪除節點「{name}」?",
       "deleteConfirmTitle": "刪除節點「{name}」?",
       "deleteConfirmContent": "這將停止監控該節點。遠端面板本身不受影響。",
       "deleteConfirmContent": "這將停止監控該節點。遠端面板本身不受影響。",
       "statusValues": {
       "statusValues": {
@@ -1177,6 +1184,9 @@
       "TemplateDesc": "最終的 Xray 配置檔案將基於此模板生成",
       "TemplateDesc": "最終的 Xray 配置檔案將基於此模板生成",
       "FreedomStrategy": "Freedom 協議策略",
       "FreedomStrategy": "Freedom 協議策略",
       "FreedomStrategyDesc": "設定 Freedom 協議中網路的輸出策略",
       "FreedomStrategyDesc": "設定 Freedom 協議中網路的輸出策略",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "為直連(freedom)出站啟用雙協定堆疊撥號——在同時具備 IPv4 與 IPv6 的出口伺服器上很有用。",
+      "FreedomHappyEyeballsTryDelayDesc": "嘗試備用位址族之前等待的毫秒數。150–250 毫秒是不錯的起點。",
       "RoutingStrategy": "配置路由域策略",
       "RoutingStrategy": "配置路由域策略",
       "RoutingStrategyDesc": "設定 DNS 解析的整體路由策略",
       "RoutingStrategyDesc": "設定 DNS 解析的整體路由策略",
       "outboundTestUrl": "出站測試 URL",
       "outboundTestUrl": "出站測試 URL",

+ 49 - 54
xray/online_test.go

@@ -20,51 +20,50 @@ func assertSameSet(t *testing.T, label string, got, want []string) {
 	}
 	}
 }
 }
 
 
-// TestGetOnlineClientsByNodeScopesPerNode pins the fix for issue #4809: a
-// client online on one node must not be reported online on any other node.
-func TestGetOnlineClientsByNodeScopesPerNode(t *testing.T) {
+// TestMergedNodeTreesScopesPerGuid pins #4983/#4809: each node's clients stay
+// under that node's GUID, so a client on one node is never attributed to
+// another — and a sub-node's clients (reported under their own GUID inside a
+// parent's tree) compose upward without collapsing onto the parent.
+func TestMergedNodeTreesScopesPerGuid(t *testing.T) {
 	p := newOnlineTestProcess()
 	p := newOnlineTestProcess()
-	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
-	p.SetNodeOnlineClients(3, []string{"user1", "user2"})
-	p.SetNodeOnlineClients(5, []string{"user3"})
-
-	byNode := p.GetOnlineClientsByNode()
-
-	assertSameSet(t, "local (key 0)", byNode[localNodeKey], []string{"user1"})
-	assertSameSet(t, "node 3", byNode[3], []string{"user1", "user2"})
-	assertSameSet(t, "node 5", byNode[5], []string{"user3"})
-
-	if slices.Contains(byNode[5], "user1") {
-		t.Errorf("user1 leaked onto node 5: %v", byNode[5])
-	}
-	if slices.Contains(byNode[localNodeKey], "user3") || slices.Contains(byNode[3], "user3") {
-		t.Errorf("user3 leaked off node 5: local=%v node3=%v", byNode[localNodeKey], byNode[3])
+	// Node A (direct) reports its own clients plus sub-node B's tree.
+	p.SetNodeOnlineTree(1, map[string][]string{
+		"guid-a": {"user1", "user2"},
+		"guid-b": {"user3"}, // B is behind A; still keyed by B's own GUID
+	})
+	p.SetNodeOnlineTree(2, map[string][]string{
+		"guid-c": {"user4"},
+	})
+
+	merged := p.GetMergedNodeTrees()
+	assertSameSet(t, "guid-a", merged["guid-a"], []string{"user1", "user2"})
+	assertSameSet(t, "guid-b", merged["guid-b"], []string{"user3"})
+	assertSameSet(t, "guid-c", merged["guid-c"], []string{"user4"})
+
+	if slices.Contains(merged["guid-a"], "user3") {
+		t.Errorf("user3 (on sub-node B) leaked onto node A: %v", merged["guid-a"])
 	}
 	}
 }
 }
 
 
-// TestGetOnlineClientsByNodeOmitsEmptyGroups keeps the payload small: a node
-// with no online clients (e.g. just cleared) must not appear as an empty key.
-func TestGetOnlineClientsByNodeOmitsEmptyGroups(t *testing.T) {
+// TestMergedNodeTreesOmitsEmpty keeps the payload small: empty GUID sets don't
+// appear as keys.
+func TestMergedNodeTreesOmitsEmpty(t *testing.T) {
 	p := newOnlineTestProcess()
 	p := newOnlineTestProcess()
-	p.SetNodeOnlineClients(3, []string{"user1"})
-	p.SetNodeOnlineClients(7, []string{})
-
-	byNode := p.GetOnlineClientsByNode()
-
-	if _, ok := byNode[7]; ok {
-		t.Errorf("node 7 has no online clients but is present: %v", byNode)
-	}
-	if _, ok := byNode[localNodeKey]; ok {
-		t.Errorf("no local clients online but key 0 is present: %v", byNode)
+	p.SetNodeOnlineTree(1, map[string][]string{
+		"guid-a": {"user1"},
+		"guid-x": {},
+	})
+	if _, ok := p.GetMergedNodeTrees()["guid-x"]; ok {
+		t.Errorf("empty GUID set should be omitted: %v", p.GetMergedNodeTrees())
 	}
 	}
 }
 }
 
 
-// TestGetOnlineClientsUnionDedupes confirms the flat union (used by the
-// client-centric / total-count views) still merges every node and dedupes.
+// TestGetOnlineClientsUnionDedupes confirms the flat union (client-centric /
+// total-count views) merges local + every node and dedupes.
 func TestGetOnlineClientsUnionDedupes(t *testing.T) {
 func TestGetOnlineClientsUnionDedupes(t *testing.T) {
 	p := newOnlineTestProcess()
 	p := newOnlineTestProcess()
 	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
 	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
-	p.SetNodeOnlineClients(3, []string{"user1", "user2"})
+	p.SetNodeOnlineTree(1, map[string][]string{"guid-a": {"user1", "user2"}})
 
 
 	assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
 	assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
 }
 }
@@ -77,18 +76,18 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
 	const grace = 20000
 	const grace = 20000
 
 
 	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, grace)
 	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, grace)
-	if got := p.GetOnlineClientsByNode()[localNodeKey]; !slices.Contains(got, "user1") {
+	if got := p.GetLocalOnlineClients(); !slices.Contains(got, "user1") {
 		t.Fatalf("user1 should be online right after activity, got %v", got)
 		t.Fatalf("user1 should be online right after activity, got %v", got)
 	}
 	}
 
 
 	p.RefreshLocalOnline([]string{"user2"}, nil, 11000, grace)
 	p.RefreshLocalOnline([]string{"user2"}, nil, 11000, grace)
-	got := p.GetOnlineClientsByNode()[localNodeKey]
+	got := p.GetLocalOnlineClients()
 	if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
 	if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
 		t.Fatalf("both within grace window, got %v", got)
 		t.Fatalf("both within grace window, got %v", got)
 	}
 	}
 
 
 	p.RefreshLocalOnline(nil, nil, 22000, grace)
 	p.RefreshLocalOnline(nil, nil, 22000, grace)
-	got = p.GetOnlineClientsByNode()[localNodeKey]
+	got = p.GetLocalOnlineClients()
 	if slices.Contains(got, "user1") {
 	if slices.Contains(got, "user1") {
 		t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
 		t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
 	}
 	}
@@ -97,40 +96,36 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
 	}
 	}
 }
 }
 
 
-// TestGetActiveInboundsByNodeTracksGraceWindow pins the fix for issue #4859: a
-// multi-inbound client must only count as online on inbounds that actually
-// carried traffic. The active-inbound signal honours the same grace window as
-// the online-email signal, and only this panel's tags report under key 0.
-func TestGetActiveInboundsByNodeTracksGraceWindow(t *testing.T) {
+// TestGetLocalActiveInboundsTracksGraceWindow pins #4859: a multi-inbound
+// client only counts online on inbounds that actually carried traffic, and the
+// active-inbound signal honours the same grace window as the online signal.
+func TestGetLocalActiveInboundsTracksGraceWindow(t *testing.T) {
 	p := newOnlineTestProcess()
 	p := newOnlineTestProcess()
 	const grace = 20000
 	const grace = 20000
 
 
 	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-a"}, 1000, grace)
 	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-a"}, 1000, grace)
-	got := p.GetActiveInboundsByNode()[localNodeKey]
-	assertSameSet(t, "active after first poll", got, []string{"inbound-a"})
+	assertSameSet(t, "active after first poll", p.GetLocalActiveInbounds(), []string{"inbound-a"})
 
 
 	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-b"}, 11000, grace)
 	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-b"}, 11000, grace)
-	got = p.GetActiveInboundsByNode()[localNodeKey]
-	assertSameSet(t, "both within grace", got, []string{"inbound-a", "inbound-b"})
+	assertSameSet(t, "both within grace", p.GetLocalActiveInbounds(), []string{"inbound-a", "inbound-b"})
 
 
 	p.RefreshLocalOnline(nil, nil, 22000, grace)
 	p.RefreshLocalOnline(nil, nil, 22000, grace)
-	got = p.GetActiveInboundsByNode()[localNodeKey]
-	assertSameSet(t, "inbound-a (idle 21s, past grace) aged out, inbound-b kept", got, []string{"inbound-b"})
+	assertSameSet(t, "inbound-a (idle 21s) aged out, inbound-b kept", p.GetLocalActiveInbounds(), []string{"inbound-b"})
 
 
 	p.RefreshLocalOnline(nil, nil, 40000, grace)
 	p.RefreshLocalOnline(nil, nil, 40000, grace)
-	if _, ok := p.GetActiveInboundsByNode()[localNodeKey]; ok {
-		t.Errorf("all inbounds idle past grace, key 0 should be absent: %v", p.GetActiveInboundsByNode())
+	if got := p.GetLocalActiveInbounds(); len(got) != 0 {
+		t.Errorf("all inbounds idle past grace, want empty, got %v", got)
 	}
 	}
 }
 }
 
 
 // TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
 // TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
-// clients must disappear from the per-node map immediately.
+// whole subtree contribution disappears immediately.
 func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
 func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
 	p := newOnlineTestProcess()
 	p := newOnlineTestProcess()
-	p.SetNodeOnlineClients(3, []string{"user1"})
+	p.SetNodeOnlineTree(3, map[string][]string{"guid-a": {"user1"}})
 	p.ClearNodeOnlineClients(3)
 	p.ClearNodeOnlineClients(3)
 
 
-	if _, ok := p.GetOnlineClientsByNode()[3]; ok {
-		t.Errorf("node 3 should be absent after ClearNodeOnlineClients")
+	if _, ok := p.GetMergedNodeTrees()["guid-a"]; ok {
+		t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
 	}
 	}
 }
 }

+ 84 - 65
xray/process.go

@@ -155,13 +155,18 @@ type process struct {
 	// two signals stay aligned — an email within grace always has the
 	// two signals stay aligned — an email within grace always has the
 	// inbound it used within grace too.
 	// inbound it used within grace too.
 	localInboundLastActive map[string]int64
 	localInboundLastActive map[string]int64
-	// nodeOnlineClients holds the online-emails list reported by each
-	// remote node, keyed by node id. NodeTrafficSyncJob populates entries
-	// per cron tick and clears them when a node's probe fails. The mutex
-	// guards this map, onlineClients, and localLastOnline above so the
+	// nodeOnlineTrees holds, per direct remote node (keyed by that node's
+	// panel-local id), the GUID-keyed online-emails subtree that node
+	// reported — its own clients under its panelGuid plus every descendant
+	// under theirs. Keying the stored value by GUID (not node id) lets the
+	// master attribute a deeply nested client to the node that physically
+	// hosts it across a chain (#4983); the outer node-id key is only so a
+	// failed probe can drop that whole branch's contribution. NodeTrafficSyncJob
+	// populates entries per cron tick and clears them when a probe fails. The
+	// mutex guards this map, onlineClients, and localLastOnline above so the
 	// online getters never see a torn read.
 	// online getters never see a torn read.
-	nodeOnlineClients map[int][]string
-	onlineMu          sync.RWMutex
+	nodeOnlineTrees map[int]map[string][]string
+	onlineMu        sync.RWMutex
 
 
 	config     *Config
 	config     *Config
 	configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
 	configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
@@ -177,12 +182,6 @@ var (
 	xrayForceStopTimeout    = 2 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second
 )
 )
 
 
-// localNodeKey is the GetOnlineClientsByNode key under which this panel's
-// own (non-node-managed) inbounds report their online clients. Node ids
-// autoincrement from 1, so 0 is a safe sentinel that never collides with a
-// real node. The frontend mirrors this contract (nodeId ?? 0).
-const localNodeKey = 0
-
 // newProcess creates a new internal process struct for Xray.
 // newProcess creates a new internal process struct for Xray.
 func newProcess(config *Config) *process {
 func newProcess(config *Config) *process {
 	return &process{
 	return &process{
@@ -255,7 +254,7 @@ func (p *Process) GetOnlineClients() []string {
 	p.onlineMu.RLock()
 	p.onlineMu.RLock()
 	defer p.onlineMu.RUnlock()
 	defer p.onlineMu.RUnlock()
 
 
-	if len(p.nodeOnlineClients) == 0 {
+	if len(p.nodeOnlineTrees) == 0 {
 		// Hot path for single-panel deployments: avoid the map+dedupe
 		// Hot path for single-panel deployments: avoid the map+dedupe
 		// work entirely and return the local slice as-is.
 		// work entirely and return the local slice as-is.
 		return p.onlineClients
 		return p.onlineClients
@@ -263,15 +262,8 @@ func (p *Process) GetOnlineClients() []string {
 
 
 	seen := make(map[string]struct{}, len(p.onlineClients))
 	seen := make(map[string]struct{}, len(p.onlineClients))
 	out := make([]string, 0, len(p.onlineClients))
 	out := make([]string, 0, len(p.onlineClients))
-	for _, email := range p.onlineClients {
-		if _, dup := seen[email]; dup {
-			continue
-		}
-		seen[email] = struct{}{}
-		out = append(out, email)
-	}
-	for _, list := range p.nodeOnlineClients {
-		for _, email := range list {
+	add := func(emails []string) {
+		for _, email := range emails {
 			if _, dup := seen[email]; dup {
 			if _, dup := seen[email]; dup {
 				continue
 				continue
 			}
 			}
@@ -279,53 +271,79 @@ func (p *Process) GetOnlineClients() []string {
 			out = append(out, email)
 			out = append(out, email)
 		}
 		}
 	}
 	}
+	add(p.onlineClients)
+	for _, tree := range p.nodeOnlineTrees {
+		for _, emails := range tree {
+			add(emails)
+		}
+	}
 	return out
 	return out
 }
 }
 
 
-// GetOnlineClientsByNode returns online emails grouped by the node that
-// reported them: this panel's own xray clients under localNodeKey (0), and
-// each remote node's clients under that node's id. Unlike GetOnlineClients
-// (which flattens everything into one deduped union), this preserves node
-// attribution so per-inbound/per-node online counts don't bleed a client
-// connected to one node onto every other node. Empty groups are omitted.
-func (p *Process) GetOnlineClientsByNode() map[int][]string {
+// GetLocalOnlineClients returns a copy of the emails online on THIS panel's own
+// xray within the grace window. The service layer keys these under the panel's
+// own GUID when assembling the per-node online view.
+func (p *Process) GetLocalOnlineClients() []string {
 	p.onlineMu.RLock()
 	p.onlineMu.RLock()
 	defer p.onlineMu.RUnlock()
 	defer p.onlineMu.RUnlock()
-
-	out := make(map[int][]string, len(p.nodeOnlineClients)+1)
-	if len(p.onlineClients) > 0 {
-		local := make([]string, len(p.onlineClients))
-		copy(local, p.onlineClients)
-		out[localNodeKey] = local
+	if len(p.onlineClients) == 0 {
+		return nil
 	}
 	}
-	for nodeID, list := range p.nodeOnlineClients {
-		if len(list) == 0 {
-			continue
+	out := make([]string, len(p.onlineClients))
+	copy(out, p.onlineClients)
+	return out
+}
+
+// GetMergedNodeTrees returns the union of every direct node's reported subtree,
+// keyed by the panelGuid of the node that physically hosts each client set.
+// Because each child already reports its descendants under their own GUIDs,
+// merging the direct children yields the whole tree at any depth (#4983), so a
+// client three hops down is attributed to its real node, not the intermediate
+// one. GUIDs are globally unique, but a set reported under the same GUID by more
+// than one path is deduped per key; empty sets are omitted.
+func (p *Process) GetMergedNodeTrees() map[string][]string {
+	p.onlineMu.RLock()
+	defer p.onlineMu.RUnlock()
+	if len(p.nodeOnlineTrees) == 0 {
+		return map[string][]string{}
+	}
+	out := make(map[string][]string)
+	seen := make(map[string]map[string]struct{})
+	for _, tree := range p.nodeOnlineTrees {
+		for guid, emails := range tree {
+			if guid == "" || len(emails) == 0 {
+				continue
+			}
+			dedup := seen[guid]
+			if dedup == nil {
+				dedup = make(map[string]struct{}, len(emails))
+				seen[guid] = dedup
+			}
+			for _, email := range emails {
+				if _, ok := dedup[email]; ok {
+					continue
+				}
+				dedup[email] = struct{}{}
+				out[guid] = append(out[guid], email)
+			}
 		}
 		}
-		cp := make([]string, len(list))
-		copy(cp, list)
-		out[nodeID] = cp
 	}
 	}
 	return out
 	return out
 }
 }
 
 
-// GetActiveInboundsByNode returns the inbound tags that carried traffic within
-// the grace window, grouped by node. Only this panel's own xray reports
-// per-inbound activity (under localNodeKey); remote-node snapshots don't carry
-// it, so their nodes are simply absent — the per-inbound view reads "node
-// missing" as "don't gate" and falls back to the email-only signal there.
-// Empty groups are omitted, mirroring GetOnlineClientsByNode.
-func (p *Process) GetActiveInboundsByNode() map[int][]string {
+// GetLocalActiveInbounds returns a copy of THIS panel's inbound tags that
+// carried traffic within the grace window. Only the local xray reports
+// per-inbound activity; remote-node snapshots don't carry it, so the service
+// layer keys these under the panel's own GUID and a node missing from the
+// active-inbounds map means "don't gate" (fall back to the email-only signal).
+func (p *Process) GetLocalActiveInbounds() []string {
 	p.onlineMu.RLock()
 	p.onlineMu.RLock()
 	defer p.onlineMu.RUnlock()
 	defer p.onlineMu.RUnlock()
-
 	if len(p.localActiveInbounds) == 0 {
 	if len(p.localActiveInbounds) == 0 {
-		return map[int][]string{}
+		return nil
 	}
 	}
-	out := make(map[int][]string, 1)
-	local := make([]string, len(p.localActiveInbounds))
-	copy(local, p.localActiveInbounds)
-	out[localNodeKey] = local
+	out := make([]string, len(p.localActiveInbounds))
+	copy(out, p.localActiveInbounds)
 	return out
 	return out
 }
 }
 
 
@@ -371,25 +389,26 @@ func (p *Process) RefreshLocalOnline(activeEmails, activeInboundTags []string, n
 	p.localActiveInbounds = activeInbounds
 	p.localActiveInbounds = activeInbounds
 }
 }
 
 
-// SetNodeOnlineClients records the online-emails set for one remote
-// node. Replaces any previous entry for that node — NodeTrafficSyncJob
-// always sends the full list per tick.
-func (p *Process) SetNodeOnlineClients(nodeID int, emails []string) {
+// SetNodeOnlineTree records the GUID-keyed online subtree one direct remote
+// node reported (its own clients under its panelGuid plus every descendant
+// under theirs). Replaces any previous entry for that node — NodeTrafficSyncJob
+// always sends the full subtree per tick.
+func (p *Process) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
 	p.onlineMu.Lock()
 	p.onlineMu.Lock()
 	defer p.onlineMu.Unlock()
 	defer p.onlineMu.Unlock()
-	if p.nodeOnlineClients == nil {
-		p.nodeOnlineClients = map[int][]string{}
+	if p.nodeOnlineTrees == nil {
+		p.nodeOnlineTrees = map[int]map[string][]string{}
 	}
 	}
-	p.nodeOnlineClients[nodeID] = emails
+	p.nodeOnlineTrees[nodeID] = tree
 }
 }
 
 
-// ClearNodeOnlineClients drops a node's contribution to the online set.
-// Called when a probe fails so a downed node doesn't keep its clients
-// listed as "online" until the next successful probe.
+// ClearNodeOnlineClients drops a direct node's whole subtree contribution.
+// Called when a probe fails so a downed node — and everything behind it — doesn't
+// keep its clients listed as "online" until the next successful probe.
 func (p *Process) ClearNodeOnlineClients(nodeID int) {
 func (p *Process) ClearNodeOnlineClients(nodeID int) {
 	p.onlineMu.Lock()
 	p.onlineMu.Lock()
 	defer p.onlineMu.Unlock()
 	defer p.onlineMu.Unlock()
-	delete(p.nodeOnlineClients, nodeID)
+	delete(p.nodeOnlineTrees, nodeID)
 }
 }
 
 
 // GetUptime returns the uptime of the Xray process in seconds.
 // GetUptime returns the uptime of the Xray process in seconds.