Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5dab0229c | ||
|
|
0e9c2031fb |
@@ -1223,6 +1223,31 @@ export interface ShareHostFields {
|
|||||||
shareAddrStrategy?: string;
|
shareAddrStrategy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The subset of a node record the share-host pick reads.
|
||||||
|
export interface NodeHostFields {
|
||||||
|
address?: string;
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
nodeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareHostForNode picks the hostOverride for share/QR/export links from a
|
||||||
|
// node record. Agent-managed nodes carry the literal placeholder "agent" in
|
||||||
|
// address — the create form's sentinel, not a connectable host — while the
|
||||||
|
// backend-resolved externally usable address rides in ipv4/ipv6
|
||||||
|
// (annotateAgentTelemetry), which is what every other surface renders.
|
||||||
|
// Resolved addresses therefore win; the raw address is only a fallback and
|
||||||
|
// only when it is not the placeholder, so the placeholder can never leak
|
||||||
|
// into a link. With no resolved address the pick is empty and
|
||||||
|
// resolveShareHost's fallback chain (listen → panel host) continues.
|
||||||
|
export function shareHostForNode(node: NodeHostFields | undefined | null): string {
|
||||||
|
if (!node) return '';
|
||||||
|
const resolved = (node.ipv4 ?? '').trim() || (node.ipv6 ?? '').trim();
|
||||||
|
if (resolved) return resolved;
|
||||||
|
const raw = (node.address ?? '').trim();
|
||||||
|
return raw.toLowerCase() === 'agent' ? '' : raw;
|
||||||
|
}
|
||||||
|
|
||||||
// resolveShareHost picks the host that goes into share/QR links, the browser-side
|
// resolveShareHost picks the host that goes into share/QR links, the browser-side
|
||||||
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
|
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
|
||||||
// address (empty for this panel's own inbounds); fallbackHostname is the
|
// address (empty for this panel's own inbounds); fallbackHostname is the
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
genInboundLinks,
|
genInboundLinks,
|
||||||
genWireguardLinks,
|
genWireguardLinks,
|
||||||
preferPublicHost,
|
preferPublicHost,
|
||||||
|
shareHostForNode,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
import { Protocols } from '@/schemas/primitives';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
@@ -187,7 +188,10 @@ export default function InboundsPage() {
|
|||||||
const hostOverrideFor = useCallback(
|
const hostOverrideFor = useCallback(
|
||||||
(dbInbound: DBInbound | null) => {
|
(dbInbound: DBInbound | null) => {
|
||||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||||
return nodesById.get(dbInbound.nodeId)?.address || '';
|
// Agent nodes store the literal placeholder "agent" in address; the
|
||||||
|
// resolved host lives in the node's ipv4/ipv6 (shareHostForNode).
|
||||||
|
// Reading .address raw put "@agent:<port>" into exported links.
|
||||||
|
return shareHostForNode(nodesById.get(dbInbound.nodeId));
|
||||||
},
|
},
|
||||||
[nodesById],
|
[nodesById],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
genWireguardLink,
|
genWireguardLink,
|
||||||
preferPublicHost,
|
preferPublicHost,
|
||||||
resolveAddr,
|
resolveAddr,
|
||||||
|
shareHostForNode,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
import { InboundSchema } from '@/schemas/api/inbound';
|
import { InboundSchema } from '@/schemas/api/inbound';
|
||||||
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||||
@@ -683,6 +684,65 @@ describe('resolveAddr precedence', () => {
|
|||||||
|
|
||||||
// #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
|
// #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
|
||||||
// leak the loopback host into share/QR links; a configured public host wins.
|
// leak the loopback host into share/QR links; a configured public host wins.
|
||||||
|
describe('shareHostForNode (agent placeholder guard)', () => {
|
||||||
|
it('prefers the resolved ipv4 of an agent node over the "agent" placeholder address', () => {
|
||||||
|
expect(
|
||||||
|
shareHostForNode({
|
||||||
|
nodeType: 'agent',
|
||||||
|
address: 'agent',
|
||||||
|
ipv4: '203.0.113.7',
|
||||||
|
ipv6: '2001:db8::7',
|
||||||
|
}),
|
||||||
|
).toBe('203.0.113.7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the resolved ipv6 when ipv4 is empty', () => {
|
||||||
|
expect(shareHostForNode({ nodeType: 'agent', address: 'agent', ipv6: '2001:db8::7' })).toBe(
|
||||||
|
'2001:db8::7',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never surfaces the placeholder: an agent node without telemetry yields an empty override', () => {
|
||||||
|
expect(shareHostForNode({ nodeType: 'agent', address: 'agent' })).toBe('');
|
||||||
|
expect(shareHostForNode({ nodeType: 'agent', address: ' Agent ' })).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the fallback chain continues past an empty agent override (no "agent" host)', () => {
|
||||||
|
const host = resolveAddr(
|
||||||
|
{ listen: '0.0.0.0', port: 46994, protocol: 'vless' } as never,
|
||||||
|
shareHostForNode({ nodeType: 'agent', address: 'agent' }),
|
||||||
|
'panel.example.com',
|
||||||
|
);
|
||||||
|
expect(host).toBe('panel.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traditional nodes keep their raw address verbatim', () => {
|
||||||
|
expect(shareHostForNode({ nodeType: 'traditional', address: '198.51.100.2' })).toBe(
|
||||||
|
'198.51.100.2',
|
||||||
|
);
|
||||||
|
expect(shareHostForNode({ address: 'node.example.com' })).toBe('node.example.com');
|
||||||
|
expect(shareHostForNode(undefined)).toBe('');
|
||||||
|
expect(shareHostForNode({})).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an exported agent-inbound link block carries the resolved ipv4, never "@agent:"', () => {
|
||||||
|
const [, raw] = fixturesForProtocol('vless')[0];
|
||||||
|
const typed = InboundSchema.parse(raw);
|
||||||
|
const block = genInboundLinks({
|
||||||
|
inbound: typed,
|
||||||
|
remark: 'agent-export',
|
||||||
|
hostOverride: shareHostForNode({
|
||||||
|
nodeType: 'agent',
|
||||||
|
address: 'agent',
|
||||||
|
ipv4: '203.0.113.7',
|
||||||
|
}),
|
||||||
|
fallbackHostname: 'fallback.test',
|
||||||
|
});
|
||||||
|
expect(block).toContain('@203.0.113.7:');
|
||||||
|
expect(block).not.toContain('@agent:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('preferPublicHost (loopback fallback)', () => {
|
describe('preferPublicHost (loopback fallback)', () => {
|
||||||
it('keeps a routable browser host as-is even when a public host is configured', () => {
|
it('keeps a routable browser host as-is even when a public host is configured', () => {
|
||||||
expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
|
expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
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/web/runtime"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// REPRO (V1.0.2 fix 2): agent nodes are push-only — their online clients
|
||||||
|
// arrive via agent traffic reports (InboundService.SetAgentOnlineClients)
|
||||||
|
// and are keyed exactly where the node list looks them up (the service-level
|
||||||
|
// alignment is pinned by TestReproAgentNodeOnlineEmailsInNodeList). But the
|
||||||
|
// traffic sync job's pull loop treats every enabled+online node as a panel:
|
||||||
|
// for an agent node the snapshot fetch can never succeed (address is the
|
||||||
|
// literal placeholder "agent", there is no panel API behind it), and the
|
||||||
|
// fetch-failure branch calls ClearNodeOnlineClients — wiping the agent's
|
||||||
|
// pushed online state on every tick. The heartbeat job already skips
|
||||||
|
// agent-managed nodes for probing; the sync job must not pull them either.
|
||||||
|
func TestSyncOne_AgentNodeKeepsReportedOnlines(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
node := &model.Node{
|
||||||
|
Name: "agent-sync-node",
|
||||||
|
Address: "agent",
|
||||||
|
Port: 2053,
|
||||||
|
Enable: true,
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
if err := db.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.AgentNode{
|
||||||
|
TokenHash: "hash-agent-sync-node",
|
||||||
|
Name: "agent-sync-node",
|
||||||
|
NodeID: &node.Id,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create agent binding: %v", err)
|
||||||
|
}
|
||||||
|
// Production form: the agent node actually hosts inbounds (without any
|
||||||
|
// node-bound inbound in the DB, NodeService.GetAll takes its early
|
||||||
|
// return and never fills per-node stats at all).
|
||||||
|
if err := db.Create(&model.Inbound{
|
||||||
|
Tag: "agent-sync-in-1", Enable: true, Port: 46994, Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[]}`, NodeID: &node.Id,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inboundSvc := service.InboundService{}
|
||||||
|
inboundSvc.ClearNodeOnlineClients(node.Id)
|
||||||
|
t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(node.Id) })
|
||||||
|
|
||||||
|
inboundSvc.SetAgentOnlineClients(node.Id, []string{"agent-online@example.com"})
|
||||||
|
if got := inboundSvc.GetOnlineClients(); len(got) != 1 {
|
||||||
|
t.Fatalf("precondition: online union = %v, want the agent-reported email", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := NewNodeTrafficSyncJob()
|
||||||
|
j.syncOne(runtime.NewManager(), node, false)
|
||||||
|
|
||||||
|
byGuid := inboundSvc.GetOnlineClientsByGuid()
|
||||||
|
found := false
|
||||||
|
for _, emails := range byGuid {
|
||||||
|
for _, e := range emails {
|
||||||
|
if e == "agent-online@example.com" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("agent onlines wiped by sync tick: byGuid = %v", byGuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
views, err := (&service.NodeService{}).GetNodeTreeView()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetNodeTreeView: %v", err)
|
||||||
|
}
|
||||||
|
for _, v := range views {
|
||||||
|
if v.Id == node.Id {
|
||||||
|
if len(v.OnlineEmails) != 1 || v.OnlineEmails[0] != "agent-online@example.com" {
|
||||||
|
t.Fatalf("node view online emails = %v, want [agent-online@example.com]", v.OnlineEmails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -349,6 +349,19 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
|
|||||||
// emails online on that node this tick, feeding the delta broadcast above the
|
// emails online on that node this tick, feeding the delta broadcast above the
|
||||||
// snapshot threshold; nil on any failure path.
|
// snapshot threshold; nil on any failure path.
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
|
||||||
|
// Agent-managed nodes are push-only: their traffic and online state
|
||||||
|
// arrive via agent reports (SetAgentOnlineClients), and there is no
|
||||||
|
// panel API to pull a snapshot from — the node address is the literal
|
||||||
|
// "agent" placeholder. Pulling one always fails, and the failure
|
||||||
|
// branch below clears the node's whole online subtree, wiping the
|
||||||
|
// state the agent just pushed on every 5s tick (the node list then
|
||||||
|
// shows agent nodes permanently dark while panel nodes, whose fetch
|
||||||
|
// succeeds and re-files a fresh tree in the same tick, are fine).
|
||||||
|
// The heartbeat job already treats agent nodes the same way: no
|
||||||
|
// probing, agent-driven state only.
|
||||||
|
if j.nodeService.IsAgentManaged(n.Id) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
rt, err := mgr.RemoteFor(n)
|
rt, err := mgr.RemoteFor(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
|
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An agent node's reported online clients must surface in the node list
|
||||||
|
// aggregation (NodeView.OnlineEmails), like they do on the inbounds page.
|
||||||
|
// Production shape: the agent node row carries the create form's placeholder
|
||||||
|
// address and no panel GUID of its own; an agent_nodes binding row ties the
|
||||||
|
// agent token to the node; the node hosts inbounds.
|
||||||
|
//
|
||||||
|
// This pins the write/read key alignment: SetAgentOnlineClients files the
|
||||||
|
// list under effectiveNodeKey of the guid-only node row, and the node list
|
||||||
|
// looks it up under effectiveNodeGuid of the full row — for a guid-less
|
||||||
|
// agent node both are the synthetic "node:{id}" key. (The node page going
|
||||||
|
// dark in production was NOT a key mismatch: the traffic sync job kept
|
||||||
|
// deleting the filed tree — see the job package's
|
||||||
|
// TestSyncOne_AgentNodeKeepsReportedOnlines.)
|
||||||
|
func TestAgentNodeOnlineEmailsInNodeList(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
const nodeID = 7601
|
||||||
|
ibSvc := InboundService{}
|
||||||
|
ibSvc.ClearNodeOnlineClients(nodeID)
|
||||||
|
t.Cleanup(func() { ibSvc.ClearNodeOnlineClients(nodeID) })
|
||||||
|
|
||||||
|
if err := db.Create(&model.Node{
|
||||||
|
Id: nodeID, Name: "agent-node-7601", Address: "agent", Enable: true, Status: "online",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
nid := nodeID
|
||||||
|
if err := db.Create(&model.AgentNode{
|
||||||
|
TokenHash: "hash-7601", NodeID: &nid,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create agent binding: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.Inbound{
|
||||||
|
Tag: "agent-in-1", Enable: true, Port: 46994, Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[]}`, NodeID: &nid,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ibSvc.SetAgentOnlineClients(nodeID, []string{"online@example.com"})
|
||||||
|
|
||||||
|
var row model.Node
|
||||||
|
if err := db.Select("guid").Where("id = ?", nodeID).First(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load node guid: %v", err)
|
||||||
|
}
|
||||||
|
row.Id = nodeID
|
||||||
|
if writeKey := effectiveNodeKey(&row); writeKey != "node:7601" {
|
||||||
|
t.Fatalf("write key = %q, want node:7601", writeKey)
|
||||||
|
}
|
||||||
|
assertStringSet(t, "byGuid[node:7601]", ibSvc.GetOnlineClientsByGuid()["node:7601"],
|
||||||
|
[]string{"online@example.com"})
|
||||||
|
|
||||||
|
views, err := (&NodeService{}).GetNodeTreeView()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetNodeTreeView: %v", err)
|
||||||
|
}
|
||||||
|
for _, v := range views {
|
||||||
|
if v.Id == nodeID {
|
||||||
|
if v.OnlineCount != 1 {
|
||||||
|
t.Fatalf("agent node OnlineCount = %d, want 1", v.OnlineCount)
|
||||||
|
}
|
||||||
|
assertStringSet(t, "agent node OnlineEmails", v.OnlineEmails,
|
||||||
|
[]string{"online@example.com"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("agent node %d missing from node list views", nodeID)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user