Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e89960474 | ||
|
|
afb5456b8f | ||
|
|
36acc4c2b1 | ||
|
|
7f098c6c4e | ||
|
|
942b220f80 | ||
|
|
7d9a38490d | ||
|
|
3df652a67d | ||
|
|
c91fb454a8 | ||
|
|
c490bdcd76 | ||
|
|
5c432205ad | ||
|
|
8901267867 |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
<CategoryChip
|
||||
title={t('subscription.active')}
|
||||
emails={record.activeEmails}
|
||||
color="green"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.activeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
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
|
||||
<CategoryChip
|
||||
title={t('depleted')}
|
||||
emails={record.depletedEmails}
|
||||
color="red"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.depletedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
count={record.depletedCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.onlineCount ? (
|
||||
<Tooltip title={t('online')}>
|
||||
<Tag
|
||||
<CategoryChip
|
||||
title={t('online')}
|
||||
emails={record.onlineEmails}
|
||||
color="blue"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.onlineCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
count={record.onlineCount}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -235,6 +256,7 @@ func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
|
||||
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"`
|
||||
@@ -244,7 +266,7 @@ func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
|
||||
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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user