11 Commits
Author SHA1 Message Date
Muse 8e89960474 merge wiring: route agent address lookups through ResolveAgentAddresses (V1.0.1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 00:34:33 +08:00
Muse afb5456b8f merge fix/agent-public-addr: master-observed public IP resolution (V1.0.1) 2026-10-23 00:22:58 +08:00
Muse 36acc4c2b1 merge fix/agent-link-address: agent inbounds export telemetry address (V1.0.1)
# Conflicts:
#	internal/web/service/inbound.go
2026-10-23 00:22:36 +08:00
Muse 7f098c6c4e merge fix/online-lists: agent inbound attribution + node page email lists (V1.0.1) 2026-10-23 00:21:04 +08:00
Muse 942b220f80 merge fix/token-backfill-null: legacy NULL token_plain backfill (V1.0.1) 2026-10-23 00:21:04 +08:00
Muse 7d9a38490d fix(sub): agent-hosted inbounds export the agent's telemetry address, not the 'agent' placeholder
Agent nodes store the create form's placeholder in nodes.address
('agent', port 2053; NodeFormModal.buildPayload). The export fallback
chain treated it as a real node address, so every host-less inbound on
an agent node produced links like vless://...@agent:31324.

SubService now loads agent_nodes bindings alongside nodes (latest
last_seen per node, same rule as the node list) and resolves an
agent-managed node's export address through the single convergence
point agentNodeAddress(nodeID): telemetry IPv4, then IPv6, then ""
so the chain continues to listen/public/request host and can never
emit the placeholder. Host-bound inbounds and traditional nodes are
untouched. GetInboundOptions applies the same substitution via
agentAdvertisedAddresses so the frontend share/QR/WireGuard feed
never sees the placeholder either.
2026-10-22 15:35:21 +00:00
Muse 3df652a67d fix(inbounds): slim list stamps agent-hosted inbounds with the agent node's key
Agent inbounds defined centrally store an empty originNodeGuid, and
inbounds re-homed from a panel node keep the previous host's GUID, so
the inbounds page looked the agent's online set up under a key it is
never filed under. GetInboundsSlim now annotates agent-hosted
inbounds at read time with the node's effective attribution key
(the key SetAgentOnlineClients files onlines under).
2026-10-22 15:33:51 +00:00
Muse c91fb454a8 fix(nodes): per-category client email lists behind the node count chips
NodeView carried only the category counts, so the nodes page chips
could hover to nothing but the category title. GetAll/recountByGuid
now emit online/active/disabled/depleted email lists from the same
bucketing pass as the counts (statuses gain the client email;
the online list is the counted set), and the chips pop the list
over with the inbounds page's popover pattern.
2026-10-22 15:33:46 +00:00
Muse c490bdcd76 fix(nodes): resolve agent public address from master-observed source IP
NAT'd agents (cloud 1:1 EIP) only ever report their private
interface address in heartbeat telemetry, so the node list showed
an unusable RFC1918 address. The master now records the source
address of every authenticated agent request (CF-Connecting-IP,
else first X-Forwarded-For hop, else RemoteAddr; change-only
writes) on the agent row as seen_ipv4/seen_ipv6, and the node
list resolves the displayed pair via service.ResolveAgentAddresses:
public telemetry wins, else a public observed source, else the
reported address as-is. CGNAT 100.64/10 observed addresses are
never promoted. Telemetry values are kept verbatim; resolution
is read-time only, and agents need no changes.
2026-10-22 15:25:08 +00:00
Muse 5c432205ad test(agent):回填回归测试——原生 SQL 造 NULL 老行走真实心跳+install 接口 2026-10-22 14:45:57 +00:00
Muse 8901267867 fix(agent):第 19 条 token 回填守卫兼容 SQL NULL 老行(token_plain IS NULL OR = '') 2026-10-22 14:45:55 +00:00
20 changed files with 1467 additions and 61 deletions
+11
View File
@@ -127,6 +127,17 @@
margin: 0;
}
/* Category chip hover list (same values as the inbounds page). */
.client-email-list {
max-height: 200px;
overflow-y: auto;
min-width: 150px;
}
.client-email-list > div {
padding: 2px 0;
}
.card-history {
margin-top: 4px;
padding-top: 8px;
+75 -33
View File
@@ -1,6 +1,18 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd';
import {
Badge,
Button,
Card,
Dropdown,
Modal,
Popover,
Space,
Switch,
Table,
Tag,
Tooltip,
} from 'antd';
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -48,6 +60,45 @@ function isUpdateEligible(n: NodeRecord): boolean {
return !!n.enable && n.status === 'online';
}
// One category chip of the nodes table's clients cell. When the backend
// sent the category's email list, hovering pops the list over (same
// pattern as the inbounds page); without a list it keeps the plain
// category tooltip. Chip styling is identical either way.
function CategoryChip({
title,
emails,
color,
count,
}: {
title: string;
emails?: string[];
color?: string;
count: number;
}) {
const tag = (
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{count}
</Tag>
);
if (emails && emails.length > 0) {
return (
<Popover
title={title}
content={
<div className="client-email-list">
{emails.map((e) => (
<div key={e}>{e}</div>
))}
</div>
}
>
{tag}
</Popover>
);
}
return <Tooltip title={title}>{tag}</Tooltip>;
}
interface NodeRow extends NodeRecord {
url: string;
key: string | number;
@@ -495,44 +546,35 @@ export default function NodeList({
<TeamOutlined /> {record.clientCount || 0}
</Tag>
{record.activeCount ? (
<Tooltip title={t('subscription.active')}>
<Tag
color="green"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.activeCount}
</Tag>
</Tooltip>
<CategoryChip
title={t('subscription.active')}
emails={record.activeEmails}
color="green"
count={record.activeCount}
/>
) : null}
{record.disabledCount ? (
<Tooltip title={t('disabled')}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{record.disabledCount}
</Tag>
</Tooltip>
<CategoryChip
title={t('disabled')}
emails={record.disabledEmails}
count={record.disabledCount}
/>
) : null}
{record.depletedCount ? (
<Tooltip title={t('depleted')}>
<Tag
color="red"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.depletedCount}
</Tag>
</Tooltip>
<CategoryChip
title={t('depleted')}
emails={record.depletedEmails}
color="red"
count={record.depletedCount}
/>
) : null}
{record.onlineCount ? (
<Tooltip title={t('online')}>
<Tag
color="blue"
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.onlineCount}
</Tag>
</Tooltip>
<CategoryChip
title={t('online')}
emails={record.onlineEmails}
color="blue"
count={record.onlineCount}
/>
) : null}
</Space>
),
+6
View File
@@ -25,6 +25,12 @@ export const NodeRecordSchema = z
activeCount: z.number().optional(),
disabledCount: z.number().optional(),
depletedCount: z.number().optional(),
// Per-category client email lists behind the counts above; the nodes
// page category chips pop these over on hover.
onlineEmails: z.array(z.string()).optional(),
activeEmails: z.array(z.string()).optional(),
disabledEmails: z.array(z.string()).optional(),
depletedEmails: z.array(z.string()).optional(),
lastHeartbeat: z.number().optional(),
lastError: z.string().optional(),
// Xray state captured from the remote node's own /panel/api/server/status.
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { fireEvent, waitFor, within } from '@testing-library/react';
import NodeList from '@/pages/nodes/NodeList';
import type { NodeRecord } from '@/schemas/node';
import { renderWithProviders } from './test-utils';
const noop = () => {};
function renderNodes(nodes: NodeRecord[]) {
return renderWithProviders(
<NodeList
nodes={nodes}
isMobile={false}
selectedIds={[]}
onSelectionChange={noop}
onAdd={noop}
onMtls={noop}
onEdit={noop}
onDelete={noop}
onProbe={noop}
onToggleEnable={noop}
onUpdateNode={noop}
onUpdateSelected={noop}
/>,
);
}
function rowFor(container: HTMLElement, name: string): HTMLElement {
const cell = within(container).getByText(name);
const row = cell.closest('tr');
if (!row) throw new Error(`row not found for ${name}`);
return row as HTMLElement;
}
// The nodes page category chips used to hover to a bare category title.
// When the backend sends the category's email list, hovering a chip must
// pop the list over (same pattern as the inbounds page); without a list
// the chip keeps the plain title tooltip.
describe('NodeList clients-cell category chips', () => {
afterEach(() => vi.restoreAllMocks());
it('pops the online and depleted email lists on hover', async () => {
const { container } = renderNodes([
{
id: 5,
name: 'agent-node',
enable: true,
status: 'online',
nodeType: 'agent',
clientCount: 9,
activeCount: 2,
activeEmails: ['active-a@x', 'active-b@x'],
depletedCount: 3,
depletedEmails: ['dep-a@x', 'dep-b@x', 'dep-c@x'],
onlineCount: 1,
onlineEmails: ['online@x'],
},
]);
const row = rowFor(container, 'agent-node');
fireEvent.mouseEnter(within(row).getByText('1'));
await waitFor(
() => {
expect(document.body.textContent).toContain('online@x');
},
{ timeout: 5000 },
);
fireEvent.mouseEnter(within(row).getByText('3'));
await waitFor(
() => {
expect(document.body.textContent).toContain('dep-b@x');
},
{ timeout: 5000 },
);
});
it('keeps the title tooltip when a category has no email list', async () => {
const { container } = renderNodes([
{
id: 6,
name: 'bare-node',
enable: true,
status: 'online',
clientCount: 4,
onlineCount: 1,
},
]);
const row = rowFor(container, 'bare-node');
fireEvent.mouseEnter(within(row).getByText('1'));
await waitFor(() => {
expect(document.body.textContent).toContain('Online');
});
expect(document.querySelector('.client-email-list')).toBeNull();
});
});
+14
View File
@@ -48,6 +48,20 @@ type AgentNode struct {
// a telemetry object.
TelemetryAt int64 `json:"telemetryAt"`
// Seen source addresses (public-address resolution): the source IP of
// the agent's own authenticated requests, as observed by the master —
// the CF-Connecting-IP header when the agent comes through Cloudflare,
// else the direct peer address. An agent behind 1:1 NAT reports only
// its private interface address in telemetry (its public address is
// not assigned to any local interface, so enumeration can never see
// it); the observed source address is the master's view of the
// agent's public side. Writes are change-only (see
// AgentController.captureSeenIP); SeenIPAt is the unix-milli time of
// the last change, not of the last request.
SeenIPv4 string `json:"seenIpv4"`
SeenIPv6 string `json:"seenIpv6"`
SeenIPAt int64 `json:"seenIpAt"`
// One-click update (item 20): the master records an update intent here;
// the agent picks it up from its config poll, downloads + verifies +
// swaps its binary, restarts itself and reports the outcome back on
+9
View File
@@ -847,6 +847,15 @@ type Node struct {
DisabledCount int `json:"disabledCount" gorm:"-" example:"3"`
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
// Per-category client email lists backing the counts above, filled by
// the node read paths (GetAll / recountByGuid) from the same bucketing
// so the nodes page can show WHO is in each category on hover, not just
// how many. Read-time only, never persisted.
OnlineEmails []string `json:"-" gorm:"-"`
ActiveEmails []string `json:"-" gorm:"-"`
DisabledEmails []string `json:"-" gorm:"-"`
DepletedEmails []string `json:"-" 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
+65 -2
View File
@@ -53,6 +53,13 @@ type SubService struct {
// inbound whose NodeID is set. Keeps the per-link host derivation
// O(1) instead of O(N) DB hits.
nodesByID map[int]*model.Node
// agentByNodeID is populated alongside nodesByID with the latest
// agent binding per node (agent_nodes rows). A node present here is
// agent-managed: its Node.Address is the literal placeholder "agent"
// written by the create form (the panel never dials agent nodes), so
// resolveInboundAddress advertises the agent's telemetry address
// instead of the stored placeholder.
agentByNodeID map[int]*model.AgentNode
// statsByEmail maps a client email to its traffic row across ALL inbounds
// loaded for the request. client_traffics.email is globally unique, so this
// lets statsForClient resolve usage for a client even on an inbound that
@@ -1296,6 +1303,7 @@ func (s *SubService) loadNodes() {
if err := db.Model(&model.Node{}).Where("address != ''").Find(&nodes).Error; err != nil {
logger.Warning("subscription: load nodes failed:", err)
s.nodesByID = nil
s.agentByNodeID = nil
return
}
m := make(map[int]*model.Node, len(nodes))
@@ -1303,11 +1311,60 @@ func (s *SubService) loadNodes() {
m[n.Id] = n
}
s.nodesByID = m
s.loadAgentBindings()
}
// loadAgentBindings refreshes agentByNodeID with the most recently seen
// agent binding for each node, mirroring the selection the node list
// makes in annotateAgentTelemetry (latest reporter wins).
func (s *SubService) loadAgentBindings() {
db := database.GetDB()
var agents []model.AgentNode
if err := db.Model(&model.AgentNode{}).
Where("node_id IS NOT NULL").
Order("last_seen DESC").
Find(&agents).Error; err != nil {
logger.Warning("subscription: load agent bindings failed:", err)
s.agentByNodeID = nil
return
}
byNode := make(map[int]*model.AgentNode, len(agents))
for i := range agents {
if agents[i].NodeID == nil {
continue
}
if _, ok := byNode[*agents[i].NodeID]; !ok {
byNode[*agents[i].NodeID] = &agents[i]
}
}
s.agentByNodeID = byNode
}
// agentNodeAddress is the single point where the export chain resolves
// the connect address of an agent-managed node: the IPv4 from the
// agent's latest heartbeat telemetry, falling back to its IPv6. It
// returns "" when the node has no bound agent or the agent never
// reported an address, letting the caller's fallback chain continue to
// listen / configured public host / request host. The address stored in
// the node row is never used for agent nodes — it is the create form's
// placeholder "agent", which is not a connectable host.
func (s *SubService) agentNodeAddress(nodeID int) string {
ag, ok := s.agentByNodeID[nodeID]
if !ok || ag == nil {
return ""
}
v4, v6 := service.ResolveAgentAddresses(ag)
if v4 != "" {
return v4
}
return v6
}
// resolveInboundAddress picks the host an external client should connect to,
// honoring the inbound's share address strategy the same way the panel's
// share/QR link builder does (#5208):
// share/QR link builder does (#5208). For agent-managed nodes the "node"
// step of every chain is the agent's telemetry-reported address (see
// agentNodeAddress), never the placeholder stored in the node row:
// - "listen": an explicit, client-reachable bind Listen wins, backed by the
// node's address for node-managed inbounds;
// - "custom": the inbound's ShareAddr wins, then node, then listen;
@@ -1324,7 +1381,13 @@ func (s *SubService) loadNodes() {
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
var nodeAddr string
if inbound.NodeID != nil && s.nodesByID != nil {
if n, ok := s.nodesByID[*inbound.NodeID]; ok {
if _, isAgent := s.agentByNodeID[*inbound.NodeID]; isAgent {
// Agent-managed node: the stored address is the create
// form's placeholder ("agent"), never a connectable host,
// so the node step of every strategy resolves through the
// agent's own reported address instead.
nodeAddr = s.agentNodeAddress(*inbound.NodeID)
} else if n, ok := s.nodesByID[*inbound.NodeID]; ok {
nodeAddr = n.Address
}
}
+137
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
@@ -204,6 +205,142 @@ func TestResolveInboundAddress(t *testing.T) {
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
}
})
// Agent-managed nodes store the literal placeholder "agent" in the
// node row (the create form writes it; the panel never dials agent
// nodes). The node step of every strategy must resolve through the
// agent's telemetry-reported address instead — a link addressed to
// "agent:<port>" is unusable.
agentSvc := func(ipv4, ipv6 string) *SubService {
id := 8
return &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{8: {Id: 8, Address: "agent", Port: 2053}},
agentByNodeID: map[int]*model.AgentNode{
8: {NodeID: &id, IPv4: ipv4, IPv6: ipv6},
},
}
}
agentInbound := func(mut func(*model.Inbound)) *model.Inbound {
id := 8
ib := &model.Inbound{NodeID: &id, Port: 31324}
if mut != nil {
mut(ib)
}
return ib
}
t.Run("agent-managed inbound advertises the agent telemetry ipv4", func(t *testing.T) {
s := agentSvc("198.51.100.23", "2001:db8::23")
if got := s.resolveInboundAddress(agentInbound(nil)); got != "198.51.100.23" {
t.Fatalf("agent address = %q, want telemetry IPv4 198.51.100.23 (never the placeholder)", got)
}
})
t.Run("agent-managed inbound falls back to telemetry ipv6", func(t *testing.T) {
s := agentSvc("", "2001:db8::23")
if got := s.resolveInboundAddress(agentInbound(nil)); got != "2001:db8::23" {
t.Fatalf("agent address = %q, want telemetry IPv6 2001:db8::23", got)
}
})
t.Run("agent-managed inbound without telemetry continues the chain", func(t *testing.T) {
s := agentSvc("", "")
ib := agentInbound(func(ib *model.Inbound) { ib.Listen = "203.0.113.9" })
if got := s.resolveInboundAddress(ib); got != "203.0.113.9" {
t.Fatalf("agent-without-telemetry address = %q, want routable listen 203.0.113.9", got)
}
ib.Listen = "0.0.0.0"
if got := s.resolveInboundAddress(ib); got != reqHost {
t.Fatalf("agent-without-telemetry address = %q, want subscriber host %q (never the placeholder)", got, reqHost)
}
})
t.Run("agent-managed listen strategy still prefers the bind", func(t *testing.T) {
s := agentSvc("198.51.100.23", "")
ib := agentInbound(func(ib *model.Inbound) {
ib.Listen = "203.0.113.9"
ib.ShareAddrStrategy = "listen"
})
if got := s.resolveInboundAddress(ib); got != "203.0.113.9" {
t.Fatalf("listen-strategy agent address = %q, want the bind 203.0.113.9", got)
}
ib.Listen = "0.0.0.0"
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
t.Fatalf("listen-strategy agent address = %q, want telemetry IPv4 fallback 198.51.100.23", got)
}
})
t.Run("agent-managed custom strategy keeps the share address first", func(t *testing.T) {
s := agentSvc("198.51.100.23", "")
ib := agentInbound(func(ib *model.Inbound) {
ib.ShareAddrStrategy = "custom"
ib.ShareAddr = "edge.example.com"
})
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
t.Fatalf("custom-strategy agent address = %q, want edge.example.com", got)
}
ib.ShareAddr = ""
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
t.Fatalf("custom-strategy agent fallback = %q, want telemetry IPv4 198.51.100.23", got)
}
})
// loadNodes must surface the latest agent binding per node so a real
// request (PrepareForRequest) resolves agent addresses from the DB,
// not just from hand-built fixtures.
t.Run("loadNodes picks the latest agent binding per node", func(t *testing.T) {
initSubDB(t)
node := &model.Node{Name: "agent-1", Scheme: "https", Address: "agent", Port: 2053, Enable: true}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
stale := &model.AgentNode{TokenHash: "stalehash", Name: "agent-1", NodeID: &node.Id, IPv4: "192.0.2.1", LastSeen: 1000}
fresh := &model.AgentNode{TokenHash: "freshhash", Name: "agent-1", NodeID: &node.Id, IPv4: "198.51.100.23", IPv6: "2001:db8::23", LastSeen: 2000}
if err := database.GetDB().Create(stale).Error; err != nil {
t.Fatalf("create stale binding: %v", err)
}
if err := database.GetDB().Create(fresh).Error; err != nil {
t.Fatalf("create fresh binding: %v", err)
}
s := &SubService{address: reqHost}
s.loadNodes()
ib := &model.Inbound{NodeID: &node.Id, Port: 31324}
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
t.Fatalf("loaded agent address = %q, want latest binding IPv4 198.51.100.23", got)
}
if got := s.agentNodeAddress(node.Id); got != "198.51.100.23" {
t.Fatalf("agentNodeAddress = %q, want 198.51.100.23", got)
}
if got := s.agentNodeAddress(99999); got != "" {
t.Fatalf("agentNodeAddress(unknown) = %q, want empty", got)
}
})
}
// A full share link for an agent-hosted inbound must carry the agent's
// telemetry address and inbound port — the regression was a link whose
// host was the node row's placeholder, e.g. vless://…@agent:31324.
func TestAgentInboundShareLinkUsesTelemetryAddress(t *testing.T) {
nodeID := 8
s := &SubService{
address: "sub.example.com",
nodesByID: map[int]*model.Node{8: {Id: 8, Address: "agent", Port: 2053}},
agentByNodeID: map[int]*model.AgentNode{
8: {NodeID: &nodeID, IPv4: "198.51.100.23", IPv6: "2001:db8::23"},
},
}
ib := shareLinkInbound(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
ib.NodeID = &nodeID
ib.Listen = "0.0.0.0"
ib.Port = 31324
link := s.GetLink(ib, "user")
if !strings.Contains(link, "@198.51.100.23:31324") {
t.Fatalf("agent share link = %q, want host 198.51.100.23:31324", link)
}
if strings.Contains(link, "@agent:") {
t.Fatalf("agent share link leaks the placeholder address: %q", link)
}
}
func TestUnmarshalStreamSettings(t *testing.T) {
+77 -3
View File
@@ -6,7 +6,9 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"net"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -83,11 +85,16 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
// Item 19 backfill: the token has just passed the hash check, so for
// rows created before plaintext storage existed, adopt the presented
// token as the recoverable copy. The guarded update only fires while
// token_plain is empty — an authenticated request can never overwrite
// an already-stored token (only issuance writes a fresh one).
// token_plain is unset — an authenticated request can never overwrite
// an already-stored token (only issuance writes a fresh one). "Unset"
// must cover SQL NULL as well as '': AutoMigrate added the column to
// pre-existing rows as NULL, and NULL = '' is never true, so an
// equality-only guard silently matched zero legacy rows in
// production (the GORM-scanned value reads as "" either way, which is
// why the in-memory check above cannot distinguish them).
if agent.TokenPlain == "" {
if res := database.GetDB().Model(&model.AgentNode{}).
Where("id = ? AND token_plain = ''", agent.Id).
Where("id = ? AND (token_plain IS NULL OR token_plain = '')", agent.Id).
Update("token_plain", token); res.Error == nil && res.RowsAffected > 0 {
agent.TokenPlain = token
}
@@ -95,6 +102,69 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
return &agent, true
}
// requestSourceIP extracts the real source IP of an agent request.
// Agents reach the master through Cloudflare, so the TCP peer is a CF
// edge and the agent's own address travels in CF-Connecting-IP; the
// first X-Forwarded-For hop is the next best signal, and the transport
// RemoteAddr is the direct-connection fallback. Only literal IPs are
// accepted — anything unparsable falls through to the next source.
func requestSourceIP(c *gin.Context) net.IP {
var candidates []string
if v := c.GetHeader("CF-Connecting-IP"); v != "" {
candidates = append(candidates, v)
}
if v := c.GetHeader("X-Forwarded-For"); v != "" {
candidates = append(candidates, strings.Split(v, ",")[0])
}
if host, _, err := net.SplitHostPort(c.Request.RemoteAddr); err == nil {
candidates = append(candidates, host)
} else {
candidates = append(candidates, c.Request.RemoteAddr)
}
for _, cand := range candidates {
if ip := net.ParseIP(strings.TrimSpace(cand)); ip != nil {
return ip
}
}
return nil
}
// captureSeenIP records the source address of an authenticated agent
// request on the agent row (SeenIPv4/SeenIPv6). This is the master's
// view of the agent's public side: for a NAT'd agent it is the only
// place the public address ever appears. Writes are change-only —
// traffic reports land every ~10s and the address almost never moves,
// so an unchanged address must not turn into a DB write per request.
func (a *AgentController) captureSeenIP(c *gin.Context, agent *model.AgentNode) {
ip := requestSourceIP(c)
if ip == nil || agent == nil {
return
}
updates := map[string]any{"seen_ip_at": time.Now().UnixMilli()}
if v4 := ip.To4(); v4 != nil {
if agent.SeenIPv4 == v4.String() {
return
}
updates["seen_ipv4"] = v4.String()
} else {
if agent.SeenIPv6 == ip.String() {
return
}
updates["seen_ipv6"] = ip.String()
}
if err := database.GetDB().Model(&model.AgentNode{}).
Where("id = ?", agent.Id).Updates(updates).Error; err != nil {
return
}
if v, ok := updates["seen_ipv4"]; ok {
agent.SeenIPv4 = v.(string)
}
if v, ok := updates["seen_ipv6"]; ok {
agent.SeenIPv6 = v.(string)
}
agent.SeenIPAt = updates["seen_ip_at"].(int64)
}
func (a *AgentController) touchAgent(id uint, name string) {
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
if name != "" {
@@ -128,6 +198,7 @@ func (a *AgentController) register(c *gin.Context) {
c.JSON(401, gin.H{"message": "unknown token"})
return
}
a.captureSeenIP(c, agent)
a.touchAgent(uint(agent.Id), req.Name)
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
jsonObj(c, gin.H{"message": "registered"}, nil)
@@ -252,6 +323,7 @@ func (a *AgentController) heartbeat(c *gin.Context) {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.captureSeenIP(c, agent)
// Telemetry is optional and best-effort: a body that fails to parse
// (or no body at all) must never fail the heartbeat itself.
var req struct {
@@ -281,6 +353,7 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.captureSeenIP(c, agent)
if agent.NodeID == nil {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return
@@ -339,6 +412,7 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.captureSeenIP(c, agent)
var req agentTrafficReport
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, "Invalid request", err)
@@ -0,0 +1,169 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Public-address capture: every authenticated agent request records its
// source address on the agent row — CF-Connecting-IP first (agents come
// through Cloudflare), then the first X-Forwarded-For hop, then the
// transport RemoteAddr. Writes are change-only so the ~10s traffic
// cadence does not become a DB write per request.
func TestAgentSeenIPCapture(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
node := &model.Node{Name: "seen-ip-node", Address: ""}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
const token = "seen-ip-test-token"
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: "seen-ip-",
Name: "seen-ip-node",
NodeID: &node.Id,
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
heartbeat := func(headers map[string]string, remoteAddr string) int {
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
for k, v := range headers {
req.Header.Set(k, v)
}
if remoteAddr != "" {
req.RemoteAddr = remoteAddr
}
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec.Code
}
load := func() model.AgentNode {
var row model.AgentNode
if err := db.First(&row, agent.Id).Error; err != nil {
t.Fatalf("reload agent: %v", err)
}
return row
}
// CF-Connecting-IP wins and is stored.
if code := heartbeat(map[string]string{"CF-Connecting-IP": "111.91.29.150"}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with CF header: status %d", code)
}
row := load()
if row.SeenIPv4 != "111.91.29.150" || row.SeenIPAt == 0 {
t.Fatalf("CF source not stored: %+v", row)
}
// Change-only: same address again must not rewrite (SeenIPAt frozen).
frozen := row.SeenIPAt
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).
Update("seen_ip_at", frozen).Error; err != nil {
t.Fatalf("pin seen_ip_at: %v", err)
}
if code := heartbeat(map[string]string{"CF-Connecting-IP": "111.91.29.150"}, ""); code != http.StatusOK {
t.Fatalf("repeat heartbeat: status %d", code)
}
if row = load(); row.SeenIPAt != frozen {
t.Fatalf("unchanged address rewrote the row: seen_ip_at %d -> %d", frozen, row.SeenIPAt)
}
// X-Forwarded-For first hop is next in precedence.
if code := heartbeat(map[string]string{"X-Forwarded-For": "203.0.113.9, 70.41.3.18"}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with XFF: status %d", code)
}
if row = load(); row.SeenIPv4 != "203.0.113.9" {
t.Fatalf("XFF first hop not stored: %+v", row)
}
// CF beats XFF when both are present.
if code := heartbeat(map[string]string{
"CF-Connecting-IP": "111.91.29.150",
"X-Forwarded-For": "203.0.113.9",
}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with both headers: status %d", code)
}
if row = load(); row.SeenIPv4 != "111.91.29.150" {
t.Fatalf("CF header did not win over XFF: %+v", row)
}
// A garbage CF value falls through to XFF.
if code := heartbeat(map[string]string{
"CF-Connecting-IP": "not-an-ip",
"X-Forwarded-For": "198.51.100.7",
}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with garbage CF header: status %d", code)
}
if row = load(); row.SeenIPv4 != "198.51.100.7" {
t.Fatalf("garbage CF value did not fall through to XFF: %+v", row)
}
// No headers: the transport RemoteAddr is the fallback.
if code := heartbeat(nil, "198.51.100.23:5555"); code != http.StatusOK {
t.Fatalf("heartbeat without headers: status %d", code)
}
if row = load(); row.SeenIPv4 != "198.51.100.23" {
t.Fatalf("RemoteAddr fallback not stored: %+v", row)
}
// IPv6 sources land in the v6 column.
if code := heartbeat(map[string]string{"CF-Connecting-IP": "2602:f32b:1:310f::1"}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with v6 CF header: status %d", code)
}
if row = load(); row.SeenIPv6 != "2602:f32b:1:310f::1" {
t.Fatalf("v6 source not stored: %+v", row)
}
// Unauthenticated requests capture nothing.
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).
Updates(map[string]any{"seen_ipv4": "", "seen_ipv6": ""}).Error; err != nil {
t.Fatalf("reset seen: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
req.Header.Set("Authorization", "Bearer wrong-token")
req.Header.Set("CF-Connecting-IP", "111.91.29.150")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad token heartbeat: status %d, want 401", rec.Code)
}
if row = load(); row.SeenIPv4 != "" || row.SeenIPv6 != "" {
t.Fatalf("unauthenticated request captured a source: %+v", row)
}
// Register is an authenticated agent request too: it captures.
regReq := httptest.NewRequest(http.MethodPost, "/panel/api/agent/register",
bytes.NewBufferString(`{"token":"`+token+`","name":"seen-ip-node"}`))
regReq.Header.Set("Content-Type", "application/json")
regReq.Header.Set("CF-Connecting-IP", "111.91.29.150")
regRec := httptest.NewRecorder()
engine.ServeHTTP(regRec, regReq)
if regRec.Code != http.StatusOK {
t.Fatalf("register: status %d", regRec.Code)
}
if row = load(); row.SeenIPv4 != "111.91.29.150" {
t.Fatalf("register did not capture source: %+v", row)
}
}
@@ -123,6 +123,62 @@ func TestAgentTokenBackfillOnAuthenticatedRequest(t *testing.T) {
}
}
// Regression for the production failure of the first V1.0.0 deployment:
// rows that existed before item 19 got token_plain from AutoMigrate as
// SQL NULL, not ”, and the original guard `token_plain = ”` never
// matches NULL — the backfill silently never fired on any legacy node.
// Seed the row exactly as production has it (raw SQL, explicit NULL)
// instead of through GORM, which would write ”.
func TestAgentTokenBackfillFromNullLegacyRow(t *testing.T) {
engine, node := setupBackfillTest(t)
const token = "legacy-token-backfill-00000000000000000004"
db := database.GetDB()
if err := db.Exec(
"INSERT INTO agent_nodes (token_hash, token_prefix, token_plain, name, node_id, last_seen, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, 0, 0, 0)",
hashAgentToken(token), token[:12], "backfill-node", node.Id,
).Error; err != nil {
t.Fatalf("seed NULL legacy row: %v", err)
}
var seeded model.AgentNode
if err := db.Where("token_hash = ?", hashAgentToken(token)).First(&seeded).Error; err != nil {
t.Fatalf("load seeded row: %v", err)
}
var nullRows int
if err := db.Raw("SELECT COUNT(*) FROM agent_nodes WHERE id = ? AND token_plain IS NULL", seeded.Id).Scan(&nullRows).Error; err != nil {
t.Fatalf("NULL check: %v", err)
}
if nullRows != 1 {
t.Fatalf("seeded row is not SQL NULL as intended (null rows = %d)", nullRows)
}
if code := heartbeatWith(engine, token); code != http.StatusOK {
t.Fatalf("heartbeat with valid token: status %d", code)
}
if row := reloadAgent(t, seeded.Id); row.TokenPlain != token {
t.Fatalf("token not backfilled from NULL legacy row: %q", row.TokenPlain)
}
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/install/"+strconv.Itoa(node.Id), nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("install endpoint: status %d", rec.Code)
}
var resp struct {
Success bool `json:"success"`
Obj struct {
Token string `json:"token"`
HasToken bool `json:"hasToken"`
} `json:"obj"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("install response not JSON: %v", err)
}
if !resp.Success || !resp.Obj.HasToken || resp.Obj.Token != token {
t.Fatalf("install endpoint did not return the backfilled token: %+v", resp.Obj)
}
}
func TestAgentTokenNoBackfillOnFailedAuth(t *testing.T) {
engine, node := setupBackfillTest(t)
const token = "legacy-token-backfill-00000000000000000002"
+92
View File
@@ -0,0 +1,92 @@
package service
import (
"net"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Public-address resolution for agent nodes.
//
// An agent reports the addresses of its own network interfaces in its
// heartbeat telemetry (P13). On a host behind 1:1 NAT (typical cloud
// EIP setups) the public address is mapped in the provider's network
// and never assigned to a local interface, so the agent can only ever
// report its private address — the node list then shows an unusable
// RFC1918 address for such nodes. The master, however, observes the
// source address of every authenticated agent request (recorded on the
// agent row as SeenIPv4/SeenIPv6 by AgentController.captureSeenIP).
// ResolveAgentAddresses combines both views into the address pair a
// client can actually connect to.
// IsPublicIPLiteral reports whether s is an IP literal that is globally
// routable: not loopback, link-local, multicast, unspecified, RFC1918,
// ULA, or the CGNAT shared range 100.64.0.0/10 (RFC 6598 — not private,
// but not publicly routable either). Go's IsGlobalUnicast alone is not
// sufficient for this: it counts RFC1918 addresses as global unicast.
//
// Exported for reuse by the subscription/export chain.
func IsPublicIPLiteral(s string) bool {
ip := net.ParseIP(strings.TrimSpace(s))
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() || ip.IsPrivate() {
return false
}
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xC0 == 0x40 {
return false // 100.64.0.0/10, CGNAT shared address space
}
return true
}
// familyLiteral returns s normalized as an IP literal of the wanted
// family, or "" when s is not a valid literal of that family.
func familyLiteral(s string, want4 bool) string {
ip := net.ParseIP(strings.TrimSpace(s))
if ip == nil {
return ""
}
if want4 {
if v4 := ip.To4(); v4 != nil {
return v4.String()
}
return ""
}
if ip.To4() != nil {
return ""
}
return ip.String()
}
// resolveAddressFamily picks the externally usable address of one
// family: the agent-reported address when it is public; otherwise the
// master-observed source address when IT is public; otherwise the
// reported address as-is (a private telemetry address still beats
// nothing, e.g. for LAN-only deployments). A non-public observed
// address is never promoted over the reported one.
func resolveAddressFamily(reported, seen string, want4 bool) string {
rep := familyLiteral(reported, want4)
if IsPublicIPLiteral(rep) {
return rep
}
if sn := familyLiteral(seen, want4); IsPublicIPLiteral(sn) {
return sn
}
return rep
}
// ResolveAgentAddresses returns the externally usable IPv4/IPv6 pair
// for an agent binding, resolved from its telemetry addresses and the
// source addresses the master observed on its requests. This is the
// single resolution point shared by the node list (agent row address)
// and the subscription/export chain (advertised connect address).
func ResolveAgentAddresses(ag *model.AgentNode) (ipv4, ipv6 string) {
if ag == nil {
return "", ""
}
return resolveAddressFamily(ag.IPv4, ag.SeenIPv4, true),
resolveAddressFamily(ag.IPv6, ag.SeenIPv6, false)
}
+164
View File
@@ -0,0 +1,164 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Public-address resolution: the node list must show the address a
// client can actually connect to. An agent behind 1:1 NAT only ever
// reports its private interface address; the master-observed source
// address (SeenIPv4/SeenIPv6) is the public side. Resolution order per
// family: public telemetry > public observed > telemetry as-is.
func TestIsPublicIPLiteral(t *testing.T) {
cases := map[string]bool{
"8.8.8.8": true,
"111.91.29.150": true,
"203.0.113.7": true, // documentation range: not excluded (codebase treats it as public)
"2602:f32b:1:310f::1": true,
"2001:db8::7": true,
"10.0.0.1": false, // RFC1918
"172.31.2.240": false, // RFC1918 (the HWSG case)
"192.168.1.1": false, // RFC1918
"100.64.1.2": false, // CGNAT shared space
"100.127.255.254": false, // CGNAT shared space, top edge
"127.0.0.1": false, // loopback
"169.254.1.1": false, // link-local
"fd00::1": false, // ULA
"fe80::1": false, // link-local v6
"::1": false,
"::": false,
"224.0.0.1": false, // multicast
"": false,
"not-an-ip": false,
"999.1.1.1": false,
}
for in, want := range cases {
if got := IsPublicIPLiteral(in); got != want {
t.Errorf("IsPublicIPLiteral(%q) = %v, want %v", in, got, want)
}
}
}
func TestResolveAgentAddresses(t *testing.T) {
cases := []struct {
name string
ag *model.AgentNode
want4, want6 string
}{
{
name: "telemetry public wins over seen public",
ag: &model.AgentNode{IPv4: "140.235.229.19", SeenIPv4: "8.8.8.8"},
want4: "140.235.229.19",
},
{
name: "NAT: private telemetry, public seen",
ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "111.91.29.150"},
want4: "111.91.29.150",
},
{
name: "both private: telemetry fallback",
ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "192.168.0.8"},
want4: "172.31.2.240",
},
{
name: "no telemetry yet, public seen",
ag: &model.AgentNode{SeenIPv4: "111.91.29.150"},
want4: "111.91.29.150",
},
{
name: "CGNAT seen is not trusted",
ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "100.64.1.2"},
want4: "172.31.2.240",
},
{
name: "v6 only, telemetry public",
ag: &model.AgentNode{IPv6: "2602:f32b:1:310f::1"},
want6: "2602:f32b:1:310f::1",
},
{
name: "v6 ULA telemetry, public seen",
ag: &model.AgentNode{IPv6: "fd00::1", SeenIPv6: "2401:1da0:22:148::a"},
want6: "2401:1da0:22:148::a",
},
{
name: "garbage telemetry ignored",
ag: &model.AgentNode{IPv4: "not-an-ip", SeenIPv4: "111.91.29.150"},
want4: "111.91.29.150",
},
{
name: "families resolve independently",
ag: &model.AgentNode{IPv4: "140.235.229.19", IPv6: "fd00::9", SeenIPv6: "2401:1da0:22:148::a"},
want4: "140.235.229.19",
want6: "2401:1da0:22:148::a",
},
{
name: "nothing known",
ag: &model.AgentNode{},
},
{
name: "nil agent",
ag: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v4, v6 := ResolveAgentAddresses(tc.ag)
if v4 != tc.want4 || v6 != tc.want6 {
t.Fatalf("ResolveAgentAddresses(%+v) = (%q, %q), want (%q, %q)",
tc.ag, v4, v6, tc.want4, tc.want6)
}
})
}
}
// Display: the node list row address comes from ResolveAgentAddresses,
// including for an agent that has not reported telemetry yet.
func TestAnnotateAgentTelemetryResolvedAddress(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
natNode := &model.Node{Id: 7501, Name: "nat-agent-node", Address: ""}
preNode := &model.Node{Id: 7502, Name: "pre-telemetry-agent-node", Address: ""}
for _, n := range []*model.Node{natNode, preNode} {
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %d: %v", n.Id, err)
}
}
nat := &model.AgentNode{
TokenHash: "hash-nat", Name: "nat-agent-node", NodeID: &natNode.Id,
AgentVersion: "A1.0.0", XrayVersion: "26.6.27", TelemetryAt: 1700000000000,
IPv4: "172.31.2.240", SeenIPv4: "111.91.29.150",
}
pre := &model.AgentNode{
TokenHash: "hash-pre", Name: "pre-telemetry-agent-node", NodeID: &preNode.Id,
SeenIPv4: "203.0.113.55", SeenIPAt: 1700000000000,
}
for _, ag := range []*model.AgentNode{nat, pre} {
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create agent row: %v", err)
}
}
views := toNodeViews([]*model.Node{natNode, preNode})
annotateNodeTypes(views)
annotateAgentTelemetry(views)
byID := map[int]*NodeView{}
for _, v := range views {
byID[v.Id] = v
}
if got := byID[7501].Ipv4; got != "111.91.29.150" {
t.Fatalf("NAT node address = %q, want observed public 111.91.29.150 (view %+v)", got, byID[7501])
}
if got := byID[7502].Ipv4; got != "203.0.113.55" {
t.Fatalf("pre-telemetry node address = %q, want observed 203.0.113.55 (view %+v)", got, byID[7502])
}
// The pre-telemetry overlay must not invent other fields.
if pv := byID[7502]; pv.XrayVersion != "" || pv.UptimeSecs != 0 || pv.PanelVersion != "" {
t.Fatalf("pre-telemetry view picked up telemetry fields: %+v", pv)
}
}
+117 -1
View File
@@ -196,6 +196,59 @@ func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
}
}
// annotateAgentOriginGuid fills OriginNodeGuid (read-time only, like
// annotateLocalOriginGuid) for inbounds hosted on agent-managed nodes with
// the node's effective attribution key — the exact key
// SetAgentOnlineClients files the node's reported online clients under.
//
// Agent inbounds are defined centrally on the master, so their stored
// OriginNodeGuid is either empty (created straight onto the agent node) or
// STALE: an inbound re-homed from a panel node keeps the GUID the snapshot
// merge stamped for its previous host, and nothing on the move path rewrites
// it. The inbounds page looks the online set up by that stored GUID first,
// so a re-homed agent inbound's online list never matched and its clients
// never lit up. An agent has no sub-node chain to preserve attribution for —
// the hosting node physically terminates every one of its inbounds — so the
// node's own key is always the right value, and computing it at read time
// also heals rows already carrying a stale GUID without a storage rewrite.
func (s *InboundService) annotateAgentOriginGuid(db *gorm.DB, inbounds []*model.Inbound) {
nodeIDs := make(map[int]struct{})
for _, ib := range inbounds {
if ib.NodeID != nil {
nodeIDs[*ib.NodeID] = struct{}{}
}
}
if len(nodeIDs) == 0 {
return
}
agents := agentManagedNodeIDs()
keyByNode := make(map[int]string, len(nodeIDs))
for id := range nodeIDs {
if !agents[id] {
continue
}
var nodeRow model.Node
if err := db.Select("guid").Where("id = ?", id).First(&nodeRow).Error; err != nil {
continue
}
nodeRow.Id = id
if key := effectiveNodeKey(&nodeRow); key != "" {
keyByNode[id] = key
}
}
if len(keyByNode) == 0 {
return
}
for _, ib := range inbounds {
if ib.NodeID == nil {
continue
}
if key, ok := keyByNode[*ib.NodeID]; ok {
ib.OriginNodeGuid = key
}
}
}
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
// strips every per-client field other than email / enable / comment from
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
@@ -215,6 +268,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
}
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
s.annotateAgentOriginGuid(db, inbounds)
// Top up stats rows owned by sibling inbounds (multi-attached clients)
// so the list's depleted/expiring badges see every client; the UUID/SubId
// enrichment stays skipped. Must run before slimming strips the settings.
@@ -362,6 +416,21 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Agent-managed nodes carry the create form's placeholder ("agent")
// in nodes.address, which is not a connectable host. For those rows
// NodeAddress must be the agent's telemetry-reported address (IPv4,
// then IPv6; "" when never reported, so the frontend fallback chain
// continues as it does for a local inbound) — otherwise every
// share/QR link and WireGuard endpoint rendered from these options
// points at the literal placeholder. Mirrors the backend export
// chain's agentNodeAddress in internal/sub.
rowNodeIDs := make(map[int]bool)
for _, r := range rows {
if r.NodeId != nil {
rowNodeIDs[*r.NodeId] = true
}
}
agentAddrByNode := agentAdvertisedAddresses(db, rowNodeIDs)
out := make([]InboundOption, 0, len(rows))
for _, r := range rows {
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
@@ -369,6 +438,12 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
if shareAddrStrategy == "node" {
shareAddrStrategy = ""
}
nodeAddress := r.NodeAddress
if r.NodeId != nil {
if addr, isAgent := agentAddrByNode[*r.NodeId]; isAgent {
nodeAddress = addr
}
}
out = append(out, InboundOption{
Id: r.Id,
Remark: r.Remark,
@@ -384,7 +459,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
MtprotoDomain: inboundMtprotoDomain(r.Protocol, r.Settings),
AwgServer: inboundAmneziaWGServer(r.Protocol, r.Settings),
NodeId: r.NodeId,
NodeAddress: r.NodeAddress,
NodeAddress: nodeAddress,
Listen: r.Listen,
ShareAddr: r.ShareAddr,
ShareAddrStrategy: shareAddrStrategy,
@@ -393,6 +468,47 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
return out, nil
}
// agentAdvertisedAddresses resolves, for each requested node id that is
// agent-managed, the address share/export surfaces should advertise for
// it: the IPv4 from the bound agent's latest heartbeat telemetry,
// falling back to its IPv6 ("" when the agent never reported one). The
// map contains an entry for every agent-managed id in the set —
// presence in the map is the agent-managed signal, mirroring
// IsAgentManaged's binding-existence check — so callers can tell
// "agent node without telemetry" apart from "traditional node" and
// never fall back to the placeholder stored in nodes.address. Latest
// reporter wins when several tokens are bound to one node, mirroring
// annotateAgentTelemetry.
func agentAdvertisedAddresses(db *gorm.DB, nodeIDs map[int]bool) map[int]string {
out := make(map[int]string)
if len(nodeIDs) == 0 {
return out
}
var agents []model.AgentNode
if err := db.Model(&model.AgentNode{}).
Where("node_id IS NOT NULL").
Order("last_seen DESC").
Find(&agents).Error; err != nil {
return out
}
for i := range agents {
ag := &agents[i]
if ag.NodeID == nil || !nodeIDs[*ag.NodeID] {
continue
}
if _, done := out[*ag.NodeID]; done {
continue
}
v4, v6 := ResolveAgentAddresses(ag)
addr := v4
if addr == "" {
addr = v6
}
out[*ag.NodeID] = addr
}
return out
}
func inboundWireguardHints(protocol string, settings string) (string, int, string) {
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
return "", 0, ""
@@ -0,0 +1,95 @@
package service
import (
"fmt"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// The inbounds page looks a node's online set up under the inbound's
// originNodeGuid (falling back to node:{id}). Agent inbounds are defined
// centrally: a fresh one stores no origin GUID, and one re-homed from a
// panel node keeps the GUID the snapshot merge stamped for its PREVIOUS
// host — while the agent's onlines are filed under the agent node's own
// effective key. The slim list must therefore stamp agent-hosted inbounds
// with the hosting node's effective key at read time, or their clients
// never show as online on the inbounds page (production: every converted
// agent node was affected). Traditional inbounds' stored origins are
// chain attribution (#4983) and must pass through untouched.
func TestGetInboundsSlim_AnnotatesAgentOriginGuid(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
svc := InboundService{}
const agentNodeID = 8201
const tradNodeID = 8202
const userID = 1
if err := db.Create(&model.Node{Id: agentNodeID, Name: "agent-node", Address: "10.0.8.1"}).Error; err != nil {
t.Fatalf("create agent node: %v", err)
}
if err := db.Create(&model.Node{Id: tradNodeID, Name: "trad-node", Address: "10.0.8.2", Port: 2053, ApiToken: "t", Guid: "trad-guid-8202"}).Error; err != nil {
t.Fatalf("create trad node: %v", err)
}
// The agent binding is what makes the node "agent-managed".
agentID := agentNodeID
if err := db.Create(&model.AgentNode{
TokenHash: fmt.Sprintf("%064d", agentNodeID),
Name: "agent-node",
NodeID: &agentID,
}).Error; err != nil {
t.Fatalf("create agent binding: %v", err)
}
mkInbound := func(tag string, nodeID *int, origin string) {
ib := &model.Inbound{
UserId: userID,
Tag: tag,
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"a@example.com","enable":true}]}`,
NodeID: nodeID,
OriginNodeGuid: origin,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", tag, err)
}
}
agentIDCopy := agentNodeID
tradIDCopy := tradNodeID
mkInbound("agent-stale", &agentIDCopy, "guid-of-previous-panel-host")
mkInbound("agent-fresh", &agentIDCopy, "")
mkInbound("trad-synced", &tradIDCopy, "trad-guid-8202")
slim, err := svc.GetInboundsSlim(userID)
if err != nil {
t.Fatalf("GetInboundsSlim: %v", err)
}
byTag := make(map[string]*model.Inbound, len(slim))
for _, ib := range slim {
byTag[ib.Tag] = ib
}
wantKey := fmt.Sprintf("node:%d", agentNodeID)
if got := byTag["agent-stale"].OriginNodeGuid; got != wantKey {
t.Errorf("agent-stale originNodeGuid = %q, want %q (stale host GUID healed to the agent's key)", got, wantKey)
}
if got := byTag["agent-fresh"].OriginNodeGuid; got != wantKey {
t.Errorf("agent-fresh originNodeGuid = %q, want %q", got, wantKey)
}
if got := byTag["trad-synced"].OriginNodeGuid; got != "trad-guid-8202" {
t.Errorf("trad-synced originNodeGuid = %q, want stored GUID untouched", got)
}
// Read-time only: the stored value must not be rewritten.
var stored model.Inbound
if err := db.Where("tag = ?", "agent-stale").First(&stored).Error; err != nil {
t.Fatalf("reload agent-stale: %v", err)
}
if stored.OriginNodeGuid != "guid-of-previous-panel-host" {
t.Errorf("stored origin_node_guid = %q, want the stale value preserved (annotation is read-time only)", stored.OriginNodeGuid)
}
}
@@ -87,3 +87,89 @@ func TestGetInboundOptions_NodeAddress(t *testing.T) {
t.Fatalf("local inbound ShareAddr = %q, want vpn.example.com", local.ShareAddr)
}
}
// TestGetInboundOptions_AgentNodeAddress verifies that an agent-managed
// node's inbounds never inherit the node row's stored address: the
// create form writes the literal placeholder "agent" there, which is
// not a connectable host. The option must carry the agent's
// telemetry-reported address instead (IPv4 first, then IPv6; empty when
// the agent never reported one), so share/QR links and WireGuard
// endpoints rendered from these options reach the agent host.
func TestGetInboundOptions_AgentNodeAddress(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
mkNode := func(name string) *model.Node {
n := &model.Node{Name: name, Scheme: "https", Address: "agent", Port: 2053, Enable: true}
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %s: %v", name, err)
}
return n
}
mkInbound := func(tag string, node *model.Node) *model.Inbound {
ib := &model.Inbound{
UserId: 1,
Tag: tag,
Enable: true,
Listen: "0.0.0.0",
Port: 31324,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
NodeID: &node.Id,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", tag, err)
}
return ib
}
mkBinding := func(node *model.Node, ipv4, ipv6 string, lastSeen int64) {
ag := &model.AgentNode{
TokenHash: "hash-" + node.Name,
Name: node.Name,
NodeID: &node.Id,
IPv4: ipv4,
IPv6: ipv6,
LastSeen: lastSeen,
}
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create binding for %s: %v", node.Name, err)
}
}
dualNode := mkNode("agent-dual")
mkBinding(dualNode, "198.51.100.23", "2001:db8::23", 2000)
dualInbound := mkInbound("in-agent-dual", dualNode)
v6Node := mkNode("agent-v6")
mkBinding(v6Node, "", "2001:db8::99", 2000)
v6Inbound := mkInbound("in-agent-v6", v6Node)
silentNode := mkNode("agent-silent")
mkBinding(silentNode, "", "", 2000)
silentInbound := mkInbound("in-agent-silent", silentNode)
svc := &InboundService{}
options, err := svc.GetInboundOptions(1)
if err != nil {
t.Fatalf("GetInboundOptions: %v", err)
}
byID := make(map[int]InboundOption, len(options))
for _, o := range options {
byID[o.Id] = o
}
if got := byID[dualInbound.Id].NodeAddress; got != "198.51.100.23" {
t.Fatalf("dual-stack agent NodeAddress = %q, want telemetry IPv4 198.51.100.23 (never the placeholder)", got)
}
if got := byID[v6Inbound.Id].NodeAddress; got != "2001:db8::99" {
t.Fatalf("ipv6-only agent NodeAddress = %q, want telemetry IPv6 2001:db8::99", got)
}
if got := byID[silentInbound.Id].NodeAddress; got != "" {
t.Fatalf("silent agent NodeAddress = %q, want empty (never the placeholder)", got)
}
for id, o := range byID {
if o.NodeAddress == "agent" {
t.Fatalf("inbound %d NodeAddress is the placeholder %q", id, o.NodeAddress)
}
}
}
+47 -11
View File
@@ -173,6 +173,12 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
depletedByNode := make(map[int]int)
disabledByNode := make(map[int]int)
activeByNode := make(map[int]int)
// Email lists ride along with the counts, appended in the same pass and
// under the same dedupe, so a category's hover list always names exactly
// the clients its count tallied.
depletedEmailsByNode := make(map[int][]string)
disabledEmailsByNode := make(map[int][]string)
activeEmailsByNode := make(map[int][]string)
statuses, _ := s.nodeClientStatuses()
seen := make(map[int]map[int]struct{}, len(nodes))
for _, st := range statuses {
@@ -190,10 +196,19 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
switch {
case st.Depleted:
depletedByNode[st.NodeID]++
if st.Email != "" {
depletedEmailsByNode[st.NodeID] = append(depletedEmailsByNode[st.NodeID], st.Email)
}
case st.Disabled:
disabledByNode[st.NodeID]++
if st.Email != "" {
disabledEmailsByNode[st.NodeID] = append(disabledEmailsByNode[st.NodeID], st.Email)
}
default:
activeByNode[st.NodeID]++
if st.Email != "" {
activeEmailsByNode[st.NodeID] = append(activeEmailsByNode[st.NodeID], st.Email)
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
@@ -204,10 +219,15 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
n.DepletedCount = depletedByNode[n.Id]
n.DisabledCount = disabledByNode[n.Id]
n.ActiveCount = activeByNode[n.Id]
n.DepletedEmails = depletedEmailsByNode[n.Id]
n.DisabledEmails = disabledEmailsByNode[n.Id]
n.ActiveEmails = activeEmailsByNode[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, ambiguous)])
onlineSet := onlineByGuid[effectiveNodeGuid(n, ambiguous)]
n.OnlineCount = len(onlineSet)
n.OnlineEmails = sortedEmailSet(onlineSet)
}
return nodes, nil
@@ -219,6 +239,7 @@ type nodeClientStatus struct {
InboundID int
NodeID int
ClientID int
Email string
Depleted bool
Disabled bool
}
@@ -232,19 +253,20 @@ type nodeClientStatus struct {
// depleted (expired/exhausted) wins over disabled.
func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
type row struct {
InboundID int `gorm:"column:inbound_id"`
NodeID int `gorm:"column:node_id"`
ClientID int `gorm:"column:client_id"`
Enable bool `gorm:"column:enable"`
Total int64 `gorm:"column:total"`
Up int64 `gorm:"column:up"`
Down int64 `gorm:"column:down"`
ExpiryTime int64 `gorm:"column:expiry_time"`
InboundID int `gorm:"column:inbound_id"`
NodeID int `gorm:"column:node_id"`
ClientID int `gorm:"column:client_id"`
Email string `gorm:"column:email"`
Enable bool `gorm:"column:enable"`
Total int64 `gorm:"column:total"`
Up int64 `gorm:"column:up"`
Down int64 `gorm:"column:down"`
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var rows []row
if err := database.GetDB().Table("inbounds").
Select("inbounds.id AS inbound_id, inbounds.node_id AS node_id, clients.id AS client_id, " +
"clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
"clients.email AS email, clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Joins("LEFT JOIN client_traffics ct ON ct.email = clients.email").
@@ -255,7 +277,7 @@ func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
now := time.Now().UnixMilli()
out := make([]nodeClientStatus, 0, len(rows))
for _, r := range rows {
st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID}
st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID, Email: r.Email}
expired := r.ExpiryTime > 0 && r.ExpiryTime <= now
exhausted := r.Total > 0 && r.Up+r.Down >= r.Total
switch {
@@ -283,6 +305,20 @@ func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
return out
}
// sortedEmailSet flattens an email set into a deterministic list for the
// node views' hover popovers. Nil for an empty set.
func sortedEmailSet(set map[string]struct{}) []string {
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for email := range set {
out = append(out, email)
}
slices.Sort(out)
return out
}
// effectiveNodeGuid is a node's stable online/inbound attribution key: its
// reported panelGuid, or a master-local synthetic node-id fallback when the node
// has no GUID yet (old build) or its GUID is ambiguous. ambiguous comes from
@@ -0,0 +1,97 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// The nodes page category chips used to hover to a bare category title:
// the API carried only the counts, never WHO is in each category. GetAll
// must now emit the per-category email lists from the exact same bucketing
// pass as the counts (statuses by node id, online set by attribution GUID),
// and the tree view must carry them through to the wire contract.
func TestGetAll_ClientEmailListsMatchCounts(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
nodeSvc := NodeService{}
inboundSvc := InboundService{}
const nodeID = 8301
if err := db.Create(&model.Node{Id: nodeID, Name: "agent-node", Address: "10.0.8.3"}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
nid := nodeID
ib := &model.Inbound{Tag: "lists-in", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
mk := func(id int, email string, enable bool) {
if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: enable}).Error; err != nil {
t.Fatalf("create client %s: %v", email, err)
}
if !enable {
if err := db.Model(&model.ClientRecord{}).Where("id = ?", id).Update("enable", false).Error; err != nil {
t.Fatalf("disable client %s: %v", email, err)
}
}
if err := db.Create(&model.ClientInbound{ClientId: id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("attach client %s: %v", email, err)
}
}
mk(8301, "online@x", true)
mk(8302, "active@x", true)
mk(8303, "disabled@x", false)
mk(8304, "depleted@x", true)
if err := db.Create(&xray.ClientTraffic{InboundId: 999, Email: "depleted@x", Enable: true, Total: 100, Up: 60, Down: 60}).Error; err != nil {
t.Fatalf("create traffic: %v", err)
}
// The agent's online report — same entry the agent controller calls.
inboundSvc.SetAgentOnlineClients(nodeID, []string{"online@x"})
t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(nodeID) })
nodes, err := nodeSvc.GetAll()
if err != nil {
t.Fatalf("GetAll: %v", err)
}
var n *model.Node
for _, x := range nodes {
if x.Id == nodeID {
n = x
}
}
if n == nil {
t.Fatal("node not found")
}
if n.ClientCount != 4 || n.ActiveCount != 2 || n.DisabledCount != 1 || n.DepletedCount != 1 || n.OnlineCount != 1 {
t.Fatalf("counts = total %d active %d disabled %d depleted %d online %d, want 4/2/1/1/1",
n.ClientCount, n.ActiveCount, n.DisabledCount, n.DepletedCount, n.OnlineCount)
}
assertStringSet(t, "active emails", n.ActiveEmails, []string{"online@x", "active@x"})
assertStringSet(t, "disabled emails", n.DisabledEmails, []string{"disabled@x"})
assertStringSet(t, "depleted emails", n.DepletedEmails, []string{"depleted@x"})
assertStringSet(t, "online emails", n.OnlineEmails, []string{"online@x"})
// The wire contract (nodes page) must carry the same lists.
views, err := nodeSvc.GetNodeTreeView()
if err != nil {
t.Fatalf("GetNodeTreeView: %v", err)
}
var v *NodeView
for _, x := range views {
if x.Id == nodeID {
v = x
}
}
if v == nil {
t.Fatal("node view not found")
}
assertStringSet(t, "view online emails", v.OnlineEmails, []string{"online@x"})
assertStringSet(t, "view active emails", v.ActiveEmails, []string{"online@x", "active@x"})
assertStringSet(t, "view disabled emails", v.DisabledEmails, []string{"disabled@x"})
assertStringSet(t, "view depleted emails", v.DepletedEmails, []string{"depleted@x"})
}
+30 -10
View File
@@ -33,10 +33,11 @@ type NodeView struct {
// panel UI annotate it via annotateNodeTypes. Empty on views built
// outside those paths.
NodeType string `json:"nodeType,omitempty" example:"traditional"`
// Ipv4/Ipv6 carry the agent-reported host addresses for agent nodes
// (P13). The nodes page renders them as plain text on agent rows
// instead of the panel-URL link used for traditional nodes. Empty for
// traditional nodes and for agents that never reported telemetry.
// Ipv4/Ipv6 carry the resolved externally usable host addresses for
// agent nodes (P13 telemetry, public-address resolution on top).
// The nodes page renders them as plain text on agent rows instead
// of the panel-URL link used for traditional nodes. Empty for
// traditional nodes and for agents with no known address yet.
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
// Agent one-click update bookkeeping (item 20), agent nodes only:
@@ -66,6 +67,13 @@ type NodeView struct {
ActiveCount int `json:"activeCount" example:"20"`
DisabledCount int `json:"disabledCount" example:"2"`
DepletedCount int `json:"depletedCount" example:"1"`
// Per-category client email lists behind the counts above, from the
// same attribution bucketing, so the nodes page category chips can
// pop over the actual usernames. Empty when the category is empty.
OnlineEmails []string `json:"onlineEmails,omitempty" example:"[\"a@example.com\"]"`
ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"`
DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"`
DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"`
ParentGuid string `json:"parentGuid,omitempty" example:""`
Transitive bool `json:"transitive,omitempty" example:"false"`
CreatedAt int64 `json:"createdAt" example:"1700000000"`
@@ -114,6 +122,10 @@ func toNodeView(n *model.Node) *NodeView {
ActiveCount: n.ActiveCount,
DisabledCount: n.DisabledCount,
DepletedCount: n.DepletedCount,
OnlineEmails: n.OnlineEmails,
ActiveEmails: n.ActiveEmails,
DisabledEmails: n.DisabledEmails,
DepletedEmails: n.DepletedEmails,
ParentGuid: n.ParentGuid,
Transitive: n.Transitive,
CreatedAt: n.CreatedAt,
@@ -171,9 +183,12 @@ func annotateNodeTypes(views []*NodeView) {
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
// by each node's bound agent onto its view (P13): xray version, the
// "agent <version>" panel-version label, host uptime, node->master latency,
// CPU/memory percentages and the host IPv4/IPv6 pair. Views for traditional
// nodes (and agent nodes whose agent never reported telemetry) are left
// exactly as built. Must run after annotateNodeTypes.
// CPU/memory percentages and the host IPv4/IPv6 pair. The address pair is
// the resolved externally usable one (ResolveAgentAddresses), not the raw
// telemetry — a NAT'd agent only ever reports its private interface
// address. Views for traditional nodes (and agent nodes whose agent never
// reported telemetry) are left exactly as built. Must run after
// annotateNodeTypes.
func annotateAgentTelemetry(views []*NodeView) {
if len(views) == 0 {
return
@@ -198,7 +213,14 @@ func annotateAgentTelemetry(views []*NodeView) {
continue
}
ag, ok := byNode[v.Id]
if !ok || ag.TelemetryAt == 0 {
if !ok {
continue
}
// Address resolution applies to every bound agent, even before
// its first telemetry report: an agent that has only registered
// already has a master-observed source address.
v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag)
if ag.TelemetryAt == 0 {
continue
}
if ag.XrayVersion != "" {
@@ -221,8 +243,6 @@ func annotateAgentTelemetry(views []*NodeView) {
v.LatencyMs = ag.LatencyMs
v.CpuPct = ag.CpuPct
v.MemPct = ag.MemPct
v.Ipv4 = ag.IPv4
v.Ipv6 = ag.IPv6
}
}
+19 -1
View File
@@ -209,10 +209,15 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
// Classify by EMAIL (not the stale client_traffics.inbound_id) and bucket
// each client under its inbound's effective attribution GUID, deduping a
// client attached to several inbounds under the same GUID.
// client attached to several inbounds under the same GUID. The email
// lists are appended in the same pass so they stay consistent with the
// recounted tallies they annotate.
depletedByGuid := make(map[string]int)
disabledByGuid := make(map[string]int)
activeByGuid := make(map[string]int)
depletedEmailsByGuid := make(map[string][]string)
disabledEmailsByGuid := make(map[string][]string)
activeEmailsByGuid := make(map[string][]string)
if statuses, err := s.nodeClientStatuses(); err == nil {
seen := make(map[string]map[int]struct{})
for _, st := range statuses {
@@ -232,10 +237,19 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
switch {
case st.Depleted:
depletedByGuid[guid]++
if st.Email != "" {
depletedEmailsByGuid[guid] = append(depletedEmailsByGuid[guid], st.Email)
}
case st.Disabled:
disabledByGuid[guid]++
if st.Email != "" {
disabledEmailsByGuid[guid] = append(disabledEmailsByGuid[guid], st.Email)
}
default:
activeByGuid[guid]++
if st.Email != "" {
activeEmailsByGuid[guid] = append(activeEmailsByGuid[guid], st.Email)
}
}
}
}
@@ -245,8 +259,12 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
guid := effectiveNodeGuid(n, ambiguous)
n.InboundCount = inboundCountByGuid[guid]
n.OnlineCount = len(onlineByGuid[guid])
n.OnlineEmails = sortedEmailSet(onlineByGuid[guid])
n.DepletedCount = depletedByGuid[guid]
n.DisabledCount = disabledByGuid[guid]
n.ActiveCount = activeByGuid[guid]
n.DepletedEmails = depletedEmailsByGuid[guid]
n.DisabledEmails = disabledEmailsByGuid[guid]
n.ActiveEmails = activeEmailsByGuid[guid]
}
}