Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7244f3c9da | ||
|
|
f9183e9dbb | ||
|
|
f99c43dd2a | ||
|
|
ef89259a49 | ||
|
|
4c04cdb1bc | ||
|
|
bdfcce0e11 | ||
|
|
7ba1841df2 | ||
|
|
3a3c7c0dc9 | ||
|
|
388e26c292 | ||
|
|
1d1258a10d | ||
|
|
81299db6ff | ||
|
|
89b65f1606 | ||
|
|
d16b888146 | ||
|
|
c94136c8a1 | ||
|
|
e7f4bfed24 | ||
|
|
6b9832cdcc | ||
|
|
e78cb8ccc5 | ||
|
|
ef662c9fdb | ||
|
|
a81eff8c0d | ||
|
|
1d7eb16784 | ||
|
|
a13179ed9b | ||
|
|
93c1620c15 | ||
|
|
ba57fb3c00 | ||
|
|
151b07b73f | ||
|
|
cec5905483 | ||
|
|
1df78dd874 | ||
|
|
92dfdc542c |
@@ -0,0 +1,113 @@
|
||||
// Command xui-agent is the standalone Linux build of the 3x-ui node
|
||||
// agent (agent/panel split, phase 1). Until the split, nodes ran the
|
||||
// panel binary under the "agent" subcommand, shipping the whole web
|
||||
// panel and the embedded frontend to every node. This entry links only
|
||||
// the agent's own dependency closure (internal/agent plus config,
|
||||
// logger, selfupdate, xray-api types and two util leaves), so the node
|
||||
// artifact is a fraction of the panel binary while remaining a drop-in
|
||||
// replacement for it:
|
||||
//
|
||||
// - invocation is unchanged: service units run
|
||||
// "xui-agent agent [-config /etc/x-ui/agent.json]" (the bare
|
||||
// "-config" form is also accepted for manual runs);
|
||||
// - agent.json, the traffic baseline and the other state files keep
|
||||
// their names, locations and formats;
|
||||
// - the wire protocol with the master (register / heartbeat /
|
||||
// config / traffic / probe-report / update intent) is byte-for-
|
||||
// byte the internal/agent implementation the dual-mode binary ran.
|
||||
//
|
||||
// The agent lifecycle wiring below mirrors main.go's agent branch
|
||||
// exactly — the same internal/agent package does the work either way,
|
||||
// and cmd/xui-agent-windows is the Windows sibling of this entry.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/agent"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
// Version self-report: the agent's own display version (A-form),
|
||||
// from the same build-time displayVersion injection the panel
|
||||
// uses. Kept ahead of the subcommand so it works however the
|
||||
// binary was invoked.
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "-v", "--version", "version":
|
||||
fmt.Println(config.GetAgentDisplayVersion())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Service units (systemd / OpenRC / procd / nohup fallback in
|
||||
// install-agent.sh) invoke the binary with the historical "agent"
|
||||
// subcommand; consume it so those units keep working unchanged.
|
||||
if len(args) > 0 && args[0] == "agent" {
|
||||
args = args[1:]
|
||||
} else if len(args) > 0 && args[0][0] != '-' {
|
||||
fmt.Fprintf(os.Stderr, "xui-agent: unknown subcommand %q (this binary only runs the node agent)\n", args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
runAgent(args)
|
||||
}
|
||||
|
||||
// runAgent is main.go's agent branch, verbatim in behaviour: same
|
||||
// flags, same defaults, same shutdown wiring.
|
||||
func runAgent(args []string) {
|
||||
agentCmd := flag.NewFlagSet("agent", flag.ExitOnError)
|
||||
var configPath string
|
||||
agentCmd.StringVar(&configPath, "config", "/etc/x-ui/agent.json", "Path to agent config file")
|
||||
agentCmd.Parse(args)
|
||||
|
||||
// Agent mode always uses the panel's directory layout for the
|
||||
// xray binary, geo files and config.json. Scoped to agent mode
|
||||
// only so the panel's own relative "bin" default is untouched.
|
||||
if os.Getenv("XUI_BIN_FOLDER") == "" {
|
||||
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
|
||||
}
|
||||
|
||||
cfg, err := agent.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load agent config from %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
// The acknowledged traffic baseline persists next to the agent
|
||||
// config (its state home, e.g. /etc/x-ui), unless the config
|
||||
// names a path.
|
||||
if cfg.BaselinePath == "" {
|
||||
cfg.BaselinePath = filepath.Join(filepath.Dir(configPath), "agent-traffic-baseline.json")
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
logger.InitLogger(logging.INFO)
|
||||
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
|
||||
|
||||
ag := agent.New(cfg)
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("Shutting down agent...")
|
||||
ag.Stop()
|
||||
}()
|
||||
|
||||
if err := ag.Run(); err != nil {
|
||||
log.Fatalf("Agent error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,6 @@ export interface NodeUpdateResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RemoteInboundOption {
|
||||
tag: string;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function useNodeMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
@@ -91,13 +84,5 @@ export function useNodeMutations() {
|
||||
probe: (id: number) => probeMut.mutateAsync(id),
|
||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
|
||||
updatePanelsMut.mutateAsync({ ids, dev }),
|
||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
},
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
|
||||
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
|
||||
fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
|
||||
HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"expireDiff": 0,
|
||||
"pageSize": 0,
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
@@ -55,7 +54,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"hasWarpSecret": false,
|
||||
"pageSize": 0,
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
@@ -488,23 +486,14 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"NodeMutationRequest": {
|
||||
"address": "",
|
||||
"allowPrivateAddress": false,
|
||||
"apiToken": null,
|
||||
"basePath": "",
|
||||
"clearApiToken": false,
|
||||
"enable": false,
|
||||
"id": 0,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
""
|
||||
],
|
||||
"name": "",
|
||||
"nodeType": "traditional",
|
||||
"outboundTag": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 0,
|
||||
"probeOnly": null,
|
||||
"remark": "",
|
||||
"scheme": "http",
|
||||
"tlsVerifyMode": "verify"
|
||||
"scheme": "http"
|
||||
},
|
||||
"NodeView": {
|
||||
"activeCount": 20,
|
||||
@@ -532,13 +521,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
],
|
||||
"enable": true,
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"ipv4": "203.0.113.7",
|
||||
"ipv6": "2001:db8::7",
|
||||
"lastError": "",
|
||||
@@ -548,21 +532,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"nodeType": "traditional",
|
||||
"nodeType": "agent",
|
||||
"onlineCount": 5,
|
||||
"onlineEmails": [
|
||||
"a@example.com"
|
||||
],
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"probeOnly": false,
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"singboxVersion": "1.13.21",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
|
||||
@@ -17,9 +17,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sessionMaxAge": {
|
||||
"maximum": 525600,
|
||||
"minimum": 1,
|
||||
@@ -160,7 +157,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"expireDiff",
|
||||
"pageSize",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
@@ -232,9 +228,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sessionMaxAge": {
|
||||
"maximum": 525600,
|
||||
"minimum": 1,
|
||||
@@ -379,7 +372,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"hasWarpSecret",
|
||||
"pageSize",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
@@ -2004,7 +1996,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "object"
|
||||
},
|
||||
"NodeMutationRequest": {
|
||||
"description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
|
||||
"description": "NodeMutationRequest is the node write contract. Every node is\nagent-managed: it dials out to the master and authenticates with the\nagent token issued on creation, so the request carries no panel\nendpoint credentials.",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
@@ -2012,57 +2004,28 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"clearApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"enum": [
|
||||
"all",
|
||||
"selected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
|
||||
"enum": [
|
||||
"traditional",
|
||||
"agent"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"probeOnly": {
|
||||
"description": "ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A\npointer so updates can tell \"leave the flag alone\" (nil) from an\nexplicit on/off; applied to the bound agent rows. On create it\npresets the flag on the freshly issued agent.",
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2072,15 +2035,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"enum": [
|
||||
"verify",
|
||||
"skip",
|
||||
"pin",
|
||||
"mtls"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2089,20 +2043,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"basePath",
|
||||
"enable",
|
||||
"id",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"name",
|
||||
"outboundTag",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"tlsVerifyMode"
|
||||
"scheme"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeView": {
|
||||
"description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
|
||||
"description": "NodeView is the browser/API read contract for nodes.",
|
||||
"properties": {
|
||||
"activeCount": {
|
||||
"example": 20,
|
||||
@@ -2197,10 +2146,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "node-guid",
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
@@ -2209,19 +2154,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 3,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"example": "all",
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"example": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ipv4": {
|
||||
"description": "Ipv4/Ipv6 carry the resolved externally usable host addresses for\nagent nodes (P13 telemetry, public-address resolution on top).\nThe nodes page renders them as plain text on agent rows instead\nof the panel-URL link used for traditional nodes. Empty for\ntraditional nodes and for agents with no known address yet.",
|
||||
"example": "203.0.113.7",
|
||||
@@ -2262,8 +2194,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
|
||||
"example": "traditional",
|
||||
"description": "NodeType is \"agent\" for agent-managed nodes (an agent is bound to\nthe node) and \"traditional\" for a legacy row with no binding. It\nis derived from the agent binding, not stored on the node row;\nread paths that serve the panel UI annotate it via\nannotateNodeTypes. Empty on views built outside those paths.",
|
||||
"example": "agent",
|
||||
"type": "string"
|
||||
},
|
||||
"onlineCount": {
|
||||
@@ -2280,24 +2212,19 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"outboundTag": {
|
||||
"example": "direct",
|
||||
"type": "string"
|
||||
},
|
||||
"panelVersion": {
|
||||
"example": "v3.x.x",
|
||||
"type": "string"
|
||||
},
|
||||
"parentGuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 2053,
|
||||
"type": "integer"
|
||||
},
|
||||
"probeOnly": {
|
||||
"description": "ProbeOnly marks an agent node as probe-only (V2.1.0): no core\nruns on it, only the probe channels. Overlaid from the bound\nagent row like the update bookkeeping below; false (and absent)\nfor traditional nodes and unflagged agents.",
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"remark": {
|
||||
"example": "Primary edge",
|
||||
"type": "string"
|
||||
@@ -2306,18 +2233,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "https",
|
||||
"type": "string"
|
||||
},
|
||||
"singboxVersion": {
|
||||
"description": "SingboxVersion is the bound agent's sing-box core version\n(V2.0.1 ②), overlaid from agent telemetry like XrayVersion;\nempty for traditional nodes and agents that never reported\none. The nodes page prefers it over XrayVersion when set.",
|
||||
"example": "1.13.21",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"example": "verify",
|
||||
"type": "string"
|
||||
},
|
||||
"transitive": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
@@ -2354,11 +2278,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"disabledCount",
|
||||
"enable",
|
||||
"guid",
|
||||
"hasApiToken",
|
||||
"id",
|
||||
"inboundCount",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"lastError",
|
||||
"lastHeartbeat",
|
||||
"latencyMs",
|
||||
@@ -2367,14 +2288,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"netDown",
|
||||
"netUp",
|
||||
"onlineCount",
|
||||
"outboundTag",
|
||||
"panelVersion",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"singboxVersion",
|
||||
"status",
|
||||
"tlsVerifyMode",
|
||||
"updatedAt",
|
||||
"uptimeSecs",
|
||||
"xrayError",
|
||||
|
||||
@@ -4,8 +4,8 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type geoipFetchResult = number;
|
||||
export type rawBytes = number[];
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface AllSetting {
|
||||
expireDiff: number;
|
||||
pageSize: number;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
@@ -65,7 +64,6 @@ export interface AllSettingView {
|
||||
hasWarpSecret: boolean;
|
||||
pageSize: number;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
@@ -457,21 +455,14 @@ export interface Node {
|
||||
export interface NodeMutationRequest {
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
apiToken?: string | null;
|
||||
basePath: string;
|
||||
clearApiToken?: boolean;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
name: string;
|
||||
nodeType?: string;
|
||||
outboundTag: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
probeOnly?: boolean | null;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
tlsVerifyMode: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
@@ -494,11 +485,8 @@ export interface NodeView {
|
||||
disabledEmails?: string[];
|
||||
enable: boolean;
|
||||
guid: string;
|
||||
hasApiToken: boolean;
|
||||
id: number;
|
||||
inboundCount: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
lastError: string;
|
||||
@@ -511,16 +499,13 @@ export interface NodeView {
|
||||
nodeType?: string;
|
||||
onlineCount: number;
|
||||
onlineEmails?: string[];
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
parentGuid?: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
probeOnly?: boolean;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
singboxVersion: string;
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
transitive?: boolean;
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
|
||||
@@ -15,12 +15,12 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const geoipFetchResultSchema = z.number().int();
|
||||
export type geoipFetchResult = z.infer<typeof geoipFetchResultSchema>;
|
||||
|
||||
export const rawBytesSchema = z.array(z.number().int());
|
||||
export type rawBytes = z.infer<typeof rawBytesSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
export const trafficLocalApplyActionSchema = z.number().int();
|
||||
export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
|
||||
|
||||
@@ -32,7 +32,6 @@ export const AllSettingSchema = z.object({
|
||||
expireDiff: z.number().int().min(0),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
@@ -84,7 +83,6 @@ export const AllSettingViewSchema = z.object({
|
||||
hasWarpSecret: z.boolean(),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
@@ -499,21 +497,14 @@ export type Node = z.infer<typeof NodeSchema>;
|
||||
export const NodeMutationRequestSchema = z.object({
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
apiToken: z.string().nullable().optional(),
|
||||
basePath: z.string(),
|
||||
clearApiToken: z.boolean().optional(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundSyncMode: z.enum(['all', 'selected']),
|
||||
inboundTags: z.array(z.string()),
|
||||
name: z.string(),
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
outboundTag: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
probeOnly: z.boolean().nullable().optional(),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
});
|
||||
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
||||
|
||||
@@ -537,11 +528,8 @@ export const NodeViewSchema = z.object({
|
||||
disabledEmails: z.array(z.string()).optional(),
|
||||
enable: z.boolean(),
|
||||
guid: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundCount: z.number().int(),
|
||||
inboundSyncMode: z.string(),
|
||||
inboundTags: z.array(z.string()),
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
lastError: z.string(),
|
||||
@@ -554,16 +542,13 @@ export const NodeViewSchema = z.object({
|
||||
nodeType: z.string().optional(),
|
||||
onlineCount: z.number().int(),
|
||||
onlineEmails: z.array(z.string()).optional(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
parentGuid: z.string().optional(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int(),
|
||||
probeOnly: z.boolean().optional(),
|
||||
remark: z.string(),
|
||||
scheme: z.string(),
|
||||
singboxVersion: z.string(),
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.string(),
|
||||
transitive: z.boolean().optional(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ExportOutlined,
|
||||
GithubOutlined,
|
||||
GlobalOutlined,
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
RadarChartOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
@@ -39,8 +37,6 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||
const REPO_URL = 'https://git.881218.xyz/rogue/3x-ui-agent';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
const RAIL_WIDTH = 72;
|
||||
@@ -78,36 +74,6 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
routing: SwapOutlined,
|
||||
};
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-donate"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-docs"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<ReadOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
|
||||
if (!version) return null;
|
||||
const label = formatPanelVersion(version);
|
||||
@@ -349,8 +315,6 @@ export default function AppSidebar() {
|
||||
>
|
||||
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
|
||||
</button>
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
@@ -402,8 +366,6 @@ export default function AppSidebar() {
|
||||
<span className="drawer-brand">L-UI</span>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
isDark={isDark}
|
||||
|
||||
@@ -34,7 +34,6 @@ export class AllSetting {
|
||||
subPort = 2096;
|
||||
subPath = '/sub/';
|
||||
subDomain = '';
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
subUpdates = 12;
|
||||
|
||||
@@ -290,12 +290,15 @@ export function useSecurityActions({
|
||||
setSaving(true);
|
||||
try {
|
||||
/*
|
||||
* Node-assigned inbounds run on the node, so their cert files must be the
|
||||
* node's own paths (fetched through the central panel), not this panel's.
|
||||
* Only a local inbound can borrow this panel's own cert paths.
|
||||
* Node-assigned inbounds run on agent machines, which have no
|
||||
* panel cert to borrow (the old /nodes/webCert proxy went away
|
||||
* with traditional nodes, B6), so they fall through to the
|
||||
* empty-cert warning below.
|
||||
*/
|
||||
const msg =
|
||||
typeof nodeId === 'number'
|
||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||
? null
|
||||
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
if (!msg?.success) {
|
||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
|
||||
import {
|
||||
CopyOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
HddOutlined,
|
||||
SwapOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, ConfigProvider, Layout, Result, Spin, message } from 'antd';
|
||||
import { DashboardOutlined, DatabaseOutlined, HddOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { HttpUtil, CPUFormatter, SizeFormatter } from '@/utils';
|
||||
import {
|
||||
USAGE_CRIT_COLOR,
|
||||
USAGE_CRIT_PERCENT,
|
||||
@@ -30,7 +23,6 @@ import ConnectionsCard from './ConnectionsCard';
|
||||
import SystemStrip from './SystemStrip';
|
||||
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||
const BackupModal = lazy(() => import('./BackupModal'));
|
||||
import './IndexPage.css';
|
||||
@@ -59,8 +51,6 @@ export default function IndexPage() {
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const [backupOpen, setBackupOpen] = useState(false);
|
||||
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
@@ -95,27 +85,6 @@ export default function IndexPage() {
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
}
|
||||
|
||||
async function openConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||
if (!msg?.success) return;
|
||||
setConfigText(JSON.stringify(msg.obj, null, 2));
|
||||
setConfigTextOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
const ok = await ClipboardManager.copyText(configText || '');
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
FileManager.downloadTextFile(configText, 'config.json');
|
||||
}
|
||||
|
||||
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||
const totalDisk = status.disk.total;
|
||||
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
||||
@@ -183,7 +152,6 @@ export default function IndexPage() {
|
||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||
panelUpdateInfo.updateAvailable)
|
||||
}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||
/>
|
||||
@@ -290,42 +258,6 @@ export default function IndexPage() {
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={configTextOpen}>
|
||||
<Modal
|
||||
open={configTextOpen}
|
||||
title={t('pages.index.config')}
|
||||
width={isMobile ? '100%' : 900}
|
||||
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : { top: 20 }}
|
||||
onCancel={() => setConfigTextOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="download"
|
||||
onClick={downloadConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
{isMobile ? 'Download' : 'config.json'}
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
onClick={copyConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CopyOutlined />}
|
||||
>
|
||||
Copy
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<JsonEditor
|
||||
value={configText}
|
||||
onChange={setConfigText}
|
||||
minHeight={isMobile ? '300px' : 'calc(100vh - 220px)'}
|
||||
maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
|
||||
readOnly
|
||||
/>
|
||||
</Modal>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tag, Tooltip } from 'antd';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined } from '@ant-design/icons';
|
||||
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
|
||||
@@ -10,7 +10,6 @@ interface OverviewActionBarProps {
|
||||
panelVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
onOpenConfig: () => void;
|
||||
onOpenBackup: () => void;
|
||||
onOpenPanelUpdate: () => void;
|
||||
}
|
||||
@@ -28,7 +27,6 @@ export default function OverviewActionBar({
|
||||
panelVersion,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
onOpenConfig,
|
||||
onOpenBackup,
|
||||
onOpenPanelUpdate,
|
||||
}: OverviewActionBarProps) {
|
||||
@@ -36,12 +34,6 @@ export default function OverviewActionBar({
|
||||
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||
|
||||
const actions: BarAction[] = [
|
||||
{
|
||||
key: 'config',
|
||||
icon: <ControlOutlined />,
|
||||
text: t('pages.index.config'),
|
||||
onClick: onOpenConfig,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <CloudServerOutlined />,
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { Alert, Button, Col, Form, Input, Modal, Row, Switch, message } from 'antd';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||
import { NodeFormSchema, type NodeFormValues } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||
|
||||
import './NodeFormModal.css';
|
||||
@@ -31,9 +17,6 @@ interface NodeFormModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
node: NodeRecord | null;
|
||||
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
|
||||
fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
|
||||
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -43,21 +26,13 @@ function defaultValues(): NodeFormValues {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
nodeType: 'traditional',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
hasStoredToken: false,
|
||||
probeOnly: false,
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
tlsVerifyMode: 'verify',
|
||||
pinnedCertSha256: '',
|
||||
inboundSyncMode: 'all',
|
||||
inboundTags: [],
|
||||
outboundTag: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,9 +40,6 @@ export default function NodeFormModal({
|
||||
open,
|
||||
mode,
|
||||
node,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
fetchInbounds,
|
||||
save,
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
@@ -77,16 +49,8 @@ export default function NodeFormModal({
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [fetchingPin, setFetchingPin] = useState(false);
|
||||
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
||||
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
// Whether the just-created agent node was flagged probe-only, so the
|
||||
// install command below can carry the matching --probe-only flag.
|
||||
const [agentProbeOnly, setAgentProbeOnly] = useState(false);
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
// Edit mode (item 19): the stored install material for an existing agent
|
||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||
@@ -99,35 +63,6 @@ export default function NodeFormModal({
|
||||
hasToken: boolean;
|
||||
} | null>(null);
|
||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
||||
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||
const isAgent = nodeType === 'agent';
|
||||
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
|
||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
|
||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||
// when balancers exist they get a labeled group so it's clear the selection
|
||||
// routes through a balancer. Empty falls back to the placeholder ("Direct
|
||||
// connection") rather than a synthetic option, so it can't read as a second
|
||||
// "direct" next to a real freedom outbound.
|
||||
const outboundOptions = useMemo<
|
||||
(
|
||||
| { label: string; value: string }
|
||||
| { label: string; options: { label: string; value: string }[] }
|
||||
)[]
|
||||
>(() => {
|
||||
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
|
||||
if (!outboundGroups?.balancers.length) return outOpts;
|
||||
return [
|
||||
{ label: t('pages.xray.Outbounds'), options: outOpts },
|
||||
{
|
||||
label: t('pages.xray.Balancers'),
|
||||
options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })),
|
||||
},
|
||||
];
|
||||
}, [outboundGroups, t]);
|
||||
|
||||
// Reset during render, not in an effect, so the first frame is already clean.
|
||||
const [synced, setSynced] = useState<{ mode: string; node: NodeRecord | null } | null>(null);
|
||||
@@ -143,23 +78,16 @@ export default function NodeFormModal({
|
||||
...(node as unknown as Partial<NodeFormValues>),
|
||||
id: node.id,
|
||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
||||
inboundTags: node.inboundTags ?? [],
|
||||
apiToken: '',
|
||||
hasStoredToken: node.hasApiToken ?? false,
|
||||
}
|
||||
: base;
|
||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
||||
methods.reset(next);
|
||||
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
// Load the stored install material when editing an agent node so the
|
||||
// Load the stored install material when editing a node so the
|
||||
// full install command can be re-displayed (item 19). Nodes created
|
||||
// before tokens were stored answer hasToken=false until the agent's
|
||||
// next authenticated request backfills the token.
|
||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
||||
const editNodeId = mode === 'edit' ? (node?.id ?? 0) : 0;
|
||||
useEffect(() => {
|
||||
if (!open || !editNodeId) return;
|
||||
let cancelled = false;
|
||||
@@ -186,94 +114,35 @@ export default function NodeFormModal({
|
||||
const cmd = shownInstall.hasToken
|
||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||
: `${base} --name ${shownInstall.name}`;
|
||||
// The toggle is the source of truth for the re-displayed command:
|
||||
// flipping it rewrites the command the operator would re-run.
|
||||
return probeOnly ? `${cmd} --probe-only` : cmd;
|
||||
}, [shownInstall, editMasterUrl, probeOnly]);
|
||||
// The command never carries --probe-only: an agent.json preset would
|
||||
// pin probe-only on the machine (dual-source OR gate) and the panel
|
||||
// toggle alone could no longer turn it off. Probe-only is governed
|
||||
// solely by the master-side flag; the script flag stays for manual
|
||||
// installs that deliberately want the preset.
|
||||
return cmd;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
|
||||
|
||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||
const token = values.apiToken.trim();
|
||||
const agentMode = values.nodeType === 'agent';
|
||||
const payload: Partial<NodeRecord> = {
|
||||
// Agent nodes have no panel address; the backend ignores these
|
||||
// (and sanitizes the stored row), so the payload keeps the
|
||||
// historical placeholder pair.
|
||||
return {
|
||||
id: values.id || 0,
|
||||
name: values.name.trim(),
|
||||
remark: values.remark?.trim() || '',
|
||||
nodeType: values.nodeType,
|
||||
scheme: values.scheme,
|
||||
// Agent nodes have no panel address; the backend ignores these.
|
||||
address: agentMode ? 'agent' : values.address.trim(),
|
||||
port: agentMode ? 2053 : values.port,
|
||||
basePath: values.basePath.trim() || '/',
|
||||
address: 'agent',
|
||||
port: 2053,
|
||||
basePath: values.basePath?.trim() || '/',
|
||||
enable: values.enable,
|
||||
allowPrivateAddress: values.allowPrivateAddress,
|
||||
tlsVerifyMode: values.tlsVerifyMode,
|
||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||
inboundSyncMode: values.inboundSyncMode,
|
||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||
outboundTag: values.outboundTag || '',
|
||||
probeOnly: values.probeOnly ?? false,
|
||||
};
|
||||
// Probe-only (V2.1.0) is an agent-node concept; traditional
|
||||
// payloads stay byte-identical to before.
|
||||
if (agentMode) payload.probeOnly = values.probeOnly ?? false;
|
||||
if (token) payload.apiToken = token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
const msg = await testConnection(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
setTestResult(msg.obj);
|
||||
} else {
|
||||
setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' });
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchPin() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setFetchingPin(true);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
const msg = await fetchFingerprint(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
methods.setValue('pinnedCertSha256', msg.obj);
|
||||
messageApi.success(t('pages.nodes.pinFetched'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingPin(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchInbounds() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
|
||||
setFetchingInbounds(true);
|
||||
try {
|
||||
const msg = await fetchInbounds(buildPayload(methods.getValues()));
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
setInboundOptions(msg.obj);
|
||||
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingInbounds(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFinish(values: NodeFormValues) {
|
||||
@@ -285,29 +154,15 @@ export default function NodeFormModal({
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = buildPayload(result.data);
|
||||
const agentMode = result.data.nodeType === 'agent';
|
||||
// Agent nodes have no panel to probe; skip the connection test.
|
||||
if (!agentMode) {
|
||||
const test = await testConnection(payload);
|
||||
const probe = test?.success ? test.obj : null;
|
||||
if (!probe || probe.status !== 'online') {
|
||||
setTestResult(
|
||||
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTestResult(probe);
|
||||
}
|
||||
const msg = await save(payload);
|
||||
if (msg?.success) {
|
||||
// Backend returns { node, agentToken, nodeType } for agent nodes.
|
||||
// Backend returns { node, agentToken } for a created node.
|
||||
const obj = msg.obj as unknown as
|
||||
| { agentToken?: string; node?: { id?: number } }
|
||||
| undefined;
|
||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||
if (mode === 'add' && obj?.agentToken) {
|
||||
setAgentToken(obj.agentToken);
|
||||
setAgentNodeName(result.data.name.trim());
|
||||
setAgentProbeOnly(result.data.probeOnly ?? false);
|
||||
// Default the master URL to the address this panel is being
|
||||
// viewed through — origin AND base path. Dropping the base
|
||||
// path made the agent's registration 404. User can edit it.
|
||||
@@ -330,7 +185,7 @@ export default function NodeFormModal({
|
||||
// one would produce a command that can never register, so withhold it
|
||||
// (and say why) instead of letting the user copy a dead command.
|
||||
const installCmd = agentMasterUrl
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}${agentProbeOnly ? ' --probe-only' : ''}`
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -349,27 +204,12 @@ export default function NodeFormModal({
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
|
||||
<Select
|
||||
disabled={mode === 'edit'}
|
||||
options={[
|
||||
{ value: 'traditional', label: t('pages.nodes.nodeTypeTraditional') },
|
||||
{ value: 'agent', label: t('pages.nodes.nodeTypeAgent') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
{isAgent && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
@@ -387,80 +227,25 @@ export default function NodeFormModal({
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.scheme')}
|
||||
name="scheme"
|
||||
onAfterChange={(value) => {
|
||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.address')}
|
||||
name="address"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
||||
>
|
||||
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.port')}
|
||||
name="port"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.probeOnly')}
|
||||
name="probeOnly"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.probeOnly')}
|
||||
name="probeOnly"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && shownInstall && (
|
||||
{mode === 'edit' && shownInstall && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
@@ -497,7 +282,7 @@ export default function NodeFormModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && node?.id ? (
|
||||
{mode === 'edit' && node?.id ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.nodes.probe.title')}
|
||||
@@ -508,174 +293,6 @@ export default function NodeFormModal({
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
name="allowPrivateAddress"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.tlsVerifyMode')}
|
||||
name="tlsVerifyMode"
|
||||
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
||||
>
|
||||
<Select
|
||||
disabled={scheme === 'http'}
|
||||
options={[
|
||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'skip' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.tlsSkipWarning')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'mtls' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.mtlsFormHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'pin' && (
|
||||
<FormField
|
||||
label={t('pages.nodes.pinnedCert')}
|
||||
name="pinnedCertSha256"
|
||||
tooltip={t('pages.nodes.pinnedCertHint')}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
|
||||
enterButton={t('pages.nodes.fetchPin')}
|
||||
loading={fetchingPin}
|
||||
onSearch={onFetchPin}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.apiToken')}
|
||||
name="apiToken"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||
tooltip={t('pages.nodes.apiTokenHint')}
|
||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={
|
||||
editingWithToken
|
||||
? t('pages.nodes.apiTokenKeepHint')
|
||||
: t('pages.nodes.apiTokenPlaceholder')
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.outboundTag')}
|
||||
name="outboundTag"
|
||||
tooltip={t('pages.nodes.outboundTagHint')}
|
||||
transform={{ input: (v) => (v as string) || undefined }}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.nodes.outboundTagPlaceholder')}
|
||||
options={outboundOptions}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.inboundSyncMode')}
|
||||
name="inboundSyncMode"
|
||||
tooltip={t('pages.nodes.inboundSyncModeHint')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all', label: t('pages.nodes.allInbounds') },
|
||||
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{inboundSyncMode === 'selected' && (
|
||||
<FormField
|
||||
label={t('pages.nodes.inboundTags')}
|
||||
name="inboundTags"
|
||||
tooltip={t('pages.nodes.inboundTagsHint')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
loading={fetchingInbounds}
|
||||
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
loading={fetchingInbounds}
|
||||
onClick={onFetchInbounds}
|
||||
>
|
||||
{t('pages.nodes.loadInbounds')}
|
||||
</Button>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
options={inboundOptions.map((inbound) => ({
|
||||
value: inbound.tag,
|
||||
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<div className="test-row">
|
||||
<Button type="default" loading={testing} onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={
|
||||
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ClusterOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -29,14 +28,12 @@ import {
|
||||
PlusOutlined,
|
||||
RadarChartOutlined,
|
||||
RightOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './NodeList.css';
|
||||
|
||||
@@ -44,11 +41,9 @@ interface NodeListProps {
|
||||
nodes: NodeRecord[];
|
||||
loading?: boolean;
|
||||
isMobile?: boolean;
|
||||
latestVersion?: string;
|
||||
selectedIds: number[];
|
||||
onSelectionChange: (ids: number[]) => void;
|
||||
onAdd: () => void;
|
||||
onMtls: () => void;
|
||||
onProbeTasks: () => void;
|
||||
onEdit: (node: NodeRecord) => void;
|
||||
onDelete: (node: NodeRecord) => void;
|
||||
@@ -102,10 +97,16 @@ function CategoryChip({
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
// Agent nodes have no panel to link to: the address cell shows the
|
||||
// host IPs the agent reports with its heartbeat as plain text (P13).
|
||||
function agentIpText(record: Pick<NodeRecord, 'ipv4' | 'ipv6'>): string {
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return ips.length > 0 ? ips.join(' · ') : '-';
|
||||
}
|
||||
|
||||
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
@@ -215,11 +216,9 @@ export default function NodeList({
|
||||
nodes,
|
||||
loading = false,
|
||||
isMobile = false,
|
||||
latestVersion = '',
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
onAdd,
|
||||
onMtls,
|
||||
onProbeTasks,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -235,49 +234,7 @@ export default function NodeList({
|
||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||
// parent it is reached through (#4983).
|
||||
const nameByGuid = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||
return m;
|
||||
}, [nodes]);
|
||||
|
||||
// Order direct nodes first, each immediately followed by its transitive
|
||||
// sub-nodes, so the table reads as a parent -> child tree without colliding
|
||||
// with the per-row history expander (transitive nodes carry id 0).
|
||||
const dataSource = useMemo<NodeRow[]>(() => {
|
||||
const toRow = (n: NodeRecord): NodeRow => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.transitive ? `t-${n.guid || ''}` : n.id,
|
||||
});
|
||||
const childrenByParent = new Map<string, NodeRecord[]>();
|
||||
for (const n of nodes) {
|
||||
if (n.transitive && n.parentGuid) {
|
||||
const arr = childrenByParent.get(n.parentGuid) || [];
|
||||
arr.push(n);
|
||||
childrenByParent.set(n.parentGuid, arr);
|
||||
}
|
||||
}
|
||||
const ordered: NodeRow[] = [];
|
||||
const added = new Set<string>();
|
||||
const push = (n: NodeRecord) => {
|
||||
const row = toRow(n);
|
||||
ordered.push(row);
|
||||
added.add(String(row.key));
|
||||
};
|
||||
for (const n of nodes) {
|
||||
if (n.transitive) continue;
|
||||
push(n);
|
||||
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
|
||||
}
|
||||
// Transitive nodes whose parent isn't in the list still get shown.
|
||||
for (const n of nodes) {
|
||||
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
|
||||
}
|
||||
return ordered;
|
||||
}, [nodes]);
|
||||
const dataSource = useMemo<NodeRow[]>(() => nodes.map((n) => ({ ...n, key: n.id })), [nodes]);
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpandedIds((prev) => {
|
||||
@@ -294,106 +251,74 @@ export default function NodeList({
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 150,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<Tooltip
|
||||
title={t('pages.nodes.subNodeTip', {
|
||||
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
|
||||
})}
|
||||
>
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
||||
{t('pages.nodes.subNode')}
|
||||
</Tag>
|
||||
render: (_value, record) => (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.testConnection')}
|
||||
onClick={() => onProbe(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updateAgent')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.probe')}
|
||||
onClick={() => onProbe(record)}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t('pages.nodes.updateAgent')}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip
|
||||
title={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 64,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<span style={{ opacity: 0.4 }}>—</span>
|
||||
) : (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<div
|
||||
className="name-cell"
|
||||
style={record.transitive ? { paddingInlineStart: 20 } : undefined}
|
||||
>
|
||||
<span className="name">
|
||||
{record.transitive && (
|
||||
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />
|
||||
)}
|
||||
{record.name}
|
||||
</span>
|
||||
<div className="name-cell">
|
||||
<span className="name">{record.name}</span>
|
||||
{record.remark && <span className="remark">{record.remark}</span>}
|
||||
</div>
|
||||
),
|
||||
@@ -425,37 +350,22 @@ export default function NodeList({
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
dataIndex: 'address',
|
||||
width: 330,
|
||||
ellipsis: true,
|
||||
render: (_value, record) => {
|
||||
if (record.nodeType === 'agent') {
|
||||
// Agent nodes have no panel to link to: show the host IPs the
|
||||
// agent reports with its heartbeat as plain text on one line
|
||||
// (P13, layout per item 21).
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -493,58 +403,29 @@ export default function NodeList({
|
||||
render: (_value, record) => formatPct(record.memPct),
|
||||
},
|
||||
{
|
||||
// Core version (V2.0.1 ②): agent rows run sing-box and show
|
||||
// its version; traditional rows keep showing the xray one.
|
||||
// Probe-only agent rows (V2.1.0) run no core by design: the
|
||||
// Core version (V2.0.1 ②): rows run sing-box and show its
|
||||
// version. Probe-only rows (V2.1.0) run no core by design: the
|
||||
// cell says so instead of a bare '-', which reads as a fault.
|
||||
title: t('pages.nodes.coreVersion'),
|
||||
dataIndex: 'xrayVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) =>
|
||||
record.nodeType === 'agent'
|
||||
? record.probeOnly
|
||||
? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{t('pages.nodes.probeOnly')}
|
||||
</Tag>
|
||||
)
|
||||
: record.singboxVersion || record.xrayVersion || '-'
|
||||
: record.xrayVersion || '-',
|
||||
record.probeOnly ? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{t('pages.nodes.probeOnly')}
|
||||
</Tag>
|
||||
) : (
|
||||
record.singboxVersion || record.xrayVersion || '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
// Rows carry the A-series agent display version ("A2.3.0"),
|
||||
// not an upstream panel version — no update badge here (the
|
||||
// one-click update action lives in the actions column).
|
||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => {
|
||||
// Agent rows carry the A-series display version ("A1.0.0"), not
|
||||
// an upstream panel version: the update badge must never light
|
||||
// up for them (the version comparator falls back to string
|
||||
// inequality for unparseable versions, which would always
|
||||
// differ) (P13, item 18).
|
||||
const canUpdate =
|
||||
record.nodeType !== 'agent' &&
|
||||
isUpdateEligible(record) &&
|
||||
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span>{record.panelVersion || '-'}</span>
|
||||
{canUpdate && (
|
||||
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{ margin: 0, cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
onKeyDown={activateOnKey(() => onUpdateNode(record))}
|
||||
>
|
||||
{t('pages.nodes.updateAvailable')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
render: (_value, record) => <span>{record.panelVersion || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.uptime'),
|
||||
@@ -611,18 +492,7 @@ export default function NodeList({
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
showAddress,
|
||||
relativeTime,
|
||||
latestVersion,
|
||||
onToggleEnable,
|
||||
onProbe,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateNode,
|
||||
nameByGuid,
|
||||
],
|
||||
[t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -631,9 +501,6 @@ export default function NodeList({
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{t('pages.nodes.addNode')}
|
||||
</Button>
|
||||
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
||||
{t('pages.nodes.mtls.title')}
|
||||
</Button>
|
||||
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
||||
{t('pages.nodes.probeTasks.title')}
|
||||
</Button>
|
||||
@@ -653,133 +520,109 @@ export default function NodeList({
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
) : (
|
||||
dataSource.map((record) =>
|
||||
record.transitive ? (
|
||||
dataSource.map((record) => (
|
||||
<div key={record.id} className="node-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
key={String(record.key)}
|
||||
className="node-card"
|
||||
style={{ paddingInlineStart: 16, opacity: 0.85 }}
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||
toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
||||
{t('pages.nodes.subNode')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={record.id} className="node-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||
toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.probe')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
...(isUpdateEligible(record)
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined />{' '}
|
||||
{t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="row-action-trigger"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
...(isUpdateEligible(record)
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined /> {t('pages.nodes.updateAgent')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="row-action-trigger"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -801,14 +644,9 @@ export default function NodeList({
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.address')}</span>
|
||||
<a
|
||||
href={statsNode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{statsNode.url}
|
||||
</a>
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{agentIpText(statsNode)}
|
||||
</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined
|
||||
@@ -854,11 +692,7 @@ export default function NodeList({
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
||||
<Tag>
|
||||
{statsNode.nodeType === 'agent'
|
||||
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
|
||||
: statsNode.xrayVersion || '-'}
|
||||
</Tag>
|
||||
<Tag>{statsNode.singboxVersion || statsNode.xrayVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">
|
||||
@@ -928,7 +762,7 @@ export default function NodeList({
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
disabled: !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
@@ -943,7 +777,6 @@ export default function NodeList({
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Spin,
|
||||
Statistic,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -35,38 +30,6 @@ import AppSidebar from '@/layouts/AppSidebar';
|
||||
import NodeList from './NodeList';
|
||||
import NodeFormModal from './NodeFormModal';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
|
||||
|
||||
// Confirm-dialog body that lets the operator pick the stable or dev channel for
|
||||
// a node panel update. Reports changes via onChange so the imperative
|
||||
// modal.confirm onOk can read the latest choice through a ref.
|
||||
function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [dev, setDev] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<p>{t('pages.nodes.updateConfirmContent')}</p>
|
||||
<Checkbox
|
||||
checked={dev}
|
||||
onChange={(e) => {
|
||||
setDev(e.target.checked);
|
||||
onChange(e.target.checked);
|
||||
}}
|
||||
>
|
||||
{t('pages.nodes.updateDevChannel')}
|
||||
</Checkbox>
|
||||
{dev && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 8 }}
|
||||
title={t('pages.index.devChannelWarning')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,70 +43,12 @@ export default function NodesPage() {
|
||||
}, [messageApi]);
|
||||
|
||||
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
fetchInbounds,
|
||||
probe,
|
||||
updatePanels,
|
||||
} = useNodeMutations();
|
||||
|
||||
const { data: latestVersion = '' } = useQuery({
|
||||
queryKey: ['server', 'panelUpdateInfo'],
|
||||
queryFn: async () => {
|
||||
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
|
||||
return msg?.obj?.latestVersion || '';
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { create, update, remove, setEnable, probe, updatePanels } = useNodeMutations();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [mtlsOpen, setMtlsOpen] = useState(false);
|
||||
const [trustCa, setTrustCa] = useState('');
|
||||
const [copyingCa, setCopyingCa] = useState(false);
|
||||
const [savingTrustCa, setSavingTrustCa] = useState(false);
|
||||
|
||||
const onCopyNodeCa = useCallback(async () => {
|
||||
setCopyingCa(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
|
||||
const ca = msg?.obj?.caCert;
|
||||
if (msg?.success && ca) {
|
||||
await navigator.clipboard.writeText(ca);
|
||||
messageApi.success(t('pages.nodes.mtls.caCopied'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('pages.nodes.mtls.caFailed'));
|
||||
} finally {
|
||||
setCopyingCa(false);
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
const onSaveTrustCa = useCallback(async () => {
|
||||
setSavingTrustCa(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.nodes.mtls.saved'));
|
||||
setMtlsOpen(false);
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
setSavingTrustCa(false);
|
||||
}
|
||||
}, [trustCa, messageApi, t]);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
@@ -208,8 +113,6 @@ export default function NodesPage() {
|
||||
[setEnable],
|
||||
);
|
||||
|
||||
const devRef = useRef(false);
|
||||
|
||||
const runUpdate = useCallback(
|
||||
async (ids: number[], dev: boolean) => {
|
||||
const msg = await updatePanels(ids, dev);
|
||||
@@ -232,33 +135,16 @@ export default function NodesPage() {
|
||||
[updatePanels, messageApi, t],
|
||||
);
|
||||
|
||||
// Nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
const onUpdateNode = useCallback(
|
||||
(node: NodeRecord) => {
|
||||
// Agent nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
if (node.nodeType === 'agent') {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: (
|
||||
<UpdateChannelChoice
|
||||
onChange={(v) => {
|
||||
devRef.current = v;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], devRef.current),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
},
|
||||
[modal, t, runUpdate],
|
||||
@@ -272,19 +158,12 @@ export default function NodesPage() {
|
||||
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
|
||||
content: (
|
||||
<UpdateChannelChoice
|
||||
onChange={(v) => {
|
||||
devRef.current = v;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate(eligible, devRef.current),
|
||||
onOk: () => runUpdate(eligible, false),
|
||||
});
|
||||
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
|
||||
|
||||
@@ -364,11 +243,9 @@ export default function NodesPage() {
|
||||
nodes={nodes}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
latestVersion={latestVersion}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onAdd={onAdd}
|
||||
onMtls={() => setMtlsOpen(true)}
|
||||
onProbeTasks={() => navigate('/settings#probe')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
@@ -388,44 +265,9 @@ export default function NodesPage() {
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
node={formNode}
|
||||
testConnection={testConnection}
|
||||
fetchFingerprint={fetchFingerprint}
|
||||
fetchInbounds={fetchInbounds}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={mtlsOpen}
|
||||
title={t('pages.nodes.mtls.title')}
|
||||
footer={null}
|
||||
onCancel={() => setMtlsOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
{t('pages.nodes.mtls.intro')}
|
||||
</Typography.Paragraph>
|
||||
<Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
|
||||
{t('pages.nodes.mtls.copyCa')}
|
||||
</Button>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.nodes.mtls.copyCaHint')}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={trustCa}
|
||||
onChange={(e) => setTrustCa(e.target.value)}
|
||||
placeholder={t('pages.nodes.mtls.trustPlaceholder')}
|
||||
style={{ marginTop: 4, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||
{t('pages.nodes.mtls.trustHint')}
|
||||
</Typography.Paragraph>
|
||||
<Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
|
||||
{t('pages.nodes.mtls.save')}
|
||||
</Button>
|
||||
</Modal>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -24,12 +24,20 @@ interface ProbeMetaWire {
|
||||
ipv4Pin: string;
|
||||
ipv6Pin: string;
|
||||
// V2.0.1: ledger corrections in GB (either sign) and the counted
|
||||
// interface pin ('' = automatic selection on the agent).
|
||||
// interface pin ('' = automatic selection on the agent). Since
|
||||
// V2.2.1 the corrections are one-shot deltas: the inputs below are
|
||||
// local state (never prefilled from the wire), a save submits them
|
||||
// once, and they clear back to empty on success.
|
||||
trafficCalUpGb: number;
|
||||
trafficCalDownGb: number;
|
||||
statIface: string;
|
||||
}
|
||||
|
||||
// GB entry converts on the binary convention every byte formatter in
|
||||
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
|
||||
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
|
||||
const BYTES_PER_GB = 1024 ** 3;
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
const NAMED_CYCLES = [
|
||||
@@ -59,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
// in NodeFormModal); visibility derives during render.
|
||||
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
|
||||
const [trafficGb, setTrafficGb] = useState(0);
|
||||
// One-shot calibration deltas (GB, either sign): null = empty box.
|
||||
const [calUpGb, setCalUpGb] = useState<number | null>(null);
|
||||
const [calDownGb, setCalDownGb] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
}).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setLoaded({ nodeId, meta: msg.obj });
|
||||
setTrafficGb(msg.obj.trafficLimit / 1e9);
|
||||
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -116,14 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
try {
|
||||
const payload: ProbeMetaWire = {
|
||||
...meta,
|
||||
trafficLimit: Math.round(trafficGb * 1e9),
|
||||
trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
|
||||
trafficCalUpGb: calUpGb ?? 0,
|
||||
trafficCalDownGb: calDownGb ?? 0,
|
||||
};
|
||||
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setLoaded({ nodeId, meta: payload });
|
||||
// One-shot: the submitted corrections are folded in server-
|
||||
// side; the boxes clear so a later save resubmits nothing.
|
||||
setLoaded({
|
||||
nodeId,
|
||||
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
|
||||
});
|
||||
setCalUpGb(null);
|
||||
setCalDownGb(null);
|
||||
messageApi.success(t('pages.nodes.probe.saved'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
|
||||
@@ -249,9 +269,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calUp')}>
|
||||
<InputNumber
|
||||
value={meta.trafficCalUpGb}
|
||||
value={calUpGb}
|
||||
step={0.1}
|
||||
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalUpGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
@@ -259,9 +280,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calDown')}>
|
||||
<InputNumber
|
||||
value={meta.trafficCalDownGb}
|
||||
value={calDownGb}
|
||||
step={0.1}
|
||||
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalDownGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -178,17 +178,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.restartXrayOnClientDisable')}
|
||||
description={t('pages.settings.restartXrayOnClientDisableDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.restartXrayOnClientDisable}
|
||||
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.trafficStatsInboundUplink')}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface ApiTokenRow {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
scope: 'admin' | 'monitor' | 'node-sync';
|
||||
scope: 'admin' | 'monitor';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ export const NodeRecordSchema = z
|
||||
address: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
basePath: z.string().optional(),
|
||||
apiToken: z.string().optional(),
|
||||
hasApiToken: z.boolean().optional(),
|
||||
enable: z.boolean().optional(),
|
||||
status: z.string().optional(),
|
||||
latencyMs: z.number().optional(),
|
||||
@@ -42,20 +40,8 @@ export const NodeRecordSchema = z
|
||||
xrayState: z.string().optional(),
|
||||
xrayError: z.string().optional(),
|
||||
allowPrivateAddress: z.boolean().optional(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
|
||||
pinnedCertSha256: z.string().optional(),
|
||||
inboundSyncMode: z.enum(['all', 'selected']).optional(),
|
||||
// Backend serializes a nil []string as null for nodes saved before #5178.
|
||||
inboundTags: z.array(z.string()).nullish(),
|
||||
outboundTag: z.string().optional(),
|
||||
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||
// A node's stable GUID (online attribution is keyed on it).
|
||||
guid: z.string().optional(),
|
||||
parentGuid: z.string().optional(),
|
||||
transitive: z.boolean().optional(),
|
||||
// P7: node kind, annotated by the backend for nodes registered by an
|
||||
// agent. Absent/traditional for classic panel-to-panel nodes.
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
// V2.1.0: probe-only agent nodes run no core, only the probe
|
||||
// channels. Annotated by the backend from the bound agent row.
|
||||
probeOnly: z.boolean().optional(),
|
||||
@@ -84,65 +70,24 @@ export const ProbeResultSchema = z
|
||||
})
|
||||
.loose();
|
||||
|
||||
export const NodeFormSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
remark: z.string().optional(),
|
||||
// Node type: traditional panel-to-panel, or lightweight agent.
|
||||
// Agent nodes skip the panel probe and API token; an agent token is
|
||||
// issued on creation instead.
|
||||
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
// For agent nodes the panel never dials the node (agent dials out),
|
||||
// so address/port are not needed. Required for traditional nodes.
|
||||
address: z.string().trim().optional().default(''),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
basePath: z.string(),
|
||||
// mTLS nodes authenticate via the client certificate, so the token is optional
|
||||
// there; every other verify mode still requires one (matches remote.do()).
|
||||
// Agent nodes use an agent token instead of a panel API token.
|
||||
apiToken: z.string().trim(),
|
||||
hasStoredToken: z.boolean().optional().default(false),
|
||||
// V2.1.0: probe-only toggle for agent nodes (see NodeRecordSchema).
|
||||
probeOnly: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
pinnedCertSha256: z.string().optional().default(''),
|
||||
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
|
||||
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
|
||||
// serialized as null by the backend for a nil slice — tolerate both.
|
||||
inboundTags: z
|
||||
.array(z.string())
|
||||
.nullish()
|
||||
.transform((tags) => tags ?? []),
|
||||
outboundTag: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.nodeType === 'agent') return; // no panel credentials needed
|
||||
if (!val.address || val.address.trim().length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['address'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
if (val.port === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['port'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['apiToken'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
});
|
||||
export const NodeFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
remark: z.string().optional(),
|
||||
// Every node is an agent node now (B6): the panel never dials it,
|
||||
// so the connection fields are not user-editable. They stay in the
|
||||
// form values so the submit payload carries the stored row's values
|
||||
// on edit (or the historical defaults on add); the backend
|
||||
// sanitizes the endpoint for agent rows regardless.
|
||||
scheme: z.enum(['http', 'https']).optional().default('https'),
|
||||
address: z.string().optional().default(''),
|
||||
port: z.number().optional(),
|
||||
basePath: z.string().optional().default('/'),
|
||||
allowPrivateAddress: z.boolean().optional().default(false),
|
||||
// V2.1.0: probe-only toggle (see NodeRecordSchema).
|
||||
probeOnly: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
|
||||
|
||||
@@ -41,7 +41,6 @@ export const AllSettingSchema = z
|
||||
subPort: z.number().int().min(0).max(65535).optional(),
|
||||
subPath: absolutePath.optional(),
|
||||
subDomain: z.string().optional(),
|
||||
restartXrayOnClientDisable: z.boolean().optional(),
|
||||
subCertFile: z.string().optional(),
|
||||
subKeyFile: z.string().optional(),
|
||||
subUpdates: z.number().int().min(0).max(525600).optional(),
|
||||
|
||||
@@ -16,7 +16,6 @@ function renderNodes(nodes: NodeRecord[]) {
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onProbeTasks={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
@@ -49,7 +48,6 @@ describe('NodeList clients-cell category chips', () => {
|
||||
name: 'agent-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
nodeType: 'agent',
|
||||
clientCount: 9,
|
||||
activeCount: 2,
|
||||
activeEmails: ['active-a@x', 'active-b@x'],
|
||||
|
||||
@@ -9,16 +9,16 @@ const noop = () => {};
|
||||
|
||||
function sampleNodes(): NodeRecord[] {
|
||||
return [
|
||||
{ id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' },
|
||||
{ id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true },
|
||||
{ id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true },
|
||||
{ id: 1, name: 'node-a', guid: 'p1', enable: true, status: 'online' },
|
||||
{ id: 2, name: 'node-b', guid: 'ca', enable: true, status: 'online' },
|
||||
{ id: 3, name: 'node-c', guid: 'cb', enable: true, status: 'online' },
|
||||
];
|
||||
}
|
||||
|
||||
describe('NodeList desktop table row keys', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('gives transitive sub-node rows distinct keys instead of colliding on id 0', () => {
|
||||
it('keys rows by node id without duplicate-key warnings', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderWithProviders(
|
||||
@@ -28,7 +28,6 @@ describe('NodeList desktop table row keys', () => {
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onProbeTasks={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
|
||||
@@ -68,6 +68,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
||||
@@ -108,6 +108,8 @@ github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
# Release download base, same shape as install-agent.sh's
|
||||
# RELEASE_BASE (the version segment is bumped by the release flow).
|
||||
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
|
||||
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.4.0"
|
||||
if (-not $AgentUrl) {
|
||||
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
|
||||
}
|
||||
|
||||
+68
-6
@@ -11,7 +11,7 @@
|
||||
#
|
||||
# 测试用环境变量(一般无需设置):
|
||||
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
|
||||
# XUI_URL agent 二进制下载地址覆盖
|
||||
#
|
||||
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
|
||||
@@ -81,8 +81,13 @@ esac
|
||||
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
|
||||
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
|
||||
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
|
||||
#
|
||||
# agent/主控拆分(期一):下载的是 agent 专用产物 x-ui-agent-linux-<arch>
|
||||
# (独立入口 cmd/xui-agent,不再是主控双模式二进制)。该资产自拆分版
|
||||
# 发行起才有——RELEASE_BASE 的升版由发版步完成,指向拆分版即可用;
|
||||
# 若需装拆分前的旧版节点,显式 XUI_URL 指回 x-ui-linux-<arch> 即可。
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.4.0"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-agent-linux-${BIN_ARCH}}"
|
||||
|
||||
ROOT="${XUI_ROOT:-}"
|
||||
XUI_HOME="${ROOT}/usr/local/x-ui"
|
||||
@@ -220,7 +225,7 @@ stop_legacy_agent() {
|
||||
|
||||
detect_init_system() {
|
||||
case "${XUI_INIT_SYSTEM:-auto}" in
|
||||
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
esac
|
||||
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo systemd; return
|
||||
@@ -228,6 +233,9 @@ detect_init_system() {
|
||||
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
|
||||
echo openrc; return
|
||||
fi
|
||||
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
|
||||
echo procd; return
|
||||
fi
|
||||
echo none
|
||||
}
|
||||
|
||||
@@ -286,10 +294,14 @@ command_args="agent"
|
||||
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
|
||||
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
|
||||
# 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。
|
||||
# respawn_period 必须为正数:supervise-daemon 启动时校验该值,0 直接报
|
||||
# "Invalid respawn-period value '0'" 拒绝拉起(V2.2.1 ⑤,Alpine 真机实证);
|
||||
# 取 1800(真机上其他 supervise-daemon 服务同值)。max=0 不限次时 period
|
||||
# 仅是计数窗口,不影响持续重试语义。
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
respawn_period=0
|
||||
respawn_period=1800
|
||||
pidfile="/run/x-ui-agent.pid"
|
||||
output_log="/var/log/x-ui-agent.log"
|
||||
error_log="/var/log/x-ui-agent.log"
|
||||
@@ -322,9 +334,58 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# 以 procd 服务方式安装并启动(开机自启,OpenWrt)
|
||||
start_via_procd() {
|
||||
local init_dir="${ROOT}/etc/init.d"
|
||||
mkdir -p "$init_dir"
|
||||
# 服务脚本与 2026-10-23 AX6600 真机手工验证版逐字一致(V2.2.1 ④
|
||||
# 收编):路径按脚本变量展开,XUI_ROOT 为空时即验证版的
|
||||
# /usr/local/x-ui/xui-agent 与 /etc/x-ui/agent.json。procd 托管
|
||||
# + respawn 守护,stdout/stderr 进 syslog(logread 查看)。
|
||||
cat > "${init_dir}/xui-agent" <<EOF
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=99
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command ${AGENT_BIN} agent -config ${CONF_DIR}/agent.json
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
EOF
|
||||
chmod +x "${init_dir}/xui-agent"
|
||||
info "已生成 procd 服务文件: ${init_dir}/xui-agent"
|
||||
if [ -n "$ROOT" ]; then
|
||||
info "(XUI_ROOT 模式:跳过 /etc/init.d 实际操作)"
|
||||
return 0
|
||||
fi
|
||||
/etc/init.d/xui-agent stop 2>/dev/null || true
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
/etc/init.d/xui-agent enable
|
||||
/etc/init.d/xui-agent start
|
||||
sleep 2
|
||||
if pgrep -f "${AGENT_BIN} agent" >/dev/null 2>&1 || ps w 2>/dev/null | grep "[x]ui-agent agent" >/dev/null 2>&1; then
|
||||
info "agent 已由 procd 托管启动(开机自启)"
|
||||
echo ""
|
||||
echo " 主控: $MASTER"
|
||||
echo " 节点名: $NAME"
|
||||
echo " 日志: logread -f"
|
||||
echo " agent 会在 30 秒内从主控拉取配置"
|
||||
else
|
||||
error "agent 服务启动失败,查看: logread | tail -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
# 无服务管理器时回落 nohup(无开机自启,明确警告)
|
||||
start_via_nohup() {
|
||||
warn "未检测到 systemd / OpenRC,退回 nohup 方式启动"
|
||||
# BusyBox 精简系统可能连 nohup 都没有(AX6600 实证):先讲清再退,
|
||||
# 不要假装启动、走到最后才报一个看不懂的失败。
|
||||
command -v nohup >/dev/null 2>&1 || error "未检测到 systemd / OpenRC / procd,且本机无 nohup 命令,无法启动 agent"
|
||||
warn "未检测到 systemd / OpenRC / procd,退回 nohup 方式启动"
|
||||
warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!"
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
@@ -348,5 +409,6 @@ info "启动 agent..."
|
||||
case "$(detect_init_system)" in
|
||||
systemd) start_via_systemd;;
|
||||
openrc) start_via_openrc;;
|
||||
procd) start_via_procd;;
|
||||
*) start_via_nohup;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// This file holds the probe collector's degrade-don't-die plumbing:
|
||||
// the per-source independence discipline as reusable mechanics,
|
||||
// shared by every platform sampler but exercised today by the Windows
|
||||
// sampler (the Linux sampler reads /proc files, whose failure mode is
|
||||
// an error return, not a panic).
|
||||
//
|
||||
// Two layers:
|
||||
//
|
||||
// - source gates: an optional OS facility (a Win32 procedure, say)
|
||||
// is probed once; when it is missing the source is skipped —
|
||||
// zeroed — with a single warning, never re-probed, and never
|
||||
// reached through a call path that panics on a missing export the
|
||||
// way LazyProc.Call does when it must-finds a procedure;
|
||||
// - guarded calls: a panicking source is recovered, zeroed and
|
||||
// logged, so one bad source can never take the agent process (and
|
||||
// every other source with it) down.
|
||||
|
||||
// sourceGate caches one availability probe. The zero value is ready.
|
||||
type sourceGate struct {
|
||||
once sync.Once
|
||||
available bool
|
||||
}
|
||||
|
||||
// enabled runs probe on first use and reports whether the source may
|
||||
// be used. probe returns nil when the facility is present. A failed
|
||||
// probe disables the source for the process lifetime and logs once.
|
||||
func (g *sourceGate) enabled(name string, probe func() error) bool {
|
||||
g.once.Do(func() {
|
||||
if err := probe(); err != nil {
|
||||
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
|
||||
return
|
||||
}
|
||||
g.available = true
|
||||
})
|
||||
return g.available
|
||||
}
|
||||
|
||||
// guardedCall runs fn and converts a panic into the zero value plus a
|
||||
// warning, so one failing metrics source degrades alone.
|
||||
func guardedCall[T any](name string, fn func() T) (out T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
|
||||
out = *new(T)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
|
||||
// Tianyi cloud-PC image the Windows sampler died on its first
|
||||
// collection because a missing iphlpapi export made LazyProc.Call
|
||||
// panic. These tests pin the two layers that replace that failure
|
||||
// mode — a failed availability probe disables the source (probed
|
||||
// once, not per poll), and a panicking source is zeroed, never
|
||||
// propagated.
|
||||
|
||||
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
|
||||
var g sourceGate
|
||||
calls := 0
|
||||
probe := func() error {
|
||||
calls++
|
||||
return errors.New("procedure not found")
|
||||
}
|
||||
for i := range 3 {
|
||||
if g.enabled("test-source", probe) {
|
||||
t.Fatalf("call %d: gate enabled despite failed probe", i)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
|
||||
var g sourceGate
|
||||
calls := 0
|
||||
probe := func() error {
|
||||
calls++
|
||||
return nil
|
||||
}
|
||||
for i := range 3 {
|
||||
if !g.enabled("test-source", probe) {
|
||||
t.Fatalf("call %d: gate disabled despite successful probe", i)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
|
||||
got := guardedCall("test-source", func() uint64 {
|
||||
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
|
||||
})
|
||||
if got != 0 {
|
||||
t.Fatalf("guardedCall after panic = %d, want zero value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedCallPassesValueThrough(t *testing.T) {
|
||||
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
|
||||
t.Fatalf("guardedCall = %q, want %q", got, "ok")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,14 @@ package agent
|
||||
// degradation discipline Load stays zero and TCP/UDP stay 0
|
||||
// until a wrapper exists;
|
||||
// - every call fails soft to zeros, like an unreadable /proc file.
|
||||
// Optional procedures are resolved through source gates
|
||||
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
|
||||
// missing export, and trimmed images do ship DLLs without the
|
||||
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
|
||||
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
|
||||
// collection (V2.2.1 ①). A missing procedure now disables just
|
||||
// its source, and collectMetrics additionally guards each source
|
||||
// so a panic anywhere degrades that source alone.
|
||||
|
||||
import (
|
||||
"net"
|
||||
@@ -50,15 +58,35 @@ import (
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// kernel32 procs x/sys/windows v0.47.0 does not wrap.
|
||||
// kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
|
||||
// iphlpapi pair this file calls directly (gated below, so x/sys'
|
||||
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
|
||||
// missing export — is deliberately not used).
|
||||
var (
|
||||
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
||||
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
||||
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
|
||||
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
|
||||
)
|
||||
|
||||
// Availability gates for the optional procedures above. A LazyProc
|
||||
// must never be Call()ed before its gate has proven the export
|
||||
// exists: Call must-finds, so a missing procedure is a process-killing
|
||||
// panic, not an error.
|
||||
var (
|
||||
gateSystemTimes sourceGate
|
||||
gateMemoryStatus sourceGate
|
||||
gateIfTable sourceGate
|
||||
)
|
||||
|
||||
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
|
||||
// the export WITHOUT the mustFind panic baked into Call/Addr.
|
||||
func procProbe(p *windows.LazyProc) func() error {
|
||||
return func() error { return p.Find() }
|
||||
}
|
||||
|
||||
func sampleFacts() *ProbeFacts {
|
||||
memTotal, _, swapTotal, _ := winMemory()
|
||||
diskTotal, _ := winDiskUsage()
|
||||
@@ -80,12 +108,26 @@ func sampleFacts() *ProbeFacts {
|
||||
}
|
||||
}
|
||||
|
||||
// memSnapshot / diskSnapshot pack the multi-value samplers so each
|
||||
// can run as one guarded source.
|
||||
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
|
||||
type diskSnapshot struct{ total, used uint64 }
|
||||
|
||||
func collectMetrics() *ProbeMetrics {
|
||||
probeColl.mu.Lock()
|
||||
defer probeColl.mu.Unlock()
|
||||
memTotal, memUsed, swapTotal, swapUsed := winMemory()
|
||||
diskTotal, diskUsed := winDiskUsage()
|
||||
rows := winIfaceRows()
|
||||
// Every source runs guarded: a panic in one (a Win32 surface the
|
||||
// gates did not foresee) zeroes that source alone instead of
|
||||
// killing the agent on its first collection.
|
||||
mem := guardedCall("memory", func() memSnapshot {
|
||||
total, used, swapTotal, swapUsed := winMemory()
|
||||
return memSnapshot{total, used, swapTotal, swapUsed}
|
||||
})
|
||||
disk := guardedCall("disk", func() diskSnapshot {
|
||||
total, used := winDiskUsage()
|
||||
return diskSnapshot{total, used}
|
||||
})
|
||||
rows := guardedCall("interfaces", winIfaceRows)
|
||||
all := make([]netCounters, 0, len(rows))
|
||||
counted := make([]netCounters, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
@@ -107,23 +149,25 @@ func collectMetrics() *ProbeMetrics {
|
||||
}
|
||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||
return &ProbeMetrics{
|
||||
BootID: bootEpoch(winMachineGUID(), names),
|
||||
Uptime: uint64(windows.DurationSinceBoot().Seconds()),
|
||||
CPU: probeColl.cpuPercent(winCPUSample()),
|
||||
BootID: bootEpoch(winMachineGUID(), names),
|
||||
Uptime: guardedCall("uptime", func() uint64 {
|
||||
return uint64(windows.DurationSinceBoot().Seconds())
|
||||
}),
|
||||
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
|
||||
Load: [3]float32{},
|
||||
MemTotal: memTotal,
|
||||
MemUsed: memUsed,
|
||||
SwapTotal: swapTotal,
|
||||
SwapUsed: swapUsed,
|
||||
DiskTotal: diskTotal,
|
||||
DiskUsed: diskUsed,
|
||||
MemTotal: mem.total,
|
||||
MemUsed: mem.used,
|
||||
SwapTotal: mem.swapTotal,
|
||||
SwapUsed: mem.swapUsed,
|
||||
DiskTotal: disk.total,
|
||||
DiskUsed: disk.used,
|
||||
NetRxTotal: rxTotal,
|
||||
NetTxTotal: txTotal,
|
||||
NetRx: rxRate,
|
||||
NetTx: txRate,
|
||||
TCP: 0, // no exported x/sys wrapper (see header)
|
||||
UDP: 0, // no exported x/sys wrapper (see header)
|
||||
Procs: winProcCount(),
|
||||
Procs: guardedCall("processes", winProcCount),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +256,9 @@ func filetime100ns(ft windows.Filetime) uint64 {
|
||||
// winCPUSample reads the cumulative system times as one cpuSample:
|
||||
// total = kernel + user (kernel already includes idle, like jiffies).
|
||||
func winCPUSample() *cpuSample {
|
||||
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
|
||||
return nil
|
||||
}
|
||||
var idle, kernel, user windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
@@ -248,6 +295,9 @@ func usedFromTotalAvail(total, avail uint64) uint64 {
|
||||
}
|
||||
|
||||
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
||||
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
var ms memoryStatusEx
|
||||
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
@@ -359,8 +409,25 @@ func winIfaceCounts(r winIfaceRow) bool {
|
||||
// derives both the all-interfaces list (for the operator pin) and the
|
||||
// counted set from it.
|
||||
func winIfaceRows() []winIfaceRow {
|
||||
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
|
||||
// without FreeMibIfTable2 would leak the system-allocated table
|
||||
// on every poll, and either export missing must degrade the
|
||||
// source to zero rows instead of panicking — the trimmed Tianyi
|
||||
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
|
||||
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
|
||||
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
|
||||
if err := procGetIfTable2Ex.Find(); err != nil {
|
||||
return err
|
||||
}
|
||||
return procFreeMibIfTable2.Find()
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
var table *windows.MibIfTable2
|
||||
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil {
|
||||
r, _, _ := procGetIfTable2Ex.Call(
|
||||
uintptr(windows.MibIfTableNormal),
|
||||
uintptr(unsafe.Pointer(&table)))
|
||||
if r != 0 || table == nil {
|
||||
return nil
|
||||
}
|
||||
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
||||
|
||||
@@ -151,7 +151,12 @@ func (a *Agent) runUpdate(intent updateIntent) {
|
||||
fail(fmt.Errorf("locate own binary: %w", err))
|
||||
return
|
||||
}
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
// Agent/panel split (phase 1): the agent downloads the standalone
|
||||
// agent asset, not the panel binary. An agent build predating the
|
||||
// split still asks for the panel asset (AssetName) — the two are
|
||||
// interchangeable at the same path, so each generation converges
|
||||
// on the form its own build requests.
|
||||
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
|
||||
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
// The agent requests the standalone agent asset (agent/panel
|
||||
// split); the fake release only serves that name.
|
||||
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -36,33 +36,3 @@ func GreatestExpr(a, b string) string {
|
||||
}
|
||||
return fmt.Sprintf("MAX(%s, %s)", a, b)
|
||||
}
|
||||
|
||||
// ClientTrafficEnableMergeExpr: placeholders nodeEnable, nodeExpiry, nodeTotal,
|
||||
// now, deltaUp, deltaDown. Mirrors nodeDisableIsStale (#6228 / #4917).
|
||||
func ClientTrafficEnableMergeExpr() string {
|
||||
if IsPostgres() {
|
||||
return `CASE
|
||||
WHEN ?::boolean THEN enable::boolean
|
||||
WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT))
|
||||
AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
|
||||
AND (total <= 0 OR up + ? + down + ? < total) THEN enable::boolean
|
||||
ELSE false
|
||||
END`
|
||||
}
|
||||
return `CASE
|
||||
WHEN ? THEN enable
|
||||
WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT))
|
||||
AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
|
||||
AND (total <= 0 OR up + ? + down + ? < total) THEN enable
|
||||
ELSE 0
|
||||
END`
|
||||
}
|
||||
|
||||
// ClientTrafficExpiryMergeExpr: placeholder nodeExpiry once. Master absolute is
|
||||
// kept; CAST avoids Postgres int4 inference on ms timestamps.
|
||||
func ClientTrafficExpiryMergeExpr() string {
|
||||
return `CASE
|
||||
WHEN expiry_time > 0 THEN expiry_time
|
||||
ELSE CAST(? AS BIGINT)
|
||||
END`
|
||||
}
|
||||
|
||||
@@ -183,13 +183,12 @@ type HistoryOfSeeders struct {
|
||||
const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
|
||||
|
||||
const (
|
||||
ApiScopeAdmin = "admin"
|
||||
ApiScopeMonitor = "monitor"
|
||||
ApiScopeNodeSync = "node-sync"
|
||||
ApiScopeAdmin = "admin"
|
||||
ApiScopeMonitor = "monitor"
|
||||
)
|
||||
|
||||
func IsKnownApiScope(s string) bool {
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the release download base of the fork's own Gitea.
|
||||
// A release tag directory hangs directly below it:
|
||||
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.sha256" sidecar.
|
||||
// A release tag directory hangs directly below it, holding the panel
|
||||
// assets (<base><tag>/x-ui-linux-<arch>) and — since the agent/panel
|
||||
// split — the standalone agent assets (x-ui-agent-linux-<arch>),
|
||||
// each with a "<asset>.sha256" sidecar.
|
||||
const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/"
|
||||
|
||||
// LatestReleaseAPI is the Gitea API endpoint for the newest release of the
|
||||
@@ -41,7 +43,9 @@ func BaseURL() string {
|
||||
return DefaultBaseURL
|
||||
}
|
||||
|
||||
// AssetName maps a GOARCH to the release asset name published for it.
|
||||
// AssetName maps a GOARCH to the release asset name published for it:
|
||||
// the panel (dual-mode) binary. The panel's own native self-update
|
||||
// downloads this asset; pre-split agents consumed it too.
|
||||
func AssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
@@ -53,6 +57,24 @@ func AssetName(goarch string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAssetName maps a GOARCH to the standalone agent asset published
|
||||
// since the agent/panel split (phase 1). A split agent's one-click
|
||||
// update downloads this asset — never the panel binary — so a node
|
||||
// keeps its slim form across updates. Releases predating the split do
|
||||
// not carry these assets; an intent pointing at such a tag fails the
|
||||
// download and the agent keeps running its old binary (the designed
|
||||
// failure mode of runUpdate).
|
||||
func AgentAssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "x-ui-agent-linux-amd64", nil
|
||||
case "arm64":
|
||||
return "x-ui-agent-linux-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("no agent release asset for architecture %q", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
|
||||
// (mirrors, tests), analogous to BaseURLEnv.
|
||||
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
|
||||
|
||||
@@ -47,6 +47,18 @@ func TestAssetName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAssetName(t *testing.T) {
|
||||
if name, err := AgentAssetName("amd64"); err != nil || name != "x-ui-agent-linux-amd64" {
|
||||
t.Fatalf("amd64: %q, %v", name, err)
|
||||
}
|
||||
if name, err := AgentAssetName("arm64"); err != nil || name != "x-ui-agent-linux-arm64" {
|
||||
t.Fatalf("arm64: %q, %v", name, err)
|
||||
}
|
||||
if _, err := AgentAssetName("riscv64"); err == nil {
|
||||
t.Fatal("unknown arch must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapExecutableKeepsBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "xui-agent")
|
||||
|
||||
@@ -35,18 +35,6 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
|
||||
}
|
||||
|
||||
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||
// A verified client certificate (a completed mTLS handshake) authenticates
|
||||
// the caller, equivalent to a valid bearer token. api_authed must be set so
|
||||
// the CSRF middleware lets cert-authed mutations through.
|
||||
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
c.Set("api_authed", true)
|
||||
c.Set("api_token_scope", model.ApiScopeNodeSync)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
tok := after
|
||||
@@ -79,33 +67,7 @@ var monitorScopeAllow = map[string]struct{}{
|
||||
"/nodes/history/:id/:metric/:bucket": {},
|
||||
}
|
||||
|
||||
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
|
||||
// /panel/api; Gin patterns prevent concrete parameters broadening authority.
|
||||
var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
||||
"/server/status": {http.MethodGet: {}},
|
||||
"/inbounds/list": {http.MethodGet: {}},
|
||||
"/inbounds/add": {http.MethodPost: {}},
|
||||
"/inbounds/del/:id": {http.MethodPost: {}},
|
||||
"/inbounds/update/:id": {http.MethodPost: {}},
|
||||
"/clients/add": {http.MethodPost: {}},
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
||||
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
||||
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
||||
"/clients/onlines": {http.MethodPost: {}},
|
||||
"/clients/lastOnline": {http.MethodPost: {}},
|
||||
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
||||
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
||||
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
||||
"/hosts/list": {http.MethodGet: {}},
|
||||
}
|
||||
|
||||
// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
|
||||
// enforceTokenScope applies the explicit allowlist to monitor tokens.
|
||||
// Admin tokens and session-login users retain their existing behavior.
|
||||
func (a *APIController) enforceTokenScope(c *gin.Context) {
|
||||
scopeVal, ok := c.Get("api_token_scope")
|
||||
@@ -131,13 +93,6 @@ func (a *APIController) enforceTokenScope(c *gin.Context) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
case model.ApiScopeNodeSync:
|
||||
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
|
||||
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
deny()
|
||||
return
|
||||
|
||||
@@ -2,12 +2,10 @@ package controller
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -110,100 +108,9 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
|
||||
// as node-sync rather than bypassing scope checks as admin.
|
||||
func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
|
||||
req.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` {
|
||||
t.Fatalf("body = %s, want node-sync scope", got)
|
||||
}
|
||||
|
||||
forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
|
||||
forbidden.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, forbidden)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
|
||||
expected := map[string]map[string]struct{}{
|
||||
"/server/status": {http.MethodGet: {}},
|
||||
"/inbounds/list": {http.MethodGet: {}},
|
||||
"/inbounds/add": {http.MethodPost: {}},
|
||||
"/inbounds/del/:id": {http.MethodPost: {}},
|
||||
"/inbounds/update/:id": {http.MethodPost: {}},
|
||||
"/clients/add": {http.MethodPost: {}},
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
||||
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
||||
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
||||
"/clients/onlines": {http.MethodPost: {}},
|
||||
"/clients/lastOnline": {http.MethodPost: {}},
|
||||
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
||||
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
||||
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
||||
"/hosts/list": {http.MethodGet: {}},
|
||||
}
|
||||
if !reflect.DeepEqual(nodeSyncScopeAllow, expected) {
|
||||
t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected)
|
||||
}
|
||||
if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok {
|
||||
t.Fatal("node-sync must not include /server/updatePanel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"detach email parameter", http.MethodPost, "/panel/api/clients/alice@example.com/detach", http.StatusOK},
|
||||
{"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK},
|
||||
{"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK},
|
||||
{"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough asserts a TLS request with no
|
||||
// verified client chain is NOT treated as authenticated (it falls through to the
|
||||
// bearer/session paths) — so the cert branch can't accidentally authorize plain
|
||||
// browser HTTPS.
|
||||
// bearer/session paths) — a plain browser HTTPS connection must never authorize.
|
||||
func TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.DELETE("/hwids/:email/:id", a.deleteHwid)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||
g.POST("/clientIpsByGuid", a.clientIpsByGuid)
|
||||
g.POST("/activeInbounds", a.activeInbounds)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
}
|
||||
@@ -523,11 +522,6 @@ func (a *ClientController) getIps(c *gin.Context) {
|
||||
jsonObj(c, infos, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clientIpsByGuid(c *gin.Context) {
|
||||
data, err := a.inboundService.GetClientIpsByGuid()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clearIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
if err := a.inboundService.ClearClientIps(email); err != nil {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -84,7 +83,6 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/import", a.importInbound)
|
||||
g.POST("/:id/fallbacks", a.setFallbacks)
|
||||
g.POST("/pushClientTraffics", a.pushClientTraffics)
|
||||
}
|
||||
|
||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||
@@ -474,24 +472,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||
}
|
||||
|
||||
// pushClientTraffics receives a master panel's aggregated per-client usage
|
||||
// (see InboundService.AcceptGlobalTraffic for the storage semantics).
|
||||
func (a *InboundController) pushClientTraffics(c *gin.Context) {
|
||||
var req struct {
|
||||
MasterGuid string `json:"masterGuid"`
|
||||
Traffics []*xray.ClientTraffic `json:"traffics"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, "success", nil)
|
||||
}
|
||||
|
||||
// importInbound imports an inbound configuration from provided data.
|
||||
func (a *InboundController) importInbound(c *gin.Context) {
|
||||
inbound := &model.Inbound{}
|
||||
|
||||
+26
-207
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -31,62 +30,15 @@ func NewNodeController(g *gin.RouterGroup) *NodeController {
|
||||
func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/webCert/:id", a.webCert)
|
||||
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.del)
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
|
||||
g.POST("/test", a.test)
|
||||
g.POST("/certFingerprint", a.certFingerprint)
|
||||
g.POST("/inbounds", a.inbounds)
|
||||
g.POST("/probe/:id", a.probe)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.GET("/history/:id/:metric/:bucket", a.history)
|
||||
g.POST("/mtls/ca", a.mtlsCa)
|
||||
g.POST("/mtls/trustCA", a.setMtlsTrustCA)
|
||||
g.POST("/mtls/reloadClient", a.reloadMtlsClient)
|
||||
}
|
||||
|
||||
// reloadMtlsClient validates the credential currently stored by the master and
|
||||
// closes cached mTLS pools so subsequent node requests present the new leaf.
|
||||
func (a *NodeController) reloadMtlsClient(c *gin.Context) {
|
||||
if err := a.nodeService.ReloadMasterMtlsClient(); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil)
|
||||
}
|
||||
|
||||
// mtlsCa returns this panel's node-auth CA certificate (public) to paste into a
|
||||
// node's mTLS trust setting. It lazily mints the CA + master client cert on
|
||||
// first call.
|
||||
func (a *NodeController) mtlsCa(c *gin.Context) {
|
||||
caCert, err := a.nodeService.NodeMtlsCaCert()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"caCert": caCert}, nil)
|
||||
}
|
||||
|
||||
// setMtlsTrustCA stores the CA this panel trusts for incoming node-API client
|
||||
// certificates (this panel acting as a node). An empty value disables it.
|
||||
// Applied on the next panel restart.
|
||||
func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
|
||||
var req struct {
|
||||
CaCert string `json:"caCert" form:"caCert"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.SetNodeMtlsTrustCA(req.CaCert); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) list(c *gin.Context) {
|
||||
@@ -112,91 +64,44 @@ func (a *NodeController) get(c *gin.Context) {
|
||||
jsonObj(c, n, nil)
|
||||
}
|
||||
|
||||
// webCert returns the node's own web TLS certificate/key file paths so the
|
||||
// inbound form's "Set Cert from Panel" can fill paths that exist on the node.
|
||||
func (a *NodeController) webCert(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
files, err := a.nodeService.GetWebCertFiles(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, files, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error {
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil {
|
||||
return errors.New(service.FriendlyProbeError(err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *NodeController) add(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isAgent := n.NodeType == "agent"
|
||||
// Agent nodes have no panel to probe; they authenticate via agent token.
|
||||
if n.OutboundTag == "" && !isAgent {
|
||||
if err := a.ensureReachable(c, n, 0); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, err := a.nodeService.CreateFromRequest(n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag != "" {
|
||||
if err := a.ensureReachable(c, n, view.Id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// For agent nodes, issue an agent token bound to the new node and
|
||||
// return it so the UI can display it (shown only once).
|
||||
if isAgent {
|
||||
view.NodeType = "agent"
|
||||
nodeID := view.Id
|
||||
token, agentID, err := issueAgentToken(&nodeID, n.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
// Probe-only (V2.1.0): preset the flag on the freshly issued
|
||||
// agent row when the create form asked for it, so the very
|
||||
// first config poll already serves no core payload. A failed
|
||||
// preset only logs — the node and token are usable, and the
|
||||
// flag can still be flipped from the edit form.
|
||||
if n.ProbeOnly != nil && *n.ProbeOnly {
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ?", agentID).
|
||||
Update("probe_only", true).Error; err != nil {
|
||||
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
|
||||
} else {
|
||||
view.ProbeOnly = true
|
||||
}
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
// Every node is agent-managed: issue an agent token bound to the
|
||||
// new node and return it so the UI can display it (shown only once).
|
||||
view.NodeType = "agent"
|
||||
nodeID := view.Id
|
||||
token, agentID, err := issueAgentToken(&nodeID, n.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
|
||||
// Probe-only (V2.1.0): preset the flag on the freshly issued
|
||||
// agent row when the create form asked for it, so the very
|
||||
// first config poll already serves no core payload. A failed
|
||||
// preset only logs — the node and token are usable, and the
|
||||
// flag can still be flipped from the edit form.
|
||||
if n.ProbeOnly != nil && *n.ProbeOnly {
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ?", agentID).
|
||||
Update("probe_only", true).Error; err != nil {
|
||||
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
|
||||
} else {
|
||||
view.ProbeOnly = true
|
||||
}
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) update(c *gin.Context) {
|
||||
@@ -209,30 +114,10 @@ func (a *NodeController) update(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
old, err := a.nodeService.GetById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
// Agent nodes have no panel to probe and no panel API token; editing one
|
||||
// must skip the reachability check exactly like creation does.
|
||||
isAgent := n.NodeType == "agent" || a.nodeService.IsAgentManaged(id)
|
||||
if !isAgent && n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
|
||||
if err := a.ensureReachable(c, n, id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.nodeService.UpdateFromRequest(id, n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag != old.OutboundTag {
|
||||
if err := a.ensureReachable(c, n, id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||
}
|
||||
|
||||
@@ -262,79 +147,13 @@ func (a *NodeController) setEnable(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
n, err := a.nodeService.GetById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag != "" {
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) inbounds(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode)
|
||||
jsonObj(c, options, err)
|
||||
}
|
||||
|
||||
func (a *NodeController) test(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
var patch service.HeartbeatPatch
|
||||
if runtimeNode.OutboundTag != "" {
|
||||
patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag)
|
||||
} else {
|
||||
patch, err = a.nodeService.Probe(ctx, runtimeNode)
|
||||
}
|
||||
jsonObj(c, patch.ToUI(err == nil), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) certFingerprint(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, fp, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) probe(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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/locale"
|
||||
)
|
||||
|
||||
func newNodeCredentialTestEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
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() })
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||
c.Next()
|
||||
})
|
||||
NewNodeController(engine.Group("/panel/api/nodes"))
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
if err := database.GetDB().Create(&model.Node{
|
||||
Name: "stored-node",
|
||||
Scheme: "https",
|
||||
Address: "example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "stored-secret-token",
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} {
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") {
|
||||
t.Fatalf("%s leaked api token: %s", path, body)
|
||||
}
|
||||
if !strings.Contains(body, `"hasApiToken":true`) {
|
||||
t.Fatalf("%s did not expose credential presence: %s", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
const callback = "test:fail_node_heartbeat_update"
|
||||
errInjected := errors.New("injected heartbeat persistence failure")
|
||||
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && tx.Statement.Table == "nodes" {
|
||||
tx.AddError(errInjected)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register update callback: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Callback().Update().Remove(callback); err != nil {
|
||||
t.Errorf("remove update callback: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil))
|
||||
if !strings.Contains(w.Body.String(), `"success":false`) {
|
||||
t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), errInjected.Error()) {
|
||||
t.Fatalf("probe response omitted persistence error: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/panel/api/server/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "added-node",
|
||||
"scheme": "http",
|
||||
"address": host,
|
||||
"port": port,
|
||||
"basePath": "/",
|
||||
"apiToken": "input-secret-token",
|
||||
"enable": true,
|
||||
"allowPrivateAddress": true,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("add status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") {
|
||||
t.Fatalf("add response leaked api token: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"hasApiToken":true`) {
|
||||
t.Fatalf("add response did not expose credential presence: %s", body)
|
||||
}
|
||||
|
||||
var stored model.Node
|
||||
if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil {
|
||||
t.Fatalf("load stored node: %v", err)
|
||||
}
|
||||
if stored.ApiToken != "input-secret-token" {
|
||||
t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/panel/api/server/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
node := &model.Node{
|
||||
Name: "stored-node",
|
||||
Scheme: "http",
|
||||
Address: host,
|
||||
Port: port,
|
||||
BasePath: "/",
|
||||
ApiToken: "stored-secret-token",
|
||||
Enable: true,
|
||||
AllowPrivateAddress: true,
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "stored-node-renamed",
|
||||
"scheme": "http",
|
||||
"address": host,
|
||||
"port": port,
|
||||
"basePath": "/",
|
||||
"apiToken": "",
|
||||
"enable": true,
|
||||
"allowPrivateAddress": true,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var stored model.Node
|
||||
if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil {
|
||||
t.Fatalf("load stored node: %v", err)
|
||||
}
|
||||
if stored.ApiToken != "stored-secret-token" {
|
||||
t.Fatalf("blank update changed token to %q", stored.ApiToken)
|
||||
}
|
||||
if stored.Name != "stored-node-renamed" {
|
||||
t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,10 @@ type probeMetaForm struct {
|
||||
IPv4Pin string `json:"ipv4Pin"`
|
||||
IPv6Pin string `json:"ipv6Pin"`
|
||||
// V2.0.1: traffic calibration (GB, either sign) and the counted
|
||||
// interface pin ("" = automatic).
|
||||
// interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
|
||||
// fields are ONE-SHOT deltas: a non-zero value is folded into the
|
||||
// stored standing correction on save (see probe.MergeCalDelta);
|
||||
// zero/absent applies nothing.
|
||||
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
|
||||
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
|
||||
StatIface string `json:"statIface"`
|
||||
@@ -75,8 +78,11 @@ func metaToForm(m probe.Meta) probeMetaForm {
|
||||
CountryPin: m.CountryPin,
|
||||
IPv4Pin: m.IPv4Pin,
|
||||
IPv6Pin: m.IPv6Pin,
|
||||
TrafficCalUpGB: m.TrafficCalUpGB,
|
||||
TrafficCalDownGB: m.TrafficCalDownGB,
|
||||
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
|
||||
// boxes always start empty; the standing correction folded
|
||||
// into the stored row is never echoed back into them.
|
||||
TrafficCalUpGB: 0,
|
||||
TrafficCalDownGB: 0,
|
||||
StatIface: m.StatIface,
|
||||
}
|
||||
}
|
||||
@@ -150,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
return
|
||||
}
|
||||
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
|
||||
// Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
|
||||
// into the stored standing correction; all other fields replace.
|
||||
meta := form.toMeta(int64(agent.Id))
|
||||
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
} else if found {
|
||||
meta = probe.MergeCalDelta(existing, meta)
|
||||
}
|
||||
if err := probe.SaveMeta(meta); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,6 +252,9 @@ var anonNodeKeys = []string{
|
||||
"traffic_limit", "traffic_mode", "traffic_reset_day",
|
||||
"total_rx", "total_tx", "month_rx", "month_tx", "month_used",
|
||||
"month_start", "day_rx", "day_tx",
|
||||
// B6 rider: ipv4/ipv6 exist in the anonymous shape as stack
|
||||
// markers only ("" or "1"), never as addresses.
|
||||
"ipv4", "ipv6",
|
||||
}
|
||||
|
||||
var metricKeys = []string{
|
||||
@@ -303,7 +306,7 @@ func TestProbePublicContract(t *testing.T) {
|
||||
body := rec.Body.String()
|
||||
for _, leak := range []string{
|
||||
"secret-host", "private-remark", `"hostname"`, `"remark"`,
|
||||
`"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0",
|
||||
`"ip":`, "203.0.113.9", "198.51.100.4", "eth0",
|
||||
} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("anonymous frame leaks %q: %s", leak, body)
|
||||
@@ -323,7 +326,15 @@ func TestProbePublicContract(t *testing.T) {
|
||||
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
|
||||
var node map[string]any
|
||||
_ = json.Unmarshal(nodeBody, &node)
|
||||
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
|
||||
// B6 rider: the seed node has a v4 stack and no v6 stack, so the
|
||||
// anonymous stack markers are exactly "1" and "" — never addresses
|
||||
// (the real addresses stay in the leak list above).
|
||||
if node["ipv4"] != "1" || node["ipv6"] != "" {
|
||||
t.Fatalf("anon stack markers = %v/%v, want \"1\"/\"\"", node["ipv4"], node["ipv6"])
|
||||
}
|
||||
// V2.2.1 ⑦: the public view's name is the public remark when one
|
||||
// is set (the seed sets "shown publicly"), not the node name.
|
||||
if node["id"] != float64(pubID) || node["name"] != "shown publicly" || node["online"] != true ||
|
||||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
|
||||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
|
||||
t.Fatalf("anon node values wrong: %v", node)
|
||||
@@ -599,3 +610,61 @@ func TestProbeWebSocket(t *testing.T) {
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
// Q7: the resolved country reaches both frames. The seed's held
|
||||
// address is a documentation range (unlisted in any country DB), so
|
||||
// repoint it at a real public address whose country the embedded
|
||||
// DB-IP snapshot knows — 8.8.8.8 is US there.
|
||||
func TestProbePublicCountryFlag(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
pubID, _ := rig.seedAgent(t, "FlagNode", true)
|
||||
|
||||
db := database.GetDB()
|
||||
var agent model.AgentNode
|
||||
if err := db.First(&agent, pubID).Error; err != nil {
|
||||
t.Fatalf("load seeded agent: %v", err)
|
||||
}
|
||||
agent.IPv4 = "8.8.8.8"
|
||||
if err := db.Save(&agent).Error; err != nil {
|
||||
t.Fatalf("repoint agent address: %v", err)
|
||||
}
|
||||
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
find := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var frame struct {
|
||||
Nodes []map[string]any `json:"nodes"`
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
|
||||
t.Fatalf("frame decode: %v", err)
|
||||
}
|
||||
for _, n := range frame.Nodes {
|
||||
if n["id"] == float64(pubID) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("seeded node missing from frame: %s", rec.Body.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
rec := rig.do(http.MethodGet, "/api/nodes", nil, false)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes anon: %d", rec.Code)
|
||||
}
|
||||
if got := find(rec)["country"]; got != "US" {
|
||||
t.Fatalf("anonymous country = %v, want US", got)
|
||||
}
|
||||
|
||||
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes admin: %d", rec.Code)
|
||||
}
|
||||
admin := find(rec)
|
||||
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||
t.Fatalf("admin country triple = (%v, %v, %v), want (US, US, empty)",
|
||||
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
@@ -43,17 +42,13 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/history/:metric/:bucket", a.getOverviewHistoryBucket)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
g.GET("/getDb", a.getDb)
|
||||
g.GET("/getMigration", a.getMigration)
|
||||
g.GET("/getNewUUID", a.getNewUUID)
|
||||
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
||||
g.GET("/descendants", a.descendants)
|
||||
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
||||
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
||||
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
||||
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
|
||||
g.GET("/clientIps", a.getClientIps)
|
||||
g.GET("/fail2banStatus", a.getFail2banStatus)
|
||||
g.GET("/outboundDirectory", a.getOutboundDirectory)
|
||||
|
||||
@@ -65,7 +60,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/getRemoteCertHash", a.getRemoteCertHash)
|
||||
g.POST("/scanRealityTarget", a.scanRealityTarget)
|
||||
g.POST("/scanRealityTargets", a.scanRealityTargets)
|
||||
g.POST("/clientIps", a.setClientIps)
|
||||
}
|
||||
|
||||
// startTask registers the @2s ticker that refreshes server status and pushes
|
||||
@@ -164,16 +158,6 @@ func (a *ServerController) setUpdateChannel(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
|
||||
}
|
||||
|
||||
// getConfigJson retrieves the Xray configuration as JSON.
|
||||
func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||
configJson, err := a.serverService.GetConfigJson()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getConfigError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, configJson, nil)
|
||||
}
|
||||
|
||||
// getDb downloads the database file.
|
||||
func (a *ServerController) getDb(c *gin.Context) {
|
||||
db, err := a.serverService.GetDb()
|
||||
@@ -230,32 +214,6 @@ func (a *ServerController) importDB(c *gin.Context) {
|
||||
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
||||
}
|
||||
|
||||
// descendants publishes read-only summaries of the nodes this panel manages so
|
||||
// a parent panel can surface them as transitive sub-nodes in a chained
|
||||
// topology. Called by the parent via the node's API token (#4983).
|
||||
func (a *ServerController) descendants(c *gin.Context) {
|
||||
data, err := (&service.NodeService{}).LocalDescendants()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
// getWebCertFiles returns this panel's own web TLS certificate and key file
|
||||
// paths. The central panel calls it on a node (via the node's API token) so
|
||||
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
|
||||
// on the node's filesystem instead of the central panel's — see issue #4854.
|
||||
func (a *ServerController) getWebCertFiles(c *gin.Context) {
|
||||
certFile, err := a.settingService.GetCertFile()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
keyFile, err := a.settingService.GetKeyFile()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil)
|
||||
}
|
||||
|
||||
// getNewX25519Cert generates a new X25519 certificate.
|
||||
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
||||
cert, err := a.serverService.GetNewX25519Cert()
|
||||
@@ -419,18 +377,3 @@ func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getClientIps(c *gin.Context) {
|
||||
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
|
||||
jsonObj(c, ips, err)
|
||||
}
|
||||
|
||||
func (a *ServerController) setClientIps(c *gin.Context) {
|
||||
var ips []model.InboundClientIps
|
||||
if err := c.ShouldBindJSON(&ips); err != nil {
|
||||
jsonMsg(c, "invalid data", err)
|
||||
return
|
||||
}
|
||||
err := (&service.InboundService{}).MergeInboundClientIps(ips)
|
||||
jsonMsg(c, "Client IPs merged", err)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeNodeSync}
|
||||
row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeMonitor}
|
||||
if err := database.GetDB().Create(row).Error; err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
@@ -86,4 +86,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
|
||||
t.Fatal("token was disabled by wrong scope")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ type AllSetting struct {
|
||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
|
||||
// TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic
|
||||
// accounting switches as one JSON string
|
||||
// ({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// GeoIPCountryJob is the weekly refresh of the local country
|
||||
// database behind the public pages' node flags (Q7: a local MMDB on
|
||||
// the master, never an external per-node lookup). The service does
|
||||
// the conditional download, validation and hot swap; with no update
|
||||
// URL configured the pass is a no-op, so scheduling it is harmless
|
||||
// on installs that never opted in.
|
||||
type GeoIPCountryJob struct{}
|
||||
|
||||
// NewGeoIPCountryJob creates a geoip country job instance.
|
||||
func NewGeoIPCountryJob() *GeoIPCountryJob {
|
||||
return new(GeoIPCountryJob)
|
||||
}
|
||||
|
||||
// Run executes one update pass.
|
||||
func (j *GeoIPCountryJob) Run() {
|
||||
service.UpdateGeoIPCountry()
|
||||
}
|
||||
@@ -100,27 +100,3 @@ func TestProbeOneLeavesUnactivatedAgentNodeAlone(t *testing.T) {
|
||||
t.Fatalf("unactivated agent node status = %q, want unknown (untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOnePanelNodeStillProbed(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
db := database.GetDB()
|
||||
// No agent bound: the legacy probe path must still run and mark an
|
||||
// unreachable panel node offline.
|
||||
n := &model.Node{
|
||||
Name: "panel-node",
|
||||
Scheme: "http",
|
||||
Address: "127.0.0.1",
|
||||
Port: 1, // nothing listens here; probe fails fast
|
||||
Enable: true,
|
||||
Status: "online",
|
||||
}
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
(&NodeHeartbeatJob{}).probeOne(n)
|
||||
|
||||
if got := nodeStatus(t, n.Id); got != "offline" {
|
||||
t.Fatalf("unreachable panel node status = %q, want offline", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -80,40 +79,13 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
}
|
||||
|
||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
// Agent-managed nodes are heartbeated via the agent token, not the
|
||||
// legacy panel probe. Skip the probe so we don't clobber the
|
||||
// agent-driven online status — but do expire that status when the
|
||||
// agent stops heartbeating, or an uninstalled agent would leave its
|
||||
// node showing online forever.
|
||||
var agentCount int64
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error
|
||||
if agentCount > 0 {
|
||||
j.expireStaleAgentNode(n)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
defer cancel()
|
||||
prevStatus := n.Status
|
||||
patch, err := j.nodeService.Probe(ctx, n)
|
||||
if err != nil {
|
||||
patch.Status = "offline"
|
||||
} else {
|
||||
patch.Status = "online"
|
||||
}
|
||||
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
|
||||
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
|
||||
}
|
||||
publishNodeTransition(n, prevStatus, patch)
|
||||
// Learn the nodes this node manages so the panel can surface them as
|
||||
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
|
||||
// be spent. Drop them when the node is unreachable.
|
||||
if patch.Status == "online" {
|
||||
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
j.nodeService.RefreshDescendants(dctx, n)
|
||||
dcancel()
|
||||
} else {
|
||||
j.nodeService.ClearDescendants(n.Id)
|
||||
}
|
||||
// Every node is agent-managed: it is heartbeated via the agent
|
||||
// token, never dialed. The job's only duty is to expire the
|
||||
// agent-driven online status when the agent stops heartbeating,
|
||||
// or an uninstalled agent would leave its node showing online
|
||||
// forever. A node with no bound agent never checked in and is
|
||||
// left alone.
|
||||
j.expireStaleAgentNode(n)
|
||||
}
|
||||
|
||||
// agentLastSeenMs returns the newest last_seen (Unix milliseconds, the unit
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,16 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const (
|
||||
nodeTrafficSyncConcurrency = 8
|
||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||
nodeReconcileTimeout = 30 * time.Second
|
||||
nodeClientIpSyncInterval = 10 * time.Second
|
||||
nodeClientIpSyncTimeout = 6 * time.Second
|
||||
nodeGlobalPushInterval = 30 * time.Second
|
||||
// nodeInboundSpeedWindowMs is the poll window node-inbound speed deltas are
|
||||
// normalized to; it MUST match the dashboard's TRAFFIC_POLL_INTERVAL_S (5s),
|
||||
// the fixed divisor the frontend applies to turn a delta into a rate.
|
||||
@@ -34,20 +23,16 @@ type inboundSample struct {
|
||||
up, down, at int64
|
||||
}
|
||||
|
||||
// NodeTrafficSyncJob is the fleet-wide traffic tick. In the agent era
|
||||
// node traffic and online state arrive via agent reports, so there is
|
||||
// nothing to pull per node; what remains is the fleet-wide duty set:
|
||||
// the client depletion check, pruning the local online sets, deriving
|
||||
// per-node-inbound speeds from the central counters, and the websocket
|
||||
// broadcasts the dashboard consumes.
|
||||
type NodeTrafficSyncJob struct {
|
||||
nodeService service.NodeService
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
xrayService service.XrayService
|
||||
running sync.Mutex
|
||||
structural atomicBool
|
||||
ipSyncMu sync.Mutex
|
||||
lastIpSync int64
|
||||
globalPushMu sync.Mutex
|
||||
lastGlobalPush int64
|
||||
// noGuidIpEndpoint tracks nodes (by id) whose client-IP attribution endpoint
|
||||
// returned 404, so an old-build node is noted once instead of every cycle.
|
||||
noGuidIpEndpoint sync.Map
|
||||
// prevInboundTotals holds the previous poll's cumulative up/down (and the time
|
||||
// the counter last changed) per node inbound tag, so the next poll can derive
|
||||
// a per-inbound speed delta — node inbounds have no local Xray poll. Touched
|
||||
@@ -84,53 +69,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
}
|
||||
defer j.running.Unlock()
|
||||
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := j.nodeService.GetAll()
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: load nodes failed:", err)
|
||||
return
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Decide once per tick whether this run also syncs client IPs, and stamp the
|
||||
// clock before the loop so two back-to-back 5s ticks can't both qualify.
|
||||
doIpSync := false
|
||||
j.ipSyncMu.Lock()
|
||||
if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
|
||||
doIpSync = true
|
||||
j.lastIpSync = now
|
||||
}
|
||||
j.ipSyncMu.Unlock()
|
||||
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var activeMu sync.Mutex
|
||||
var activeEmails []string
|
||||
for _, n := range nodes {
|
||||
if !n.Enable || n.Status != "online" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
n := n
|
||||
common.GoRecover("node-traffic-sync:"+n.Name, func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 {
|
||||
activeMu.Lock()
|
||||
activeEmails = append(activeEmails, emails...)
|
||||
activeMu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: depletion check failed:", err)
|
||||
@@ -139,8 +77,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
j.structural.set()
|
||||
}
|
||||
|
||||
j.maybePushGlobals(mgr, nodes)
|
||||
|
||||
// Prune stale local-online entries (no local active emails or inbound tags
|
||||
// to add here — only the local xray poll feeds those) so a stopped local
|
||||
// xray's clients and inbounds still age out between traffic polls.
|
||||
@@ -157,6 +93,10 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
// Same snapshot-vs-delta split as the local traffic job: above the
|
||||
// threshold a full snapshot would be dropped by the hub's payload cap, so
|
||||
// send only the rows for clients online on the synced nodes this tick.
|
||||
// Agent reports file online state into the online store rather than
|
||||
// this tick's return, so the delta path keys off an empty active set
|
||||
// exactly as it did while every node was agent-managed.
|
||||
var activeEmails []string
|
||||
snapshot := true
|
||||
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
|
||||
logger.Warning("node traffic sync: count client traffics failed:", countErr)
|
||||
@@ -284,213 +224,3 @@ func (j *NodeTrafficSyncJob) nodeInboundSpeed() []*xray.Traffic {
|
||||
j.prevInboundTotals = next
|
||||
return deltas
|
||||
}
|
||||
|
||||
// maybePushGlobals broadcasts this panel's aggregated per-client usage to its
|
||||
// online nodes so each node can display the client's cross-panel total and
|
||||
// enforce its quota locally (see InboundService.AcceptGlobalTraffic). Scoped
|
||||
// per node to the clients that node actually hosts, and throttled — the
|
||||
// aggregates only need to reach nodes on a human timescale, not every poll.
|
||||
func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*model.Node) {
|
||||
j.globalPushMu.Lock()
|
||||
now := time.Now().Unix()
|
||||
if now-j.lastGlobalPush < int64(nodeGlobalPushInterval/time.Second) {
|
||||
j.globalPushMu.Unlock()
|
||||
return
|
||||
}
|
||||
j.lastGlobalPush = now
|
||||
j.globalPushMu.Unlock()
|
||||
|
||||
masterGuid, err := j.settingService.GetPanelGuid()
|
||||
if err != nil || masterGuid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range nodes {
|
||||
if !n.Enable || n.Status != "online" {
|
||||
continue
|
||||
}
|
||||
remote, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
traffics, err := j.inboundService.GetNodeClientTraffics(n.Id)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: load globals for %s failed: %v", n.Name, err)
|
||||
continue
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
n, remote, traffics := n, remote, traffics
|
||||
common.GoRecover("node-global-push:"+n.Name, func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
if err := remote.PushGlobalClientTraffics(ctx, masterGuid, traffics); err != nil {
|
||||
// An old-build node without the endpoint answers 404 — not worth a
|
||||
// warning every cycle.
|
||||
if strings.Contains(err.Error(), "HTTP 404") {
|
||||
logger.Debugf("node traffic sync: node %s has no global-traffic endpoint (old build)", n.Name)
|
||||
} else {
|
||||
logger.Warningf("node traffic sync: push globals to %s failed: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// syncOne pulls one node's traffic snapshot and merges it. It returns the
|
||||
// emails online on that node this tick, feeding the delta broadcast above the
|
||||
// snapshot threshold; nil on any failure path.
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
justPushed := false
|
||||
if n.ConfigDirty {
|
||||
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
|
||||
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
|
||||
reconcileCancel()
|
||||
if reconcileErr != nil {
|
||||
// The dirty flag stays set so reconcile retries next tick, but traffic
|
||||
// accounting must keep flowing: one rejected inbound used to starve the
|
||||
// whole node's traffic/online sync forever (#5685).
|
||||
logger.Warningf("node traffic sync: reconcile for %s failed, continuing with traffic pull: %v", n.Name, reconcileErr)
|
||||
} else {
|
||||
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
|
||||
logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
|
||||
}
|
||||
j.structural.set()
|
||||
// The snapshot below may still predate the push we just made, so its
|
||||
// lagging lifecycle values must not merge back this tick (#6228).
|
||||
justPushed = true
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
snap, err := rt.FetchTrafficSnapshot(ctx)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
|
||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||
return nil
|
||||
}
|
||||
snap.ManagedAliases = rt.AdoptedInboundAliases()
|
||||
syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases)
|
||||
service.FilterNodeSnapshot(n, snap)
|
||||
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||
if !dirty {
|
||||
if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
|
||||
logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
|
||||
} else if pending {
|
||||
hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
groups, hgErr := rt.FetchHostGroups(hostCtx)
|
||||
hostCancel()
|
||||
if hgErr != nil {
|
||||
logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
|
||||
} else {
|
||||
snap.HostGroups = groups
|
||||
}
|
||||
}
|
||||
}
|
||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty, justPushed)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
|
||||
return nil
|
||||
}
|
||||
if changed {
|
||||
j.structural.set()
|
||||
}
|
||||
if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt {
|
||||
if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
|
||||
logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
|
||||
}
|
||||
}
|
||||
|
||||
active := make([]string, 0, len(snap.OnlineEmails))
|
||||
active = append(active, snap.OnlineEmails...)
|
||||
for _, emails := range snap.OnlineTree {
|
||||
active = append(active, emails...)
|
||||
}
|
||||
|
||||
if !doIpSync {
|
||||
return active
|
||||
}
|
||||
|
||||
ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
|
||||
defer ipCancel()
|
||||
|
||||
nodeIps, err := rt.FetchAllClientIps(ipCtx)
|
||||
if err == nil && len(nodeIps) > 0 {
|
||||
if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
|
||||
logger.Warningf("node traffic sync: merge client ips from %s failed: %v", n.Name, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err)
|
||||
}
|
||||
|
||||
masterIps, err := j.inboundService.GetAllInboundClientIps()
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
|
||||
return active
|
||||
}
|
||||
if len(masterIps) > 0 {
|
||||
if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
|
||||
logger.Warningf("node traffic sync: push client ips to %s failed: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-node IP attribution: pull the node's guid-keyed subtree (its own
|
||||
// observations plus any descendants) so the master can tell which node each
|
||||
// IP is on. Old nodes without the endpoint return HTTP 404 every cycle — note
|
||||
// it once per node (re-armed on recovery) instead of flooding the log.
|
||||
if guidTrees, err := rt.FetchClientIpsByGuid(ipCtx); err != nil {
|
||||
if strings.Contains(err.Error(), "HTTP 404") {
|
||||
if _, seen := j.noGuidIpEndpoint.LoadOrStore(n.Id, true); !seen {
|
||||
logger.Debugf("node traffic sync: node %s has no client-IP attribution endpoint (old build)", n.Name)
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err)
|
||||
}
|
||||
} else {
|
||||
j.noGuidIpEndpoint.Delete(n.Id)
|
||||
if len(guidTrees) > 0 {
|
||||
if err := j.inboundService.MergeClientIpsByGuid(n, guidTrees); err != nil {
|
||||
logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
// Whether this sync can perform the "first clean adoption" that
|
||||
// InboundsAdoptedAt records (#6283).
|
||||
func syncCanAdoptInbounds(n *model.Node, adoptedAliases []string) bool {
|
||||
if n == nil || n.InboundSyncMode != "selected" {
|
||||
return true
|
||||
}
|
||||
return len(n.InboundTags) > 0 || len(adoptedAliases) > 0
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package job
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestAtomicBool_DefaultIsFalse(t *testing.T) {
|
||||
@@ -69,44 +67,3 @@ func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
|
||||
t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression (#6283): a node onboarded in selected mode with an empty tag
|
||||
// list empties its snapshot via FilterNodeSnapshot before the merge sees it,
|
||||
// so that sync adopts nothing and must not stamp InboundsAdoptedAt.
|
||||
func TestSyncCanAdoptInbounds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
node *model.Node
|
||||
aliases []string
|
||||
expected bool
|
||||
}{
|
||||
{"all mode always adopts", &model.Node{InboundSyncMode: "all"}, nil, true},
|
||||
{
|
||||
"selected with tags adopts",
|
||||
&model.Node{InboundSyncMode: "selected", InboundTags: []string{"in-443-tcp"}},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"selected empty with adopted alias adopts",
|
||||
&model.Node{InboundSyncMode: "selected"},
|
||||
[]string{"in-443-tcp"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
// The reported bug: registering in selected mode and choosing
|
||||
// tags afterwards stamped adoption while adopting nothing.
|
||||
"selected empty with no aliases adopts nothing",
|
||||
&model.Node{InboundSyncMode: "selected"},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := syncCanAdoptInbounds(c.node, c.aliases); got != c.expected {
|
||||
t.Fatalf("syncCanAdoptInbounds(%+v, %v) = %v, want %v", c.node, c.aliases, got, c.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only
|
||||
// soft-orphaned, once enough clean merges have agreed they are really gone.
|
||||
type ReapSyncOrphansJob struct {
|
||||
clientService service.ClientService
|
||||
}
|
||||
|
||||
func NewReapSyncOrphansJob() *ReapSyncOrphansJob {
|
||||
return new(ReapSyncOrphansJob)
|
||||
}
|
||||
|
||||
func (j *ReapSyncOrphansJob) Run() {
|
||||
reaped, err := j.clientService.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
logger.Warning("reap sync orphans failed:", err)
|
||||
return
|
||||
}
|
||||
if reaped > 0 {
|
||||
logger.Infof("reap sync orphans: removed %d client(s)", reaped)
|
||||
}
|
||||
}
|
||||
+19
-136
@@ -1,37 +1,32 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
type NodeEgressResolver interface {
|
||||
NodeEgressProxyURL(nodeID int) string
|
||||
}
|
||||
|
||||
// Manager dispatches inbound/client writes to a Runtime. In the lite
|
||||
// master every node is agent-managed and the master runs no local core,
|
||||
// so the only production runtime is the no-op Local: writes land in the
|
||||
// database and agent nodes converge by pulling their config. The
|
||||
// override seams remain for tests that exercise the dispatch paths with
|
||||
// a stub runtime.
|
||||
type Manager struct {
|
||||
local Runtime
|
||||
|
||||
mu sync.RWMutex
|
||||
remotes map[int]*Remote
|
||||
overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub
|
||||
localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub
|
||||
egressResolver NodeEgressResolver
|
||||
mu sync.RWMutex
|
||||
overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub
|
||||
localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
local: NewLocal(),
|
||||
remotes: make(map[int]*Remote),
|
||||
local: NewLocal(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of building a
|
||||
// real Remote. Test seam for exercising node-dispatch paths without a network
|
||||
// node; pass nil rt to clear.
|
||||
// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of the
|
||||
// local runtime. Test seam for exercising node-dispatch paths without a
|
||||
// network node; pass nil rt to clear.
|
||||
func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -45,144 +40,32 @@ func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
|
||||
m.overrides[nodeID] = rt
|
||||
}
|
||||
|
||||
// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the real
|
||||
// local runtime. Test seam for exercising the local dispatch path (MTProto
|
||||
// sidecar, local Xray) without a running child process; pass nil rt to clear.
|
||||
// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the
|
||||
// real local runtime. Test seam for exercising the local dispatch path
|
||||
// without a running child process; pass nil rt to clear.
|
||||
func (m *Manager) SetLocalRuntimeOverride(rt Runtime) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.localOverride = rt
|
||||
}
|
||||
|
||||
func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.egressResolver = r
|
||||
}
|
||||
|
||||
func (m *Manager) NodeEgressProxyURL(nodeID int) string {
|
||||
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.egressResolver == nil {
|
||||
return ""
|
||||
}
|
||||
return m.egressResolver.NodeEgressProxyURL(nodeID)
|
||||
}
|
||||
|
||||
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
|
||||
if nodeID == nil {
|
||||
m.mu.RLock()
|
||||
if m.localOverride != nil {
|
||||
rt := m.localOverride
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
return m.localOverride, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
return m.local, nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
if rt, ok := m.overrides[*nodeID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
if rt, ok := m.remotes[*nodeID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if rt, ok := m.remotes[*nodeID]; ok {
|
||||
return rt, nil
|
||||
}
|
||||
n, err := loadNode(*nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !n.Enable {
|
||||
return nil, errors.New("node " + n.Name + " is disabled")
|
||||
}
|
||||
rt := NewRemote(n, m.egressResolver)
|
||||
m.remotes[*nodeID] = rt
|
||||
return rt, nil
|
||||
return m.local, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Local() Runtime { return m.local }
|
||||
|
||||
func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
|
||||
if node == nil {
|
||||
return nil, errors.New("node is nil")
|
||||
}
|
||||
m.mu.RLock()
|
||||
if rt, ok := m.remotes[node.Id]; ok {
|
||||
if sameRemoteIdentity(rt.node, node) {
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
} else {
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if rt, ok := m.remotes[node.Id]; ok {
|
||||
if sameRemoteIdentity(rt.node, node) {
|
||||
return rt, nil
|
||||
}
|
||||
} else {
|
||||
rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
|
||||
m.remotes[node.Id] = rt
|
||||
return rt, nil
|
||||
}
|
||||
rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
|
||||
m.remotes[node.Id] = rt
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
func cloneRemoteNode(n *model.Node) *model.Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *n
|
||||
if n.InboundTags != nil {
|
||||
clone.InboundTags = append([]string(nil), n.InboundTags...)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func sameRemoteIdentity(a, b *model.Node) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return a.Id == b.Id &&
|
||||
a.Scheme == b.Scheme &&
|
||||
a.Address == b.Address &&
|
||||
a.Port == b.Port &&
|
||||
a.BasePath == b.BasePath &&
|
||||
a.ApiToken == b.ApiToken &&
|
||||
a.AllowPrivateAddress == b.AllowPrivateAddress &&
|
||||
a.TlsVerifyMode == b.TlsVerifyMode &&
|
||||
a.PinnedCertSha256 == b.PinnedCertSha256 &&
|
||||
a.OutboundTag == b.OutboundTag
|
||||
}
|
||||
|
||||
func (m *Manager) InvalidateNode(nodeID int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.remotes, nodeID)
|
||||
}
|
||||
|
||||
func loadNode(id int) (*model.Node, error) {
|
||||
db := database.GetDB()
|
||||
n := &model.Node{}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var (
|
||||
managerMu sync.RWMutex
|
||||
manager *Manager
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) {
|
||||
m := NewManager()
|
||||
first, err := m.RemoteFor(&model.Node{
|
||||
Id: 1,
|
||||
Name: "node",
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "old-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first RemoteFor: %v", err)
|
||||
}
|
||||
second, err := m.RemoteFor(&model.Node{
|
||||
Id: 1,
|
||||
Name: "node",
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "new-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteFor: %v", err)
|
||||
}
|
||||
if second == first {
|
||||
t.Fatal("RemoteFor reused stale Remote after ApiToken changed")
|
||||
}
|
||||
if got := second.node.ApiToken; got != "new-token" {
|
||||
t.Fatalf("cached Remote token = %q, want new-token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRemoteForIdentityFields(t *testing.T) {
|
||||
base := model.Node{
|
||||
Id: 7,
|
||||
Name: "node-a",
|
||||
Remark: "old remark",
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "token",
|
||||
AllowPrivateAddress: true,
|
||||
TlsVerifyMode: "pin",
|
||||
PinnedCertSha256: "sha",
|
||||
OutboundTag: "warp",
|
||||
Status: "online",
|
||||
InboundCount: 1,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*model.Node)
|
||||
refresh bool
|
||||
}{
|
||||
{"same", func(*model.Node) {}, false},
|
||||
{"name does not churn", func(n *model.Node) { n.Name = "renamed" }, false},
|
||||
{"remark does not churn", func(n *model.Node) { n.Remark = "new remark" }, false},
|
||||
{"status does not churn", func(n *model.Node) { n.Status = "offline" }, false},
|
||||
{"metrics do not churn", func(n *model.Node) { n.InboundCount = 99 }, false},
|
||||
{"scheme", func(n *model.Node) { n.Scheme = "http" }, true},
|
||||
{"address", func(n *model.Node) { n.Address = "other.example.com" }, true},
|
||||
{"port", func(n *model.Node) { n.Port = 8443 }, true},
|
||||
{"base path", func(n *model.Node) { n.BasePath = "/x/" }, true},
|
||||
{"api token", func(n *model.Node) { n.ApiToken = "next" }, true},
|
||||
{"allow private", func(n *model.Node) { n.AllowPrivateAddress = false }, true},
|
||||
{"tls verify mode", func(n *model.Node) { n.TlsVerifyMode = "skip" }, true},
|
||||
{"pin", func(n *model.Node) { n.PinnedCertSha256 = "other" }, true},
|
||||
{"outbound tag", func(n *model.Node) { n.OutboundTag = "direct" }, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := NewManager()
|
||||
firstNode := base
|
||||
first, err := m.RemoteFor(&firstNode)
|
||||
if err != nil {
|
||||
t.Fatalf("first RemoteFor: %v", err)
|
||||
}
|
||||
|
||||
nextNode := base
|
||||
tc.mutate(&nextNode)
|
||||
second, err := m.RemoteFor(&nextNode)
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteFor: %v", err)
|
||||
}
|
||||
if gotRefresh := second != first; gotRefresh != tc.refresh {
|
||||
t.Fatalf("refresh = %v, want %v", gotRefresh, tc.refresh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRemoteForClonesInputNode(t *testing.T) {
|
||||
m := NewManager()
|
||||
n := &model.Node{
|
||||
Id: 9,
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "original",
|
||||
}
|
||||
rt, err := m.RemoteFor(n)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteFor: %v", err)
|
||||
}
|
||||
n.ApiToken = "mutated-after-cache"
|
||||
if got := rt.node.ApiToken; got != "original" {
|
||||
t.Fatalf("cached Remote observed caller mutation: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestReconcileInbound_SkipsUnchanged proves the delta-skip: a second reconcile
|
||||
// of an unchanged inbound that the node still reports sends no push, while a
|
||||
// content change or an absent-on-node inbound forces a fresh push.
|
||||
func TestReconcileInbound_SkipsUnchanged(t *testing.T) {
|
||||
var pushes atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") {
|
||||
pushes.Add(1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-1", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
// Pre-seed the tag→id cache so resolveRemoteID needs no network round-trip.
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
// First reconcile: node doesn't report it yet → must push and record the fp.
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||
t.Fatalf("first reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 1 {
|
||||
t.Fatalf("after first reconcile pushes=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Second reconcile: unchanged and present on node → skip.
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
|
||||
t.Fatalf("second reconcile: pushed=%v err=%v, want skip", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 1 {
|
||||
t.Fatalf("unchanged reconcile pushed again: pushes=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Content change → push again even though it's present on node.
|
||||
ib.Settings = `{"clients":[{"email":"a@x"}]}`
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("changed reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 2 {
|
||||
t.Fatalf("changed reconcile pushes=%d, want 2", got)
|
||||
}
|
||||
|
||||
// Absent on node (e.g. node restarted/lost it) → re-push even if fp matches.
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||
t.Fatalf("absent-on-node reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 3 {
|
||||
t.Fatalf("absent-on-node reconcile pushes=%d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
type nodeCallCounts struct {
|
||||
adds atomic.Int32
|
||||
inboundUpdates atomic.Int32
|
||||
clientMutations atomic.Int32
|
||||
}
|
||||
|
||||
func newCountingNodeServer(t *testing.T, clientsResp string) (*httptest.Server, *nodeCallCounts) {
|
||||
t.Helper()
|
||||
counts := &nodeCallCounts{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/panel/api/inbounds/add"):
|
||||
counts.adds.Add(1)
|
||||
case strings.Contains(r.URL.Path, "/panel/api/inbounds/update/"):
|
||||
counts.inboundUpdates.Add(1)
|
||||
case strings.Contains(r.URL.Path, "/panel/api/clients/"):
|
||||
counts.clientMutations.Add(1)
|
||||
_, _ = w.Write([]byte(clientsResp))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, counts
|
||||
}
|
||||
|
||||
func perClientMutationCases(client model.Client) []struct {
|
||||
name string
|
||||
run func(*Remote, *model.Inbound) error
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
run func(*Remote, *model.Inbound) error
|
||||
}{
|
||||
{"add", func(r *Remote, ib *model.Inbound) error {
|
||||
return r.AddClient(context.Background(), ib, client)
|
||||
}},
|
||||
{"delete", func(r *Remote, ib *model.Inbound) error {
|
||||
return r.DeleteUser(context.Background(), ib, client.Email)
|
||||
}},
|
||||
{"update", func(r *Remote, ib *model.Inbound) error {
|
||||
return r.UpdateUser(context.Background(), ib, client.Email, client)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerClientMutationsAloneDoNotSeedReconcileFingerprint: a per-client RPC
|
||||
// only proves one client's slice converged, so without an explicit advance the
|
||||
// dirty-reconcile backup must still send the full inbound.
|
||||
func TestPerClientMutationsAloneDoNotSeedReconcileFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
client := model.Client{ID: "11111111-1111-1111-1111-111111111111", Email: "a@x", SubID: "s", Enable: true}
|
||||
for _, tt := range perClientMutationCases(client) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
counts.inboundUpdates.Store(0)
|
||||
counts.clientMutations.Store(0)
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-" + tt.name, Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"id":"11111111-1111-1111-1111-111111111111","email":"a@x","subId":"s","enable":true}]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
if err := tt.run(r, ib); err != nil {
|
||||
t.Fatalf("%s client mutation: %v", tt.name, err)
|
||||
}
|
||||
if got := counts.clientMutations.Load(); got != 1 {
|
||||
t.Fatalf("%s client mutation requests=%d, want 1", tt.name, got)
|
||||
}
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("%s reconcile after unadvanced mutation: pushed=%v err=%v, want full push", tt.name, pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 1 {
|
||||
t.Fatalf("%s reconcile sent %d full inbound updates, want 1", tt.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvancePushedInboundEnablesReconcileSkip: when the node provably held the
|
||||
// pre-edit payload and every per-client push succeeded, advancing the
|
||||
// fingerprint lets the next reconcile skip the redundant full push.
|
||||
func TestAdvancePushedInboundEnablesReconcileSkip(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
client := model.Client{ID: "11111111-1111-1111-1111-111111111111", Email: "a@x", SubID: "s", Enable: true}
|
||||
prevByOp := map[string]string{
|
||||
"add": `{"clients":[]}`,
|
||||
"delete": `{"clients":[{"email":"a@x","enable":true}]}`,
|
||||
"update": `{"clients":[{"email":"a@x","enable":false}]}`,
|
||||
}
|
||||
newByOp := map[string]string{
|
||||
"add": `{"clients":[{"email":"a@x","enable":true}]}`,
|
||||
"delete": `{"clients":[]}`,
|
||||
"update": `{"clients":[{"email":"a@x","enable":true}]}`,
|
||||
}
|
||||
for _, tt := range perClientMutationCases(client) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
counts.inboundUpdates.Store(0)
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
prevIb := &model.Inbound{Tag: "in-adv-" + tt.name, Protocol: model.VLESS, Port: 443, Settings: prevByOp[tt.name]}
|
||||
ib := &model.Inbound{Tag: prevIb.Tag, Protocol: model.VLESS, Port: 443, Settings: newByOp[tt.name]}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
r.recordPushedInbound(prevIb)
|
||||
|
||||
if err := tt.run(r, ib); err != nil {
|
||||
t.Fatalf("%s client mutation: %v", tt.name, err)
|
||||
}
|
||||
r.AdvancePushedInbound(prevIb, ib)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
|
||||
t.Fatalf("%s reconcile after advance: pushed=%v err=%v, want skip", tt.name, pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 0 {
|
||||
t.Fatalf("%s reconcile sent %d full inbound updates, want 0", tt.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvancePushedInboundRequiresMatchingPreviousFingerprint: if changes were
|
||||
// folded to dirty (or an earlier push failed), the recorded fingerprint no
|
||||
// longer matches the pre-edit payload; a later successful client push must not
|
||||
// mask the pending reconcile.
|
||||
func TestAdvancePushedInboundRequiresMatchingPreviousFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
staleIb := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"folded@x"}]}`}
|
||||
prevIb := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
ib := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"a@x"}]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
r.recordPushedInbound(staleIb)
|
||||
|
||||
if err := r.UpdateUser(context.Background(), ib, "a@x", model.Client{Email: "a@x"}); err != nil {
|
||||
t.Fatalf("client mutation: %v", err)
|
||||
}
|
||||
r.AdvancePushedInbound(prevIb, ib)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("reconcile with unproven pre-state: pushed=%v err=%v, want full push", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 1 {
|
||||
t.Fatalf("reconcile sent %d full inbound updates, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdoptedSerializationChainKeepsReconcileSkip: after a push the node
|
||||
// re-serializes settings its own way and the master adopts that form back into
|
||||
// its DB; stamping the adopted payload keeps edit->advance->skip alive instead
|
||||
// of degrading every edit to a full reconcile push.
|
||||
func TestAdoptedSerializationChainKeepsReconcileSkip(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
pushForm := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"a@x"}]}`}
|
||||
adoptedForm := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: "{\n \"clients\": [{\"email\": \"a@x\"}]\n}"}
|
||||
edited := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: "{\n \"clients\": [{\"comment\": \"c\", \"email\": \"a@x\"}]\n}"}
|
||||
r.cacheSet(pushForm.Tag, 7)
|
||||
|
||||
r.recordPushedInbound(pushForm)
|
||||
r.RecordAdoptedInbound(adoptedForm)
|
||||
if err := r.UpdateUser(context.Background(), edited, "a@x", model.Client{Email: "a@x"}); err != nil {
|
||||
t.Fatalf("client mutation: %v", err)
|
||||
}
|
||||
r.AdvancePushedInbound(adoptedForm, edited)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), edited, true); err != nil || pushed {
|
||||
t.Fatalf("reconcile after adopted-form advance: pushed=%v err=%v, want skip", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 0 {
|
||||
t.Fatalf("full inbound updates=%d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserNotFoundHandling(t *testing.T) {
|
||||
t.Run("envelope not-found counts as already deleted", func(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":false,"msg":"client not found"}`)
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-delete-missing", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
if err := r.DeleteUser(context.Background(), ib, "missing@x"); err != nil {
|
||||
t.Fatalf("DeleteUser missing client: %v", err)
|
||||
}
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("reconcile after missing-client delete: pushed=%v err=%v, want full push", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 1 {
|
||||
t.Fatalf("reconcile sent %d full inbound updates, want 1", got)
|
||||
}
|
||||
})
|
||||
t.Run("http 404 from an old node stays an error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/panel/api/clients/") {
|
||||
http.Error(w, "404 page not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-delete-404", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
err := r.DeleteUser(context.Background(), ib, "missing@x")
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 404") {
|
||||
t.Fatalf("DeleteUser against old node = %v, want HTTP 404 error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDelInboundDropsReconcileFingerprint: deleting an inbound must forget its
|
||||
// fingerprint so a later same-tag inbound with identical content is re-pushed.
|
||||
func TestDelInboundDropsReconcileFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||
t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if err := r.DelInbound(context.Background(), ib); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("reconcile after DelInbound: pushed=%v err=%v, want full push", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 2 {
|
||||
t.Fatalf("full inbound updates=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInboundFallbackAddSeedsReconcileFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-add-fallback", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
|
||||
if err := r.UpdateInbound(context.Background(), ib, ib); err != nil {
|
||||
t.Fatalf("UpdateInbound fallback add: %v", err)
|
||||
}
|
||||
if got := counts.adds.Load(); got != 1 {
|
||||
t.Fatalf("fallback add requests=%d, want 1", got)
|
||||
}
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
|
||||
t.Fatalf("reconcile after fallback add: pushed=%v err=%v, want skip", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 0 {
|
||||
t.Fatalf("reconcile sent %d full inbound updates, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -1,950 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const remoteHTTPTimeout = 10 * time.Second
|
||||
|
||||
// zstdMinBodyBytes is the smallest body worth compressing; below it the framing
|
||||
// overhead can outweigh the savings.
|
||||
const zstdMinBodyBytes = 1024
|
||||
|
||||
// maxRemoteResponseBytes caps a single node RPC's response body. It bounds the
|
||||
// wire/decompressed size of one response — the real guard against a broken or
|
||||
// hostile node streaming an unbounded body. It is NOT a process-wide memory
|
||||
// bound: concurrent RPCs and the decoded JSON can each exceed it, so
|
||||
// endpoint-specific caps and a concurrency budget remain follow-ups. Node
|
||||
// responses (traffic snapshots, client-IP lists, inbound options) are JSON and
|
||||
// stay well under it.
|
||||
const maxRemoteResponseBytes = 64 << 20 // 64 MiB
|
||||
|
||||
// errBodyDiagBytes bounds how much of a non-OK error body we read for a
|
||||
// diagnostic snippet (and to let small-error connections be reused) without
|
||||
// buffering a potentially huge or hostile error payload.
|
||||
const errBodyDiagBytes = 8 << 10 // 8 KiB
|
||||
|
||||
// errRemoteResponseTooLarge is returned when a node response exceeds the cap.
|
||||
var errRemoteResponseTooLarge = errors.New("remote response exceeds size limit")
|
||||
|
||||
// readCappedBody reads all of r but rejects bodies larger than limit, returning
|
||||
// errRemoteResponseTooLarge. It reads at most limit+1 bytes so a body of exactly
|
||||
// limit is accepted and the first oversize byte is detected without buffering
|
||||
// more.
|
||||
func readCappedBody(r io.Reader, limit int64) ([]byte, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, errRemoteResponseTooLarge
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
Obj json.RawMessage `json:"obj"`
|
||||
}
|
||||
|
||||
// remoteAPIError is a node-panel envelope failure (HTTP 200, success=false),
|
||||
// distinct from transport/HTTP-status errors so callers can trust its message.
|
||||
type remoteAPIError struct{ msg string }
|
||||
|
||||
func (e *remoteAPIError) Error() string { return "remote: " + e.msg }
|
||||
|
||||
type Remote struct {
|
||||
node *model.Node
|
||||
|
||||
mu sync.RWMutex
|
||||
remoteIDByTag map[string]int
|
||||
adoptedAliases map[string]string
|
||||
// pushedFP holds the fingerprint of the last inbound wire payload successfully
|
||||
// pushed, keyed by panel-side tag, so reconcile can skip re-sending an
|
||||
// unchanged inbound. Guarded by mu; dropped with the Remote on node config change.
|
||||
pushedFP map[string]string
|
||||
// supportsZstd is learned from the node's X-3x-Node-Caps response header; once
|
||||
// seen, config pushes to this node are zstd-compressed. Old nodes never set
|
||||
// it, so they keep receiving plain bodies (mixed-version safe).
|
||||
supportsZstd bool
|
||||
|
||||
// Per-node client honoring the TLS verify mode, built once and reused; a
|
||||
// node config change drops the cached Remote so the next one rebuilds it.
|
||||
clientOnce sync.Once
|
||||
client *http.Client
|
||||
clientErr error
|
||||
|
||||
egressResolver NodeEgressResolver
|
||||
}
|
||||
|
||||
type RemoteInboundOption struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
Remark string `json:"remark"`
|
||||
Listen string `json:"listen"`
|
||||
Protocol model.Protocol `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
|
||||
return &Remote{
|
||||
node: n,
|
||||
remoteIDByTag: make(map[string]int),
|
||||
adoptedAliases: make(map[string]string),
|
||||
pushedFP: make(map[string]string),
|
||||
egressResolver: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Remote) Name() string { return "node:" + r.node.Name }
|
||||
|
||||
func (r *Remote) nodeSupportsZstd() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.supportsZstd
|
||||
}
|
||||
|
||||
// recordCaps learns the node's capabilities from a response header so later
|
||||
// pushes can use the negotiated envelope.
|
||||
func (r *Remote) recordCaps(h http.Header) {
|
||||
if !strings.Contains(h.Get(wirecodec.CapsHeader), wirecodec.CapZstd) {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.supportsZstd = true
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// httpClient lazily builds and caches the per-node client honoring the TLS
|
||||
// verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264).
|
||||
func (r *Remote) httpClient() (*http.Client, error) {
|
||||
r.clientOnce.Do(func() {
|
||||
proxyURL := ""
|
||||
if r.node.OutboundTag != "" && r.egressResolver != nil {
|
||||
proxyURL = r.egressResolver.NodeEgressProxyURL(r.node.Id)
|
||||
}
|
||||
r.client, r.clientErr = HTTPClientForNode(r.node, proxyURL)
|
||||
})
|
||||
return r.client, r.clientErr
|
||||
}
|
||||
|
||||
// isAgentPlaceholderAddress reports whether addr is the placeholder the
|
||||
// node form used to store for agent nodes ("agent"). Agent nodes dial out
|
||||
// to the master and expose no panel endpoint; the write path no longer
|
||||
// stores the placeholder (see sanitizeAgentEndpoint in the node service),
|
||||
// but rows written earlier still carry it, and "agent" is a syntactically
|
||||
// valid hostname — without this guard baseURL would splice it into a
|
||||
// dialable URL (https://agent:2053/...) and do() would fire real requests
|
||||
// at whatever "agent" happens to resolve to.
|
||||
func isAgentPlaceholderAddress(addr string) bool {
|
||||
return strings.EqualFold(addr, "agent")
|
||||
}
|
||||
|
||||
func (r *Remote) baseURL() (string, error) {
|
||||
addr, err := netsafe.NormalizeHost(r.node.Address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isAgentPlaceholderAddress(addr) {
|
||||
return "", errors.New("node has no panel endpoint (agent-managed nodes cannot be dialed)")
|
||||
}
|
||||
scheme := r.node.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if r.node.Port <= 0 || r.node.Port > 65535 {
|
||||
return "", fmt.Errorf("invalid node port %d", r.node.Port)
|
||||
}
|
||||
bp := r.node.BasePath
|
||||
if !strings.HasSuffix(bp, "/") {
|
||||
bp += "/"
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
|
||||
Path: bp,
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
|
||||
// mtls nodes authenticate via the client certificate, so a bearer token is
|
||||
// optional for them; every other mode still requires one.
|
||||
if r.node.ApiToken == "" && r.node.TlsVerifyMode != "mtls" {
|
||||
return nil, errors.New("node has no API token configured")
|
||||
}
|
||||
|
||||
base, err := r.baseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := base + strings.TrimPrefix(path, "/")
|
||||
|
||||
var (
|
||||
bodyBytes []byte
|
||||
contentType string
|
||||
)
|
||||
switch b := body.(type) {
|
||||
case nil:
|
||||
case url.Values:
|
||||
bodyBytes = []byte(b.Encode())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
default:
|
||||
buf, jerr := json.Marshal(b)
|
||||
if jerr != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", jerr)
|
||||
}
|
||||
bodyBytes = buf
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
// Attach the integrity hash of the uncompressed body unconditionally (a new
|
||||
// node verifies it, an old one ignores it), and zstd-compress only when the
|
||||
// node advertised support and the body is worth it.
|
||||
var (
|
||||
reqBody io.Reader
|
||||
hashHex string
|
||||
zstdEncoded bool
|
||||
)
|
||||
if bodyBytes != nil {
|
||||
hashHex = wirecodec.Sha256Hex(bodyBytes)
|
||||
if len(bodyBytes) >= zstdMinBodyBytes && r.nodeSupportsZstd() {
|
||||
bodyBytes = wirecodec.Compress(bodyBytes)
|
||||
zstdEncoded = true
|
||||
}
|
||||
reqBody = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.node.ApiToken != "" {
|
||||
token, err := nodetoken.Decrypt(r.node.Id, r.node.ApiToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt node token: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if hashHex != "" {
|
||||
req.Header.Set(wirecodec.HashHeader, hashHex)
|
||||
}
|
||||
if zstdEncoded {
|
||||
req.Header.Set("Content-Encoding", wirecodec.EncodingZstd)
|
||||
}
|
||||
|
||||
client, err := r.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
r.recordCaps(resp.Header)
|
||||
|
||||
// Validate status before reading a success payload: a non-OK response's
|
||||
// body is never used beyond a short diagnostic, so don't let a node force us
|
||||
// to buffer a large body just to return an HTTP error.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyDiagBytes))
|
||||
if msg := bytes.TrimSpace(snippet); len(msg) > 0 {
|
||||
// %q quotes/escapes the untrusted node body so control characters or
|
||||
// newlines in it can't garble or inject into the error/log output.
|
||||
return nil, fmt.Errorf("%s %s: HTTP %d: %q", method, path, resp.StatusCode, msg)
|
||||
}
|
||||
return nil, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Fast-fail on an honestly-declared oversize body; the LimitReader below is
|
||||
// the real guard since Content-Length is untrusted, may be absent, or is -1
|
||||
// under transparent decompression.
|
||||
if resp.ContentLength > maxRemoteResponseBytes {
|
||||
return nil, fmt.Errorf("%s %s: %w (content-length %d, cap %d)", method, path, errRemoteResponseTooLarge, resp.ContentLength, maxRemoteResponseBytes)
|
||||
}
|
||||
|
||||
raw, err := readCappedBody(resp.Body, maxRemoteResponseBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRemoteResponseTooLarge) {
|
||||
return nil, fmt.Errorf("%s %s: %w (cap %d bytes)", method, path, err, maxRemoteResponseBytes)
|
||||
}
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("decode envelope: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return &env, &remoteAPIError{msg: env.Msg}
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
|
||||
if id, ok := r.cacheGetTag(tag); ok {
|
||||
return id, nil
|
||||
}
|
||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id, ok := r.cacheGetTag(tag); ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
|
||||
}
|
||||
|
||||
// nodeInboundTagPrefix is the central-panel alias for an inbound on nodeID.
|
||||
// Kept in sync with service.nodeTagPrefix (port_conflict.go); duplicated here
|
||||
// so runtime does not import service.
|
||||
func nodeInboundTagPrefix(nodeID int) string {
|
||||
return fmt.Sprintf("n%d-", nodeID)
|
||||
}
|
||||
|
||||
// stripNodeInboundTagPrefix removes the central-only n<id>- prefix before
|
||||
// pushing an inbound to the node so Xray keeps its original tag and routing.
|
||||
func stripNodeInboundTagPrefix(nodeID int, tag string) string {
|
||||
if stripped, ok := strings.CutPrefix(tag, nodeInboundTagPrefix(nodeID)); ok {
|
||||
return stripped
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
// cacheGetTag looks up a remote inbound id by tag, tolerating an n<id>- prefix
|
||||
// that lives on only one of the two panels: the node may carry the bare tag
|
||||
// while the central panel stores the prefixed form, or vice versa.
|
||||
func (r *Remote) cacheGetTag(tag string) (int, bool) {
|
||||
if id, ok := r.cacheGet(tag); ok {
|
||||
return id, true
|
||||
}
|
||||
prefix := nodeInboundTagPrefix(r.node.Id)
|
||||
if stripped, found := strings.CutPrefix(tag, prefix); found {
|
||||
return r.cacheGet(stripped)
|
||||
}
|
||||
return r.cacheGet(prefix + tag)
|
||||
}
|
||||
|
||||
func (r *Remote) cacheGet(tag string) (int, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
id, ok := r.remoteIDByTag[tag]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (r *Remote) cacheSet(tag string, id int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.remoteIDByTag[tag] = id
|
||||
}
|
||||
|
||||
func (r *Remote) cacheDel(tag string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.remoteIDByTag, tag)
|
||||
delete(r.pushedFP, tag)
|
||||
}
|
||||
|
||||
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
|
||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
tags := make([]string, 0, len(r.remoteIDByTag))
|
||||
for tag := range r.remoteIDByTag {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []RemoteInboundOption
|
||||
if err := json.Unmarshal(env.Obj, &list); err != nil {
|
||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var list []struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Obj, &list); err != nil {
|
||||
return fmt.Errorf("decode inbound list: %w", err)
|
||||
}
|
||||
next := make(map[string]int, len(list))
|
||||
for _, ib := range list {
|
||||
if ib.Tag == "" {
|
||||
continue
|
||||
}
|
||||
next[ib.Tag] = ib.Id
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.remoteIDByTag = next
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
|
||||
payload := wireInbound(ib, r.node.Id)
|
||||
env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var created struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &created); err == nil && created.Id > 0 && created.Tag != "" {
|
||||
r.cacheSet(created.Tag, created.Id)
|
||||
}
|
||||
}
|
||||
r.recordPushedInbound(ib)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) DelInbound(ctx context.Context, ib *model.Inbound) error {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
logger.Warning("remote DelInbound: tag", ib.Tag, "not found on", r.node.Name)
|
||||
return nil
|
||||
}
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/del/"+strconv.Itoa(id), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cacheDel(ib.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
||||
id, err := r.resolveRemoteID(ctx, oldIb.Tag)
|
||||
if err != nil {
|
||||
return r.AddInbound(ctx, newIb)
|
||||
}
|
||||
payload := wireInbound(newIb, r.node.Id)
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldIb.Tag != newIb.Tag {
|
||||
r.cacheDel(oldIb.Tag)
|
||||
}
|
||||
r.cacheSet(newIb.Tag, id)
|
||||
r.recordPushedInbound(newIb)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) SetInboundSubSortIndex(ctx context.Context, ib *model.Inbound, index int) error {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := url.Values{"subSortIndex": []string{strconv.Itoa(index)}}
|
||||
_, err = r.do(ctx, http.MethodPost, "panel/api/inbounds/"+strconv.Itoa(id)+"/subSortIndex", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReconcileInbound pushes ib only when its wire payload differs from the last
|
||||
// successful push, or when the node no longer reports the tag (existsOnNode
|
||||
// false) — a node that dropped/restarted must still be re-seeded. Returns
|
||||
// whether a push actually happened. This turns a full-fleet reconcile from "send
|
||||
// every inbound's full settings" into "send only what changed".
|
||||
func (r *Remote) ReconcileInbound(ctx context.Context, ib *model.Inbound, existsOnNode bool) (bool, error) {
|
||||
fp := wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
if existsOnNode {
|
||||
r.mu.RLock()
|
||||
prev, ok := r.pushedFP[ib.Tag]
|
||||
r.mu.RUnlock()
|
||||
if ok && prev == fp {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := r.UpdateInbound(ctx, ib, ib); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// recordPushedInbound stamps the fingerprint after a full-payload push — the
|
||||
// only operation that proves the node holds the entire wire payload.
|
||||
func (r *Remote) recordPushedInbound(ib *model.Inbound) {
|
||||
fp := wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
r.mu.Lock()
|
||||
r.pushedFP[ib.Tag] = fp
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// RecordAdoptedInbound stamps the exact payload fingerprint after the master
|
||||
// adopts a node's settings serialization.
|
||||
func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
|
||||
r.recordPushedInbound(ib)
|
||||
}
|
||||
|
||||
// AdoptInboundAlias records a deployed alias without mutating either panel.
|
||||
// The runtime association is rediscovered after a master restart.
|
||||
func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {
|
||||
r.mu.Lock()
|
||||
r.remoteIDByTag[remote.Tag] = remote.Id
|
||||
r.remoteIDByTag[ib.Tag] = remote.Id
|
||||
r.adoptedAliases[ib.Tag] = remote.Tag
|
||||
r.pushedFP[ib.Tag] = wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Remote) AdoptedInboundAliases() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
aliases := make([]string, 0, len(r.adoptedAliases))
|
||||
for _, alias := range r.adoptedAliases {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
// AdvancePushedInbound moves the reconcile-skip fingerprint from an inbound's
|
||||
// pre-edit payload to its post-edit payload once every per-client push for the
|
||||
// edit succeeded. It advances only when the recorded fingerprint proves the
|
||||
// node held the exact pre-edit state; otherwise the stale fingerprint stays and
|
||||
// the next reconcile re-sends the full inbound.
|
||||
func (r *Remote) AdvancePushedInbound(prevIb, ib *model.Inbound) {
|
||||
prevFP := wireFingerprint(wireInbound(prevIb, r.node.Id))
|
||||
nextFP := wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
r.mu.Lock()
|
||||
if r.pushedFP[ib.Tag] == prevFP {
|
||||
r.pushedFP[ib.Tag] = nextFP
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// wireFingerprint hashes a wire payload so an unchanged inbound is cheap to detect.
|
||||
func wireFingerprint(v url.Values) string {
|
||||
sum := sha256.Sum256([]byte(v.Encode()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (r *Remote) AddUser(ctx context.Context, ib *model.Inbound, _ map[string]any) error {
|
||||
return r.UpdateInbound(ctx, ib, ib)
|
||||
}
|
||||
|
||||
func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) error {
|
||||
return r.UpdateInbound(ctx, ib, ib)
|
||||
}
|
||||
|
||||
func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"client": client,
|
||||
"inboundIds": []int{id},
|
||||
}
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
// Can't confirm the delete reached the node — surface it so the caller
|
||||
// marks the node dirty and a reconcile converges, instead of silently
|
||||
// dropping the delete and letting the next snapshot resurrect the client.
|
||||
return fmt.Errorf("remote DeleteUser: resolve tag %q: %w", ib.Tag, err)
|
||||
}
|
||||
body := map[string]any{"inboundIds": []int{id}}
|
||||
_, err = r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *remoteAPIError
|
||||
if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) DeleteClient(ctx context.Context, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/del/"+url.PathEscape(email), nil)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *remoteAPIError
|
||||
if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
|
||||
if oldEmail == "" {
|
||||
oldEmail = payload.Email
|
||||
}
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
|
||||
"?inboundIds=" + strconv.Itoa(id)
|
||||
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) RestartXray(ctx context.Context) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePanel asks the node to run its own official self-updater (update.sh)
|
||||
// and restart onto the latest release. The node returns as soon as the job is
|
||||
// launched; the new version surfaces on the next heartbeat. When dev is true the
|
||||
// node is moved to the rolling dev channel instead of the latest stable release.
|
||||
func (r *Remote) UpdatePanel(ctx context.Context, dev bool) error {
|
||||
var body any
|
||||
if dev {
|
||||
body = url.Values{"dev": {"true"}}
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", body)
|
||||
return err
|
||||
}
|
||||
|
||||
// WebCertFiles holds a node's own web TLS certificate and key file paths.
|
||||
type WebCertFiles struct {
|
||||
WebCertFile string `json:"webCertFile"`
|
||||
WebKeyFile string `json:"webKeyFile"`
|
||||
}
|
||||
|
||||
// GetWebCertFiles fetches the node's own web TLS certificate/key file paths so
|
||||
// the central panel can offer them as the "Set Cert from Panel" default for a
|
||||
// node-assigned inbound — those paths exist on the node, the central panel's
|
||||
// don't. See issue #4854.
|
||||
func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/server/getWebCertFiles", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files WebCertFiles
|
||||
if err := json.Unmarshal(env.Obj, &files); err != nil {
|
||||
return nil, fmt.Errorf("decode web cert files: %w", err)
|
||||
}
|
||||
return &files, nil
|
||||
}
|
||||
|
||||
// GetDescendants fetches the node's read-only summaries of the nodes IT
|
||||
// manages, so this panel can surface them as transitive sub-nodes in a chained
|
||||
// topology (#4983). Best-effort: an old-build node without the endpoint returns
|
||||
// an error the caller ignores.
|
||||
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []model.NodeSummary
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode descendants: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
|
||||
_, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) ResetAllTraffics(ctx context.Context) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/resetAllTraffics", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
|
||||
_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type TrafficSnapshot struct {
|
||||
Inbounds []*model.Inbound
|
||||
OnlineEmails []string
|
||||
ManagedAliases []string
|
||||
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
|
||||
// its panelGuid plus every descendant under theirs). Preferred over the flat
|
||||
// OnlineEmails so the master can attribute deeply nested clients to the real
|
||||
// node across a chain (#4983). Empty when the node is an old build without
|
||||
// the per-GUID endpoint — OnlineEmails is the fallback then.
|
||||
OnlineTree map[string][]string
|
||||
// ActiveInboundTree is the GUID-keyed subtree of inbound tags that carried
|
||||
// traffic within the node's online grace window. Empty when the node is an
|
||||
// old build without the endpoint; the master then falls back to email-only
|
||||
// online attribution for that node.
|
||||
ActiveInboundTree map[string][]string
|
||||
LastOnlineMap map[string]int64
|
||||
// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
|
||||
// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
|
||||
HostGroups []*entity.HostGroup
|
||||
}
|
||||
|
||||
// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound
|
||||
// keeps its subscription TLS/SNI/fingerprint settings on the master.
|
||||
func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var groups []*entity.HostGroup
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &groups); err != nil {
|
||||
return nil, fmt.Errorf("decode host groups: %w", err)
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
|
||||
snap := &TrafficSnapshot{LastOnlineMap: map[string]int64{}}
|
||||
|
||||
envList, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(envList.Obj, &snap.Inbounds); err != nil {
|
||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||
}
|
||||
|
||||
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
|
||||
// node is an old build without the per-GUID endpoint (#4983).
|
||||
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
|
||||
if err == nil && len(envTree.Obj) > 0 {
|
||||
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
|
||||
}
|
||||
if len(snap.OnlineTree) == 0 {
|
||||
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
|
||||
if err != nil {
|
||||
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
|
||||
} else if len(envOnlines.Obj) > 0 {
|
||||
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
|
||||
}
|
||||
}
|
||||
|
||||
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
|
||||
if err != nil {
|
||||
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
|
||||
} else if len(envLastOnline.Obj) > 0 {
|
||||
_ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap)
|
||||
}
|
||||
|
||||
envActiveInbounds, err := r.do(ctx, http.MethodPost, "panel/api/clients/activeInbounds", nil)
|
||||
if err != nil {
|
||||
logger.Debugf("remote %s active inbounds fetch failed: %v", r.node.Name, err)
|
||||
} else if len(envActiveInbounds.Obj) > 0 {
|
||||
_ = json.Unmarshal(envActiveInbounds.Obj, &snap.ActiveInboundTree)
|
||||
}
|
||||
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// PushGlobalClientTraffics sends this panel's aggregated per-client usage to
|
||||
// the node, tagged with this panel's GUID so the node keeps one row per
|
||||
// pushing master. Display/enforcement input on the node only — the node never
|
||||
// folds these into the counters it reports back, so this panel's (and any
|
||||
// other master's) delta accounting over the node snapshot stays intact.
|
||||
func (r *Remote) PushGlobalClientTraffics(ctx context.Context, masterGuid string, traffics []*xray.ClientTraffic) error {
|
||||
payload := map[string]any{
|
||||
"masterGuid": masterGuid,
|
||||
"traffics": traffics,
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/pushClientTraffics", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
|
||||
v := url.Values{}
|
||||
v.Set("total", strconv.FormatInt(ib.Total, 10))
|
||||
v.Set("remark", ib.Remark)
|
||||
v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
|
||||
v.Set("enable", strconv.FormatBool(ib.Enable))
|
||||
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
|
||||
v.Set("listen", ib.Listen)
|
||||
v.Set("port", strconv.Itoa(ib.Port))
|
||||
v.Set("protocol", string(ib.Protocol))
|
||||
v.Set("settings", ib.Settings)
|
||||
v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
|
||||
tag := ib.Tag
|
||||
if remoteNodeID > 0 {
|
||||
tag = stripNodeInboundTagPrefix(remoteNodeID, tag)
|
||||
}
|
||||
v.Set("tag", tag)
|
||||
v.Set("sniffing", ib.Sniffing)
|
||||
shareAddrStrategy := strings.TrimSpace(ib.ShareAddrStrategy)
|
||||
switch shareAddrStrategy {
|
||||
case "listen", "custom":
|
||||
default:
|
||||
shareAddrStrategy = "node"
|
||||
}
|
||||
v.Set("shareAddrStrategy", shareAddrStrategy)
|
||||
v.Set("shareAddr", ib.ShareAddr)
|
||||
v.Set("disableFlow", strconv.FormatBool(ib.DisableFlow))
|
||||
if ib.TrafficReset != "" {
|
||||
v.Set("trafficReset", ib.TrafficReset)
|
||||
}
|
||||
if ib.TrafficResetDay > 0 {
|
||||
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
|
||||
// from the StreamSettings before sending to a remote node, but ONLY when
|
||||
// inline certificate content (certificate / key) is also present in the same
|
||||
// entry. In that case the file paths are redundant and stripping them avoids
|
||||
// confusion when the central panel's local paths don't exist on the remote.
|
||||
//
|
||||
// When a certificate entry contains ONLY file paths (no inline content) the
|
||||
// paths are left untouched: the user explicitly entered paths that exist on
|
||||
// the remote node's filesystem, and removing them would leave Xray with TLS
|
||||
// configured but no certificate, causing Xray to crash on the remote node.
|
||||
func sanitizeStreamSettingsForRemote(streamSettings string) string {
|
||||
if streamSettings == "" {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
|
||||
if !ok {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
certificates, ok := tlsSettings["certificates"].([]any)
|
||||
if !ok {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, cert := range certificates {
|
||||
c, ok := cert.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Only strip file paths when inline content is present so that the
|
||||
// remote Xray still has a valid certificate to use.
|
||||
hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != ""
|
||||
hasKeyFile := c["keyFile"] != nil && c["keyFile"] != ""
|
||||
hasCertInline := isNonEmptySlice(c["certificate"])
|
||||
hasKeyInline := isNonEmptySlice(c["key"])
|
||||
if hasCertFile && hasCertInline {
|
||||
delete(c, "certificateFile")
|
||||
changed = true
|
||||
}
|
||||
if hasKeyFile && hasKeyInline {
|
||||
delete(c, "keyFile")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return streamSettings
|
||||
}
|
||||
out, err := json.Marshal(stream)
|
||||
if err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value.
|
||||
func isNonEmptySlice(v any) bool {
|
||||
s, ok := v.([]any)
|
||||
return ok && len(s) > 0
|
||||
}
|
||||
|
||||
func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ips []model.InboundClientIps
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &ips); err != nil {
|
||||
return nil, fmt.Errorf("decode client ips: %w", err)
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchClientIpsByGuid pulls the node's per-node IP attribution subtree
|
||||
// (guid -> email -> observed IPs). Unlike FetchAllClientIps (the flat union the
|
||||
// master also pushes back), this preserves which physical node each IP is on.
|
||||
// Returns an empty map for older nodes that lack the endpoint.
|
||||
func (r *Remote) FetchClientIpsByGuid(ctx context.Context) (map[string]map[string][]model.ClientIpEntry, error) {
|
||||
env, err := r.do(ctx, http.MethodPost, "panel/api/clients/clientIpsByGuid", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]map[string][]model.ClientIpEntry{}
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode client ips by guid: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||
)
|
||||
|
||||
// TestRemoteSendsEnvelopeWhenNodeAdvertisesCap: once a node has advertised the
|
||||
// zstd capability (via a response header on any prior call), a large push is
|
||||
// sent zstd-compressed with an X-Config-Sha256 of the *uncompressed* body.
|
||||
func TestRemoteSendsEnvelopeWhenNodeAdvertisesCap(t *testing.T) {
|
||||
var capturedEnc, capturedHash string
|
||||
var capturedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(wirecodec.CapsHeader, wirecodec.CapZstd) // advertise on every response
|
||||
if r.Method == http.MethodPost {
|
||||
capturedEnc = r.Header.Get("Content-Encoding")
|
||||
capturedHash = r.Header.Get(wirecodec.HashHeader)
|
||||
capturedBody, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
|
||||
// Prime: a prior call learns the cap from the response header.
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil {
|
||||
t.Fatalf("prime call: %v", err)
|
||||
}
|
||||
|
||||
body := url.Values{}
|
||||
body.Set("settings", strings.Repeat("x", 4096))
|
||||
if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
|
||||
if capturedEnc != wirecodec.EncodingZstd {
|
||||
t.Fatalf("Content-Encoding = %q, want %q", capturedEnc, wirecodec.EncodingZstd)
|
||||
}
|
||||
if len(capturedHash) != 64 {
|
||||
t.Fatalf("missing/short X-Config-Sha256: %q", capturedHash)
|
||||
}
|
||||
raw, err := wirecodec.Decompress(capturedBody, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatalf("server could not decompress the body: %v", err)
|
||||
}
|
||||
if string(raw) != body.Encode() {
|
||||
t.Fatalf("decompressed body mismatch: %q != %q", string(raw), body.Encode())
|
||||
}
|
||||
if wirecodec.Sha256Hex(raw) != capturedHash {
|
||||
t.Fatal("X-Config-Sha256 does not match the decompressed body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteSendsPlainWhenNoCap: a node that never advertises the cap (old
|
||||
// build) receives a plain body — but the integrity hash is still attached
|
||||
// (harmless to old nodes, verified by new ones).
|
||||
func TestRemoteSendsPlainWhenNoCap(t *testing.T) {
|
||||
var capturedEnc, capturedHash string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
capturedEnc = r.Header.Get("Content-Encoding")
|
||||
capturedHash = r.Header.Get(wirecodec.HashHeader)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
body := url.Values{}
|
||||
body.Set("settings", strings.Repeat("x", 4096))
|
||||
if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
|
||||
if capturedEnc != "" {
|
||||
t.Fatalf("a no-cap node must receive a plain body, got Content-Encoding=%q", capturedEnc)
|
||||
}
|
||||
if len(capturedHash) != 64 {
|
||||
t.Fatalf("integrity hash should always be sent, got %q", capturedHash)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// nodeForPlainServer builds an http (non-TLS) node so do()'s token handling can
|
||||
// be exercised without TLS scaffolding.
|
||||
func nodeForPlainServer(t *testing.T, srv *httptest.Server, mode, token string) *model.Node {
|
||||
t.Helper()
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse url: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("parse port: %v", err)
|
||||
}
|
||||
return &model.Node{
|
||||
Id: 1, Name: "n1", Scheme: "http", Address: u.Hostname(), Port: port,
|
||||
BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
|
||||
TlsVerifyMode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_MTLSNodeNoBearer asserts that an mtls node with no API token
|
||||
// sends its request with NO Authorization header and does not trip the
|
||||
// empty-token precondition; while a non-mtls node with no token still errors.
|
||||
func TestRemoteDo_MTLSNodeNoBearer(t *testing.T) {
|
||||
var reached bool
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("mtls without token sends no Authorization header", func(t *testing.T) {
|
||||
reached, gotAuth = false, "sentinel"
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "mtls", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil {
|
||||
t.Fatalf("mtls node with no token must not error on the token precondition: %v", err)
|
||||
}
|
||||
if !reached {
|
||||
t.Fatal("request did not reach the server")
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("Authorization header = %q, want empty for a tokenless mtls node", gotAuth)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-mtls without token still errors", func(t *testing.T) {
|
||||
reached = false
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err == nil {
|
||||
t.Fatal("non-mtls node with no token must still error")
|
||||
}
|
||||
if reached {
|
||||
t.Fatal("non-mtls tokenless request must not reach the server")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestRemoteDo_RejectsOversizeResponse: a node streaming a body larger than
|
||||
// maxRemoteResponseBytes must error out instead of the master buffering it
|
||||
// unbounded.
|
||||
func TestRemoteDo_RejectsOversizeResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
chunk := bytes.Repeat([]byte("a"), 1<<20) // 1 MiB
|
||||
for written := 0; written <= maxRemoteResponseBytes; written += len(chunk) {
|
||||
if _, err := w.Write(chunk); err != nil {
|
||||
return // client stopped reading at the cap
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "/probe", nil); !errors.Is(err, errRemoteResponseTooLarge) {
|
||||
t.Fatalf("do() error = %v, want errRemoteResponseTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_AcceptsNormalResponse confirms the cap does not break a normal
|
||||
// under-limit envelope.
|
||||
func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":{"x":1}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
env, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("do() unexpected error: %v", err)
|
||||
}
|
||||
if env == nil || !env.Success {
|
||||
t.Fatalf("env = %+v, want Success=true", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSetInboundSubSortIndexSendsOnlyNarrowField(t *testing.T) {
|
||||
var posted url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.URL.Path {
|
||||
case "/panel/api/inbounds/list":
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":42,"tag":"remote-tag"}]}`))
|
||||
case "/panel/api/inbounds/42/subSortIndex":
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
posted = req.PostForm
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "remote-tag", Settings: `{"clients":[{"email":"newer"}]}`}
|
||||
if err := r.SetInboundSubSortIndex(context.Background(), ib, 7); err != nil {
|
||||
t.Fatalf("SetInboundSubSortIndex: %v", err)
|
||||
}
|
||||
if got := posted.Get("subSortIndex"); got != "7" {
|
||||
t.Fatalf("subSortIndex = %q, want 7", got)
|
||||
}
|
||||
if len(posted) != 1 {
|
||||
t.Fatalf("posted fields = %v, want only subSortIndex", posted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
|
||||
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
|
||||
func TestReadCappedBody_Boundary(t *testing.T) {
|
||||
const limit = 8
|
||||
cases := []struct {
|
||||
name string
|
||||
n int
|
||||
wantErr bool
|
||||
}{
|
||||
{"under", limit - 1, false},
|
||||
{"exact", limit, false},
|
||||
{"over-by-one", limit + 1, true},
|
||||
{"way-over", limit * 4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw, err := readCappedBody(bytes.NewReader(bytes.Repeat([]byte("x"), c.n)), limit)
|
||||
if c.wantErr {
|
||||
if !errors.Is(err, errRemoteResponseTooLarge) {
|
||||
t.Fatalf("n=%d: err=%v, want errRemoteResponseTooLarge", c.n, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d: unexpected err %v", c.n, err)
|
||||
}
|
||||
if len(raw) != c.n {
|
||||
t.Fatalf("n=%d: read %d bytes, want %d", c.n, len(raw), c.n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_NonOKStatusReturnsHTTPError confirms a non-OK status is reported
|
||||
// as an HTTP error (with a bounded diagnostic snippet) rather than being read as
|
||||
// a success payload — i.e. status precedence over the body.
|
||||
func TestRemoteDo_NonOKStatusReturnsHTTPError(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err == nil {
|
||||
t.Fatal("do() error = nil, want HTTP 500 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 500") || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("error = %q, want it to mention HTTP 500 and the body snippet", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubEgress struct{ url string }
|
||||
|
||||
func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
|
||||
|
||||
// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
|
||||
// sits on only one side: the node may store the bare tag while the central
|
||||
// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
|
||||
// the push create a duplicate inbound on the node.
|
||||
func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cacheTag string
|
||||
lookup string
|
||||
wantID int
|
||||
wantFound bool
|
||||
}{
|
||||
{"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true},
|
||||
{"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true},
|
||||
{"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true},
|
||||
{"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Id: 1, Name: "n1"}, nil)
|
||||
r.cacheSet(c.cacheTag, 7)
|
||||
id, ok := r.cacheGetTag(c.lookup)
|
||||
if ok != c.wantFound || id != c.wantID {
|
||||
t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundIncludesShareAddressFields(t *testing.T) {
|
||||
values := wireInbound(&model.Inbound{
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: "edge.example.com",
|
||||
}, 0)
|
||||
|
||||
if got := values.Get("shareAddrStrategy"); got != "custom" {
|
||||
t.Fatalf("shareAddrStrategy = %q, want custom", got)
|
||||
}
|
||||
if got := values.Get("shareAddr"); got != "edge.example.com" {
|
||||
t.Fatalf("shareAddr = %q, want edge.example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that does not mirror DisableFlow re-injects Vision into its own xray
|
||||
// config and share links, undoing the opt-out on every multi-node deployment.
|
||||
func TestWireInboundCarriesDisableFlow(t *testing.T) {
|
||||
if got := wireInbound(&model.Inbound{DisableFlow: true}, 0).Get("disableFlow"); got != "true" {
|
||||
t.Fatalf("disableFlow = %q, want true", got)
|
||||
}
|
||||
if got := wireInbound(&model.Inbound{}, 0).Get("disableFlow"); got != "false" {
|
||||
t.Fatalf("disableFlow = %q, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteHTTPClientEgressProxy(t *testing.T) {
|
||||
// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
|
||||
withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})
|
||||
c, err := withTag.httpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("httpClient: %v", err)
|
||||
}
|
||||
if c == defaultNodeHTTPClient {
|
||||
t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default")
|
||||
}
|
||||
// No OutboundTag → no egress proxy → shared default client (verify mode).
|
||||
noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"})
|
||||
c2, err := noTag.httpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("httpClient: %v", err)
|
||||
}
|
||||
if c2 != defaultNodeHTTPClient {
|
||||
t.Fatal("no OutboundTag must use the shared default client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteDoSetsContentType(t *testing.T) {
|
||||
var gotCT string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if gotCT != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBaseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
scheme string
|
||||
port int
|
||||
bp string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"https default path", "https", 443, "", "https://example.com:443/", false},
|
||||
{"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false},
|
||||
{"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false},
|
||||
{"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false},
|
||||
{"port zero rejected", "https", 0, "/", "", true},
|
||||
{"port above range rejected", "https", 65536, "/", "", true},
|
||||
{"negative port rejected", "https", -1, "/", "", true},
|
||||
{"max port accepted", "https", 65535, "/", "https://example.com:65535/", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil)
|
||||
got, err := r.baseURL()
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("baseURL = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy agent-node rows store the node form's placeholder pair
|
||||
// (address "agent", port 2053). "agent" is a syntactically valid
|
||||
// hostname, so without a guard baseURL would splice it into a dialable
|
||||
// URL — https://agent:2053/... — and do() would fire real requests at
|
||||
// whatever "agent" resolves to. The placeholder must be refused with a
|
||||
// clear endpoint error instead, in any casing.
|
||||
func TestRemoteBaseURL_RejectsAgentPlaceholder(t *testing.T) {
|
||||
for _, addr := range []string{"agent", "Agent", "AGENT"} {
|
||||
r := NewRemote(&model.Node{Address: addr, Port: 2053, Scheme: "https"}, nil)
|
||||
got, err := r.baseURL()
|
||||
if err == nil || !strings.Contains(err.Error(), "no panel endpoint") {
|
||||
t.Fatalf("baseURL(%q) = %q, %v; want a panel-endpoint error", addr, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The latent trigger: a placeholder row that also carries a panel API
|
||||
// token passes do()'s credential gate and reaches baseURL. The request
|
||||
// must die there — never on the network.
|
||||
func TestRemoteDo_AgentPlaceholderNeverDials(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Address: "agent", Port: 2053, Scheme: "https", ApiToken: "tok"}, nil)
|
||||
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "no panel endpoint") {
|
||||
t.Fatalf("do() error = %v, want a panel-endpoint error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNonEmptySlice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want bool
|
||||
}{
|
||||
{"non-empty slice", []any{1}, true},
|
||||
{"empty slice", []any{}, false},
|
||||
{"nil slice", []any(nil), false},
|
||||
{"not a slice", "x", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isNonEmptySlice(c.in); got != c.want {
|
||||
t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundTrafficReset(t *testing.T) {
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
|
||||
if got := with.Get("trafficReset"); got != "monthly" {
|
||||
t.Fatalf("trafficReset = %q, want monthly", got)
|
||||
}
|
||||
if got := with.Get("trafficResetDay"); got != "15" {
|
||||
t.Fatalf("trafficResetDay = %q, want 15", got)
|
||||
}
|
||||
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
|
||||
without := wireInbound(&model.Inbound{}, 0)
|
||||
if without.Has("trafficReset") {
|
||||
t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
|
||||
values := wireInbound(&model.Inbound{}, 0)
|
||||
|
||||
if got := values.Get("shareAddrStrategy"); got != "node" {
|
||||
t.Fatalf("shareAddrStrategy = %q, want node", got)
|
||||
}
|
||||
|
||||
values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}, 0)
|
||||
if got := values.Get("shareAddrStrategy"); got != "node" {
|
||||
t.Fatalf("invalid shareAddrStrategy = %q, want node", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNodeInboundTagPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
nodeID int
|
||||
tag string
|
||||
want string
|
||||
}{
|
||||
{2, "n2-in-443-tcp", "in-443-tcp"},
|
||||
{2, "in-443-tcp", "in-443-tcp"},
|
||||
{2, "my-custom", "my-custom"},
|
||||
{2, "n3-in-443-tcp", "n3-in-443-tcp"},
|
||||
{0, "n2-in-443-tcp", "n2-in-443-tcp"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripNodeInboundTagPrefix(c.nodeID, c.tag); got != c.want {
|
||||
t.Fatalf("stripNodeInboundTagPrefix(%d, %q) = %q, want %q", c.nodeID, c.tag, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundStripsNodeTagOnPush(t *testing.T) {
|
||||
values := wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 2)
|
||||
if got := values.Get("tag"); got != "in-443-tcp" {
|
||||
t.Fatalf("tag = %q, want in-443-tcp", got)
|
||||
}
|
||||
values = wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 0)
|
||||
if got := values.Get("tag"); got != "n2-in-443-tcp" {
|
||||
t.Fatalf("nodeID 0 must not strip, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
// wantCertFile / wantKeyFile: expected presence after sanitize
|
||||
wantCertFile bool
|
||||
wantKeyFile bool
|
||||
}{
|
||||
{
|
||||
name: "file paths only — kept intact (remote node paths)",
|
||||
input: `{
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificateFile": "/etc/ssl/cert.crt",
|
||||
"keyFile": "/etc/ssl/key.key"
|
||||
}]
|
||||
}
|
||||
}`,
|
||||
wantCertFile: true,
|
||||
wantKeyFile: true,
|
||||
},
|
||||
{
|
||||
name: "inline content only — unchanged",
|
||||
input: `{
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificate": ["-----BEGIN CERTIFICATE-----"],
|
||||
"key": ["-----BEGIN PRIVATE KEY-----"]
|
||||
}]
|
||||
}
|
||||
}`,
|
||||
wantCertFile: false,
|
||||
wantKeyFile: false,
|
||||
},
|
||||
{
|
||||
name: "both file paths and inline content — file paths stripped (redundant)",
|
||||
input: `{
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificateFile": "/etc/ssl/cert.crt",
|
||||
"keyFile": "/etc/ssl/key.key",
|
||||
"certificate": ["-----BEGIN CERTIFICATE-----"],
|
||||
"key": ["-----BEGIN PRIVATE KEY-----"]
|
||||
}]
|
||||
}
|
||||
}`,
|
||||
wantCertFile: false,
|
||||
wantKeyFile: false,
|
||||
},
|
||||
{
|
||||
name: "empty stream settings",
|
||||
input: "",
|
||||
// empty input returns empty, nothing to check
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.input == "" {
|
||||
if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := sanitizeStreamSettingsForRemote(tc.input)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
|
||||
}
|
||||
|
||||
tls, _ := out["tlsSettings"].(map[string]any)
|
||||
certs, _ := tls["certificates"].([]any)
|
||||
if len(certs) == 0 {
|
||||
t.Fatal("certificates array missing in output")
|
||||
}
|
||||
cert, _ := certs[0].(map[string]any)
|
||||
|
||||
_, hasCertFile := cert["certificateFile"]
|
||||
_, hasKeyFile := cert["keyFile"]
|
||||
|
||||
if hasCertFile != tc.wantCertFile {
|
||||
t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
|
||||
}
|
||||
if hasKeyFile != tc.wantKeyFile {
|
||||
t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
// MasterClientCertProvider supplies the master client certificate this panel
|
||||
// presents to nodes in mtls mode. It is injected by the web layer so the
|
||||
// runtime package need not import service.
|
||||
type MasterClientCertProvider func() (tls.Certificate, error)
|
||||
|
||||
var (
|
||||
masterClientCertMu sync.RWMutex
|
||||
masterClientCert MasterClientCertProvider
|
||||
masterCertEpoch atomic.Uint64
|
||||
)
|
||||
|
||||
// SetMasterClientCertProvider installs the provider used to obtain the master
|
||||
// client certificate for mtls nodes. Passing nil disables it.
|
||||
func SetMasterClientCertProvider(p MasterClientCertProvider) {
|
||||
masterClientCertMu.Lock()
|
||||
defer masterClientCertMu.Unlock()
|
||||
masterClientCert = p
|
||||
}
|
||||
|
||||
func getMasterClientCert() (tls.Certificate, error) {
|
||||
masterClientCertMu.RLock()
|
||||
p := masterClientCert
|
||||
masterClientCertMu.RUnlock()
|
||||
if p == nil {
|
||||
return tls.Certificate{}, common.NewError("mtls: master client certificate provider not configured")
|
||||
}
|
||||
return p()
|
||||
}
|
||||
|
||||
// InvalidateMasterClientConnections advances the client-credential generation.
|
||||
// Every cached mTLS transport observes the generation before its next request,
|
||||
// replaces its TLS transport, and closes the old idle pool. Requests already
|
||||
// in flight are not interrupted; no request that starts after invalidation can
|
||||
// reuse a connection authenticated with the previous leaf.
|
||||
func InvalidateMasterClientConnections() {
|
||||
masterCertEpoch.Add(1)
|
||||
}
|
||||
|
||||
// ReloadMasterClientConnections validates that the currently configured
|
||||
// provider can load the master credential, then invalidates every cached mTLS
|
||||
// transport. Operators that rotate the credential outside the process (for
|
||||
// example by restoring settings) can call this without restarting the panel.
|
||||
func ReloadMasterClientConnections() error {
|
||||
if _, err := getMasterClientCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
InvalidateMasterClientConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
type idleClosingRoundTripper interface {
|
||||
http.RoundTripper
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
type credentialRotatingTransport struct {
|
||||
mu sync.Mutex
|
||||
generation uint64
|
||||
current idleClosingRoundTripper
|
||||
build func() (idleClosingRoundTripper, error)
|
||||
}
|
||||
|
||||
func buildStableCredentialTransport(build func() (idleClosingRoundTripper, error)) (idleClosingRoundTripper, uint64, error) {
|
||||
for {
|
||||
before := masterCertEpoch.Load()
|
||||
current, err := build()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
after := masterCertEpoch.Load()
|
||||
if before == after {
|
||||
return current, after, nil
|
||||
}
|
||||
current.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func newCredentialRotatingTransport(build func() (idleClosingRoundTripper, error)) (*credentialRotatingTransport, error) {
|
||||
current, generation, err := buildStableCredentialTransport(build)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &credentialRotatingTransport{
|
||||
generation: generation,
|
||||
current: current,
|
||||
build: build,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *credentialRotatingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.mu.Lock()
|
||||
if masterCertEpoch.Load() != t.generation {
|
||||
next, generation, err := buildStableCredentialTransport(t.build)
|
||||
if err != nil {
|
||||
t.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
previous := t.current
|
||||
t.current = next
|
||||
t.generation = generation
|
||||
previous.CloseIdleConnections()
|
||||
}
|
||||
current := t.current
|
||||
t.mu.Unlock()
|
||||
return current.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *credentialRotatingTransport) CloseIdleConnections() {
|
||||
t.mu.Lock()
|
||||
current := t.current
|
||||
t.mu.Unlock()
|
||||
current.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
|
||||
// mode or plain http); shared so connections pool across nodes.
|
||||
var defaultNodeHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
},
|
||||
}
|
||||
|
||||
func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
|
||||
mode := n.TlsVerifyMode
|
||||
if mode == "" {
|
||||
mode = "verify"
|
||||
}
|
||||
if proxyURL != "" {
|
||||
if mode == "mtls" && n.Scheme != "http" {
|
||||
timeout := remoteHTTPTimeout
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, common.NewError("mtls proxy client transport does not support credential rotation")
|
||||
}
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
return transport, nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}, nil
|
||||
}
|
||||
client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
return client, nil
|
||||
}
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
return client, nil
|
||||
}
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
return client, nil
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
return defaultNodeHTTPClient, nil
|
||||
}
|
||||
if mode == "mtls" {
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
}, nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{Transport: transport}, nil
|
||||
}
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tlsConfigForNode(n *model.Node) (*tls.Config, error) {
|
||||
if n.TlsVerifyMode == "mtls" {
|
||||
// Present the master client cert; verify the node's server cert against
|
||||
// the system roots (no InsecureSkipVerify). mtls authenticates the
|
||||
// caller — it does not change how the node's server identity is checked.
|
||||
cert, err := getMasterClientCert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, nil
|
||||
}
|
||||
tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check]
|
||||
if n.TlsVerifyMode == "pin" {
|
||||
want, err := DecodeCertPin(n.PinnedCertSha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return common.NewError("node presented no certificate")
|
||||
}
|
||||
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
|
||||
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
|
||||
return common.NewError("node certificate does not match pinned SHA-256")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return tlsCfg, nil
|
||||
}
|
||||
|
||||
// DecodeCertPin decodes a SHA-256 cert pin given as base64 (Xray's
|
||||
// pinnedPeerCertSha256 form) or hex with optional colons into 32 raw bytes.
|
||||
func DecodeCertPin(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, common.NewError("certificate pin is empty")
|
||||
}
|
||||
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
|
||||
return b, nil
|
||||
}
|
||||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
||||
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
func insertColons(h string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(h); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(h[i : i+2])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every
|
||||
// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes
|
||||
// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant
|
||||
// that breaks one decoding path is caught across the whole input space.
|
||||
func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw")
|
||||
hx := hex.EncodeToString(raw)
|
||||
forms := []string{
|
||||
hx,
|
||||
strings.ToUpper(hx),
|
||||
insertColons(hx),
|
||||
base64.StdEncoding.EncodeToString(raw),
|
||||
base64.RawStdEncoding.EncodeToString(raw),
|
||||
base64.URLEncoding.EncodeToString(raw),
|
||||
base64.RawURLEncoding.EncodeToString(raw),
|
||||
}
|
||||
for _, f := range forms {
|
||||
got, err := DecodeCertPin(f)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeCertPin(%q) errored: %v", f, err)
|
||||
}
|
||||
if !bytes.Equal(got, raw) {
|
||||
t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never
|
||||
// returns a non-32-byte slice with a nil error, and never returns bytes alongside an
|
||||
// error. Seeded from the known-good/known-bad cases.
|
||||
func FuzzDecodeCertPin(f *testing.F) {
|
||||
seed := sha256.Sum256([]byte("seed"))
|
||||
f.Add(hex.EncodeToString(seed[:]))
|
||||
f.Add(base64.StdEncoding.EncodeToString(seed[:]))
|
||||
f.Add(insertColons(hex.EncodeToString(seed[:])))
|
||||
f.Add("")
|
||||
f.Add("not-a-pin")
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
got, err := DecodeCertPin(s)
|
||||
if err == nil && len(got) != sha256.Size {
|
||||
t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size)
|
||||
}
|
||||
if err != nil && got != nil {
|
||||
t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
)
|
||||
|
||||
type generationProbeTransport struct {
|
||||
id string
|
||||
closed atomic.Int32
|
||||
}
|
||||
|
||||
func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: http.NoBody,
|
||||
Header: make(http.Header),
|
||||
Request: &http.Request{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *generationProbeTransport) CloseIdleConnections() {
|
||||
t.closed.Add(1)
|
||||
}
|
||||
|
||||
func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
|
||||
var selected atomic.Pointer[generationProbeTransport]
|
||||
oldTransport := &generationProbeTransport{id: "old"}
|
||||
newTransport := &generationProbeTransport{id: "new"}
|
||||
selected.Store(oldTransport)
|
||||
|
||||
rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
|
||||
return selected.Load(), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newCredentialRotatingTransport: %v", err)
|
||||
}
|
||||
rotating.mu.Lock()
|
||||
initial := rotating.current
|
||||
rotating.mu.Unlock()
|
||||
if initial != oldTransport {
|
||||
t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
|
||||
}
|
||||
|
||||
selected.Store(newTransport)
|
||||
InvalidateMasterClientConnections()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
|
||||
resp, err := rotating.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip after credential rotation: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
rotating.mu.Lock()
|
||||
current := rotating.current
|
||||
rotating.mu.Unlock()
|
||||
if current != newTransport {
|
||||
t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
|
||||
}
|
||||
if got := oldTransport.closed.Load(); got != 1 {
|
||||
t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
|
||||
before := masterCertEpoch.Load()
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
return tls.Certificate{}, context.Canceled
|
||||
})
|
||||
if err := ReloadMasterClientConnections(); err == nil {
|
||||
t.Fatal("reload with an invalid provider unexpectedly succeeded")
|
||||
}
|
||||
if got := masterCertEpoch.Load(); got != before {
|
||||
t.Fatalf("failed reload changed generation from %d to %d", before, got)
|
||||
}
|
||||
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
return masterCertForTest(t), nil
|
||||
})
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
if err := ReloadMasterClientConnections(); err != nil {
|
||||
t.Fatalf("ReloadMasterClientConnections: %v", err)
|
||||
}
|
||||
if got := masterCertEpoch.Load(); got != before+1 {
|
||||
t.Fatalf("successful reload generation = %d, want %d", got, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
|
||||
oldTransport := &generationProbeTransport{id: "old"}
|
||||
newTransport := &generationProbeTransport{id: "new"}
|
||||
var selected atomic.Pointer[generationProbeTransport]
|
||||
selected.Store(oldTransport)
|
||||
|
||||
firstBuildCaptured := make(chan struct{})
|
||||
releaseFirstBuild := make(chan struct{})
|
||||
var once sync.Once
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
captured := selected.Load()
|
||||
once.Do(func() {
|
||||
close(firstBuildCaptured)
|
||||
<-releaseFirstBuild
|
||||
})
|
||||
return captured, nil
|
||||
}
|
||||
|
||||
type result struct {
|
||||
transport *credentialRotatingTransport
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
resultCh <- result{transport: transport, err: err}
|
||||
}()
|
||||
|
||||
<-firstBuildCaptured
|
||||
selected.Store(newTransport)
|
||||
InvalidateMasterClientConnections()
|
||||
close(releaseFirstBuild)
|
||||
|
||||
got := <-resultCh
|
||||
if got.err != nil {
|
||||
t.Fatalf("newCredentialRotatingTransport: %v", got.err)
|
||||
}
|
||||
got.transport.mu.Lock()
|
||||
current := got.transport.current
|
||||
got.transport.mu.Unlock()
|
||||
if current != newTransport {
|
||||
t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
|
||||
}
|
||||
if calls := oldTransport.closed.Load(); calls != 1 {
|
||||
t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
selected := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
|
||||
client, err := HTTPClientForNode(&model.Node{
|
||||
Scheme: "https",
|
||||
Address: "node.example.test",
|
||||
Port: 443,
|
||||
TlsVerifyMode: "mtls",
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
rotating, ok := client.Transport.(*credentialRotatingTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
|
||||
}
|
||||
leaf := func() []byte {
|
||||
rotating.mu.Lock()
|
||||
defer rotating.mu.Unlock()
|
||||
transport, ok := rotating.current.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
|
||||
}
|
||||
return transport.TLSClientConfig.Certificates[0].Certificate[0]
|
||||
}
|
||||
if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
|
||||
t.Fatal("initial TLS config does not contain the old credential")
|
||||
}
|
||||
|
||||
selected = newCert
|
||||
InvalidateMasterClientConnections()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequestWithContext: %v", err)
|
||||
}
|
||||
if _, err := client.Do(req); err == nil {
|
||||
t.Fatal("canceled request unexpectedly succeeded")
|
||||
}
|
||||
if got := leaf(); string(got) != string(newCert.Certificate[0]) {
|
||||
t.Fatal("TLS config retained the old credential after invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
selected := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
|
||||
const proxyURL = "http://127.0.0.1:18080"
|
||||
client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
rotating, ok := client.Transport.(*credentialRotatingTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport = %T, want rotating transport", client.Transport)
|
||||
}
|
||||
current := func() *http.Transport {
|
||||
rotating.mu.Lock()
|
||||
defer rotating.mu.Unlock()
|
||||
transport, ok := rotating.current.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
assertProxy := func(transport *http.Transport) {
|
||||
t.Helper()
|
||||
if transport.Proxy == nil {
|
||||
t.Fatalf("proxy function is nil, want %s", proxyURL)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
|
||||
got, err := transport.Proxy(req)
|
||||
if err != nil || got == nil || got.String() != proxyURL {
|
||||
t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
|
||||
}
|
||||
}
|
||||
assertProxy(current())
|
||||
|
||||
selected = newCert
|
||||
InvalidateMasterClientConnections()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
|
||||
_, _ = client.Do(req)
|
||||
rebuilt := current()
|
||||
assertProxy(rebuilt)
|
||||
if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
|
||||
t.Fatal("proxy mTLS rebuild retained the old credential")
|
||||
}
|
||||
}
|
||||
|
||||
// masterCertForTest builds a real CA-signed client certificate for mtls tests.
|
||||
func masterCertForTest(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
ca, err := crypto.GenerateNodeCA("test ca")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateNodeCA: %v", err)
|
||||
}
|
||||
client, err := crypto.IssueClientCert(ca, "master")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueClientCert: %v", err)
|
||||
}
|
||||
cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("X509KeyPair: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
// TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents
|
||||
// the master client cert and verifies the node's server cert against system
|
||||
// roots (no InsecureSkipVerify, no custom RootCAs).
|
||||
func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) {
|
||||
cert := masterCertForTest(t)
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil })
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
|
||||
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"})
|
||||
if err != nil {
|
||||
t.Fatalf("tlsConfigForNode(mtls): %v", err)
|
||||
}
|
||||
if len(cfg.Certificates) != 1 {
|
||||
t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates))
|
||||
}
|
||||
if cfg.InsecureSkipVerify {
|
||||
t.Fatal("mtls must NOT skip server verification")
|
||||
}
|
||||
if cfg.RootCAs != nil {
|
||||
t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when
|
||||
// no master client certificate is available, rather than silently dropping auth.
|
||||
func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) {
|
||||
SetMasterClientCertProvider(nil)
|
||||
if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil {
|
||||
t.Fatal("mtls without a configured client cert provider must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
// nodeForServer builds a node pointing at a loopback test server (loopback is
|
||||
// SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
|
||||
func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
|
||||
t.Helper()
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server url: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("parse server port: %v", err)
|
||||
}
|
||||
return &model.Node{
|
||||
Id: 1,
|
||||
Name: "n1",
|
||||
Scheme: "https",
|
||||
Address: u.Hostname(),
|
||||
Port: port,
|
||||
BasePath: "/",
|
||||
ApiToken: "token",
|
||||
Enable: true,
|
||||
AllowPrivateAddress: true,
|
||||
TlsVerifyMode: mode,
|
||||
PinnedCertSha256: pin,
|
||||
}
|
||||
}
|
||||
|
||||
func leafPinBase64(srv *httptest.Server) string {
|
||||
sum := sha256.Sum256(srv.Certificate().Raw)
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// A self-signed node must be reachable by Remote ops under skip/pin and
|
||||
// rejected under verify — the split issue #5264 reported.
|
||||
func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
goodPin := leafPinBase64(srv)
|
||||
wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
pin string
|
||||
wantErr bool
|
||||
}{
|
||||
{"verify rejects self-signed", "verify", "", true},
|
||||
{"skip accepts self-signed", "skip", "", false},
|
||||
{"pin accepts matching cert", "pin", goodPin, false},
|
||||
{"pin rejects mismatched cert", "pin", wrongPin, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil)
|
||||
_, err := r.ListInboundOptions(context.Background())
|
||||
if c.wantErr && err == nil {
|
||||
t.Fatalf("mode %q: expected error, got nil", c.mode)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Fatalf("mode %q: unexpected error: %v", c.mode, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The lazily-built client is cached for the Remote's lifetime so repeated
|
||||
// operations reuse one pooled transport rather than rebuilding TLS each call.
|
||||
func TestRemoteClientCached(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil)
|
||||
c1, err1 := r.httpClient()
|
||||
c2, err2 := r.httpClient()
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("httpClient errors: %v %v", err1, err2)
|
||||
}
|
||||
if c1 != c2 {
|
||||
t.Fatal("expected the same cached client across calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodeVerifyShared(t *testing.T) {
|
||||
// verify mode and plain http both reuse the shared default client.
|
||||
for _, n := range []*model.Node{
|
||||
{Scheme: "https", TlsVerifyMode: "verify"},
|
||||
{Scheme: "https", TlsVerifyMode: ""},
|
||||
{Scheme: "http", TlsVerifyMode: "skip"},
|
||||
} {
|
||||
c, err := HTTPClientForNode(n, "")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
|
||||
}
|
||||
if c != defaultNodeHTTPClient {
|
||||
t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodePinInvalid(t *testing.T) {
|
||||
// pin mode must fail closed, and with a specific error per cause — not merely
|
||||
// "some error" (which a bug anywhere in the build path would also satisfy).
|
||||
cases := []struct {
|
||||
name string
|
||||
pin string
|
||||
wantErr string
|
||||
}{
|
||||
{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
|
||||
{"empty pin", "", "certificate pin is empty"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for pin %q", c.pin)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
|
||||
// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
|
||||
// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
|
||||
// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
|
||||
func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
|
||||
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
|
||||
|
||||
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
if c == defaultNodeHTTPClient {
|
||||
t.Fatal("proxy client must not be the shared default client")
|
||||
}
|
||||
tr, ok := c.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want *http.Transport", c.Transport)
|
||||
}
|
||||
if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
|
||||
t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
|
||||
// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
|
||||
// using system-CA verification and NOT a pin VerifyConnection.
|
||||
func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
|
||||
n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
|
||||
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
if c == defaultNodeHTTPClient {
|
||||
t.Fatal("proxy client must not be the shared default client")
|
||||
}
|
||||
if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
|
||||
t.Fatal("verify mode must not install a pin VerifyConnection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of
|
||||
// tlsConfigForNode so the "mtls" branch added later cannot silently regress the
|
||||
// existing skip/pin modes (characterization — passes on unchanged code).
|
||||
func TestTLSConfigForNode_CurrentContract(t *testing.T) {
|
||||
t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) {
|
||||
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !cfg.InsecureSkipVerify {
|
||||
t.Fatal("skip mode must set InsecureSkipVerify")
|
||||
}
|
||||
if cfg.VerifyConnection != nil {
|
||||
t.Fatal("skip mode must not install a VerifyConnection")
|
||||
}
|
||||
})
|
||||
t.Run("pin installs a VerifyConnection", func(t *testing.T) {
|
||||
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.VerifyConnection == nil {
|
||||
t.Fatal("pin mode must install a VerifyConnection")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeCertPin(t *testing.T) {
|
||||
raw := sha256.Sum256([]byte("cert"))
|
||||
hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))
|
||||
// reinsert colons in openssl -fingerprint style
|
||||
var withColons strings.Builder
|
||||
for i := 0; i < len(hexColon); i += 2 {
|
||||
if i > 0 {
|
||||
withColons.WriteByte(':')
|
||||
}
|
||||
withColons.WriteString(hexColon[i : i+2])
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false},
|
||||
{"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false},
|
||||
{"hex bare", hex.EncodeToString(raw[:]), false},
|
||||
{"hex colon openssl", withColons.String(), false},
|
||||
{"empty", "", true},
|
||||
{"garbage", "not-a-pin", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := DecodeCertPin(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", c.in)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", c.in, err)
|
||||
}
|
||||
if string(got) != string(raw[:]) {
|
||||
t.Fatalf("decoded bytes mismatch for %q", c.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type wireObservation struct {
|
||||
pin string
|
||||
remoteAddr string
|
||||
}
|
||||
|
||||
func startLeafRecordingServer(t *testing.T) (*httptest.Server, *x509.CertPool, func() []wireObservation) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var seen []wireObservation
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observation := wireObservation{remoteAddr: r.RemoteAddr}
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
sum := sha256.Sum256(r.TLS.PeerCertificates[0].Raw)
|
||||
observation.pin = hex.EncodeToString(sum[:])
|
||||
}
|
||||
mu.Lock()
|
||||
seen = append(seen, observation)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
srv.TLS = &tls.Config{ClientAuth: tls.RequestClientCert}
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(srv.Certificate())
|
||||
return srv, pool, func() []wireObservation {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]wireObservation, len(seen))
|
||||
copy(result, seen)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func pinOf(t *testing.T, cert tls.Certificate) string {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(cert.Certificate[0])
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func rotatingClientForTest(t *testing.T, roots *x509.CertPool) *http.Client {
|
||||
t.Helper()
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
cert, err := getMasterClientCert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
RootCAs: roots,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
t.Fatalf("newCredentialRotatingTransport: %v", err)
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func doWireRequest(t *testing.T, client *http.Client, url string) {
|
||||
t.Helper()
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d want=%d", response.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRotationPresentsNewLeafOnNextConnection(t *testing.T) {
|
||||
server, roots, observations := startLeafRecordingServer(t)
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
oldPin := pinOf(t, oldCert)
|
||||
newPin := pinOf(t, newCert)
|
||||
if oldPin == newPin {
|
||||
t.Fatal("test fixture produced identical leaves")
|
||||
}
|
||||
var providerMu sync.Mutex
|
||||
current := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
providerMu.Lock()
|
||||
defer providerMu.Unlock()
|
||||
return current, nil
|
||||
})
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
client := rotatingClientForTest(t, roots)
|
||||
doWireRequest(t, client, server.URL)
|
||||
doWireRequest(t, client, server.URL)
|
||||
baseline := observations()
|
||||
if len(baseline) != 2 || baseline[0].pin != oldPin || baseline[1].pin != oldPin {
|
||||
t.Fatalf("baseline=%v", baseline)
|
||||
}
|
||||
if baseline[0].remoteAddr != baseline[1].remoteAddr {
|
||||
t.Fatalf("baseline connections differ: %v", baseline)
|
||||
}
|
||||
providerMu.Lock()
|
||||
current = newCert
|
||||
providerMu.Unlock()
|
||||
InvalidateMasterClientConnections()
|
||||
doWireRequest(t, client, server.URL)
|
||||
after := observations()
|
||||
if len(after) != 3 || after[2].pin != newPin {
|
||||
t.Fatalf("rotation observations=%v want new leaf=%s", after, newPin)
|
||||
}
|
||||
if after[2].remoteAddr == baseline[1].remoteAddr {
|
||||
t.Fatalf("rotated request reused stale connection %s", after[2].remoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRotationControlKeepsOldLeafWithoutInvalidation(t *testing.T) {
|
||||
server, roots, observations := startLeafRecordingServer(t)
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
oldPin := pinOf(t, oldCert)
|
||||
var providerMu sync.Mutex
|
||||
current := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
providerMu.Lock()
|
||||
defer providerMu.Unlock()
|
||||
return current, nil
|
||||
})
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
client := rotatingClientForTest(t, roots)
|
||||
doWireRequest(t, client, server.URL)
|
||||
providerMu.Lock()
|
||||
current = newCert
|
||||
providerMu.Unlock()
|
||||
doWireRequest(t, client, server.URL)
|
||||
got := observations()
|
||||
if len(got) != 2 || got[1].pin != oldPin {
|
||||
t.Fatalf("control observations=%v want stale leaf=%s", got, oldPin)
|
||||
}
|
||||
if got[0].remoteAddr != got[1].remoteAddr {
|
||||
t.Fatalf("control did not reuse connection: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1575,7 +1575,6 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
|
||||
}
|
||||
return res
|
||||
}
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||
@@ -1651,7 +1650,6 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
|
||||
}
|
||||
}
|
||||
if !pushFailed {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,78 +7,8 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
|
||||
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()
|
||||
|
||||
const nodeID = 1
|
||||
const email = "node-user@example.com"
|
||||
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003"
|
||||
const wantGroup = "vip"
|
||||
const wantComment = "renewed manually"
|
||||
|
||||
id := nodeID
|
||||
central := &model.Inbound{
|
||||
UserId: 1,
|
||||
NodeID: &id,
|
||||
Tag: "n1-vless",
|
||||
Enable: true,
|
||||
Port: 20001,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"group":"` + wantGroup + `","comment":"` + wantComment + `"}]}`,
|
||||
}
|
||||
if err := db.Create(central).Error; err != nil {
|
||||
t.Fatalf("create node inbound: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.ClientRecord{
|
||||
Email: email,
|
||||
UUID: uid,
|
||||
Enable: true,
|
||||
Group: wantGroup,
|
||||
Comment: wantComment,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{
|
||||
Tag: "n1-vless",
|
||||
Enable: true,
|
||||
Port: 20001,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("lookup client row after sync: %v", err)
|
||||
}
|
||||
if row.Group != wantGroup {
|
||||
t.Errorf("group was wiped by node snapshot sync: got %q, want %q", row.Group, wantGroup)
|
||||
}
|
||||
if row.Comment != wantComment {
|
||||
t.Errorf("comment was wiped by node snapshot sync: got %q, want %q", row.Comment, wantComment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInbound_KeepsGroupWhenIncomingEmpty(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
|
||||
@@ -29,18 +29,6 @@ func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
|
||||
return aerr == nil && berr == nil && string(an) == string(bn)
|
||||
}
|
||||
|
||||
// advancePushedInbound advances the node's reconcile-skip fingerprint from the
|
||||
// pre-edit settings to the saved ones after every per-client push succeeded.
|
||||
func advancePushedInbound(rt runtime.Runtime, prevSettings string, ib *model.Inbound) {
|
||||
rem, ok := rt.(*runtime.Remote)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
prev := *ib
|
||||
prev.Settings = prevSettings
|
||||
rem.AdvancePushedInbound(&prev, ib)
|
||||
}
|
||||
|
||||
// delInboundClients removes several clients from a single inbound in one pass:
|
||||
// one settings rewrite, one runtime sweep, one Save and one link delta for the
|
||||
// whole batch, instead of repeating the full per-client cycle. It mirrors the
|
||||
@@ -112,7 +100,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
var sharedSet map[string]bool
|
||||
@@ -235,7 +222,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||
}
|
||||
}
|
||||
if nodePush && !nodePushFailed {
|
||||
advancePushedInbound(nodeRt, prevSettings, oldInbound)
|
||||
}
|
||||
|
||||
return needRestart, nil
|
||||
@@ -480,7 +466,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
return false, err
|
||||
}
|
||||
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
// From the stamped wire entries, not from clients: created_at / updated_at /
|
||||
@@ -609,7 +594,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
}
|
||||
}
|
||||
if push {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,7 +826,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
return false, nil
|
||||
}
|
||||
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
// From the stamped wire entry, not from clients[0]: created_at, the
|
||||
@@ -1021,7 +1004,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
||||
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
||||
} else {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1082,7 +1064,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
return false, err
|
||||
}
|
||||
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
@@ -1191,7 +1172,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
if err1 != nil {
|
||||
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||
} else {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// How long a client stays recoverable after a node merge concluded it is gone.
|
||||
// Any merge that sees it attached again inside this window clears the mark.
|
||||
const syncOrphanReapGrace = 15 * time.Minute
|
||||
|
||||
// A traffic row with no clients row behind it is drift the orphan sweep will
|
||||
// never mark, so it stays on the old delete-immediately path.
|
||||
func clientRecordExists(tx *gorm.DB, email string) bool {
|
||||
var n int64
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("email = ? AND sync_orphaned_at = 0", email).
|
||||
Update("sync_orphaned_at", nowMs).Error
|
||||
}
|
||||
|
||||
// A client that is attached again is not orphaned, whatever an earlier merge
|
||||
// concluded — this is what makes a bad merge recoverable instead of fatal.
|
||||
func clearSyncOrphanMarks(tx *gorm.DB) error {
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Update("sync_orphaned_at", 0).Error
|
||||
}
|
||||
|
||||
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
|
||||
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
|
||||
func (s *ClientService) ReapSyncOrphans() (int, error) {
|
||||
db := database.GetDB()
|
||||
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
|
||||
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
|
||||
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
reaped := 0
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
|
||||
}); err != nil {
|
||||
return reaped, err
|
||||
}
|
||||
reaped += len(batch)
|
||||
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
|
||||
t.Helper()
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("read client %q: %v", email, err)
|
||||
}
|
||||
return row.SyncOrphanedAt
|
||||
}
|
||||
|
||||
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
||||
t.Helper()
|
||||
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("email = ?", email).
|
||||
Update("sync_orphaned_at", past).Error; err != nil {
|
||||
t.Fatalf("backdate orphan mark: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge must soft-orphan, not delete: everything stays recoverable until
|
||||
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
|
||||
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "gone@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("merge did not stamp sync_orphaned_at")
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap inside grace: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
|
||||
}
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatal("client removed before the grace period elapsed")
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
reaped, err = clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap after grace: %v", err)
|
||||
}
|
||||
if reaped != 1 {
|
||||
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
|
||||
}
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 0 || traf != 0 {
|
||||
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// A client the node reports again was never gone: clearing the mark is what
|
||||
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
||||
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "flaky@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("setup: expected the merge to mark the client")
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
||||
|
||||
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
||||
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
|
||||
// mark and keep their existing manual-cleanup semantics.
|
||||
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
const email = "manual@x"
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
|
||||
}
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// Node country flags (item 7 Q7, resolved 2026-10-24): the public
|
||||
// views carry an ISO country code per node, which the themes render
|
||||
// as a flag. The upstream hub resolves it by calling ipinfo.io per
|
||||
// node; this build never sends a node address to a third party — the
|
||||
// lookup is a local MMDB read on the master, from the node's own
|
||||
// reported addresses. The agent is untouched: it already reports its
|
||||
// interface addresses in its facts, and the master already observes
|
||||
// the connection's source addresses (SeenIPv4/SeenIPv6).
|
||||
//
|
||||
// The data is the DB-IP Lite country database (CC BY 4.0 by DB-IP,
|
||||
// https://db-ip.com; attribution and snapshot provenance live in
|
||||
// geoipdata/NOTICE), in the MMDB conversion published by the
|
||||
// sapics/ip-location-db project. Three layers, in winning order:
|
||||
//
|
||||
// 1. a local file in the data folder (geoip-country.mmdb beside the
|
||||
// panel database) — written by the weekly updater below, or by
|
||||
// the operator by hand;
|
||||
// 2. the factory snapshot embedded in the binary (geoipdata/), so a
|
||||
// fresh install shows flags with no network at all;
|
||||
// 3. no database: lookups answer "", the views render exactly the
|
||||
// pre-flag shape.
|
||||
//
|
||||
// Reads are mmap-backed (maxminddb.Open) or run against the embedded
|
||||
// bytes in place; either way a lookup is a pure in-memory tree walk
|
||||
// on the snapshot path, and answers are cached per address for the
|
||||
// life of the loaded database.
|
||||
|
||||
//go:embed geoipdata/country.mmdb
|
||||
var geoipCountryEmbedded []byte
|
||||
|
||||
// geoipCountryFileName is the local copy's name inside the data
|
||||
// folder (config.GetDBFolderPath, where x-ui.db and probe.db live).
|
||||
const geoipCountryFileName = "geoip-country.mmdb"
|
||||
|
||||
// geoipCountryDefaultURL is the weekly updater's fallback source,
|
||||
// used when the operator has not set the geoip_country_url setting.
|
||||
//
|
||||
// RELEASE-TIME (main session): point this at the self-hosted Gitea
|
||||
// mirror asset for geoip-country.mmdb. Empty disables auto-update;
|
||||
// the embedded snapshot and any hand-placed local file still apply.
|
||||
// Set at V2.3.0 release time to the standing `geoip-country` release
|
||||
// asset on the self-hosted Gitea (asset swapped in place, URL stable).
|
||||
const geoipCountryDefaultURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/geoip-country/geoip-country.mmdb"
|
||||
|
||||
// Settings keys (main settings KV, alongside the other probe_* keys).
|
||||
const (
|
||||
geoipCountryURLSetting = "geoip_country_url"
|
||||
geoipCountryETagSetting = "geoip_country_etag"
|
||||
)
|
||||
|
||||
// geoipCountryMaxBytes caps one update download; the country
|
||||
// database is ~8 MB today, the cap only stops a runaway response.
|
||||
const geoipCountryMaxBytes = 64 << 20
|
||||
|
||||
// geoipCountryProbeIP / geoipCountryProbeIPv6 are the well-known
|
||||
// addresses a candidate database must answer to be trusted (Google
|
||||
// DNS v4, AliDNS v6 — both resolve in the DB-IP data).
|
||||
const (
|
||||
geoipCountryProbeIP = "8.8.8.8"
|
||||
geoipCountryProbeIPv6 = "2400:3200::1"
|
||||
)
|
||||
|
||||
// GeoIPCountryLocalPath is where the local country database lives.
|
||||
func GeoIPCountryLocalPath() string {
|
||||
return filepath.Join(config.GetDBFolderPath(), geoipCountryFileName)
|
||||
}
|
||||
|
||||
// geoipCountryUpdateURL resolves the updater's source: the operator
|
||||
// setting wins, else the compiled-in default (possibly empty).
|
||||
func geoipCountryUpdateURL() string {
|
||||
if v, ok := probeSetting(geoipCountryURLSetting); ok {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return geoipCountryDefaultURL
|
||||
}
|
||||
|
||||
// ---- the loaded database ----
|
||||
|
||||
// countryRecord is the DB-IP Lite record shape: a flat country_code.
|
||||
type countryRecord struct {
|
||||
CountryCode string `maxminddb:"country_code"`
|
||||
}
|
||||
|
||||
// geoipDB is one loaded database plus its per-address answer cache
|
||||
// ("" answers are cached too — an unlisted address stays unlisted
|
||||
// until the database is swapped).
|
||||
type geoipDB struct {
|
||||
reader *maxminddb.Reader
|
||||
origin string // "local" | "embedded"
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
func (db *geoipDB) lookup(ipStr string) string {
|
||||
db.mu.Lock()
|
||||
if cc, ok := db.cache[ipStr]; ok {
|
||||
db.mu.Unlock()
|
||||
return cc
|
||||
}
|
||||
db.mu.Unlock()
|
||||
|
||||
cc := ""
|
||||
var rec countryRecord
|
||||
if err := db.reader.Lookup(net.ParseIP(ipStr), &rec); err == nil {
|
||||
cc = rec.CountryCode
|
||||
}
|
||||
|
||||
db.mu.Lock()
|
||||
db.cache[ipStr] = cc
|
||||
db.mu.Unlock()
|
||||
return cc
|
||||
}
|
||||
|
||||
var (
|
||||
geoipMu sync.RWMutex
|
||||
geoipCur *geoipDB
|
||||
geoipOnce sync.Once
|
||||
)
|
||||
|
||||
// currentGeoIP returns the live database, loading it on first use.
|
||||
func currentGeoIP() *geoipDB {
|
||||
geoipOnce.Do(func() {
|
||||
if db := loadGeoIP(); db != nil {
|
||||
swapGeoIP(db)
|
||||
}
|
||||
})
|
||||
geoipMu.RLock()
|
||||
defer geoipMu.RUnlock()
|
||||
return geoipCur
|
||||
}
|
||||
|
||||
// swapGeoIP makes db the live database. The replaced reader is
|
||||
// deliberately NOT closed: it is mmap-backed (or backed by the
|
||||
// binary's own image), a lookup racing the swap could still be
|
||||
// walking it, and an mmap cannot be safely unmapped under a reader.
|
||||
// The mapping is one file per week at most and is reclaimed when the
|
||||
// process exits.
|
||||
func swapGeoIP(db *geoipDB) {
|
||||
geoipMu.Lock()
|
||||
geoipCur = db
|
||||
geoipMu.Unlock()
|
||||
}
|
||||
|
||||
// loadGeoIP applies the layer order: a valid local file wins, else
|
||||
// the embedded snapshot. A local file that will not open or will not
|
||||
// answer falls through to the snapshot with one warning — a corrupt
|
||||
// download must never take the flags (or the panel) down.
|
||||
func loadGeoIP() *geoipDB {
|
||||
if path := GeoIPCountryLocalPath(); path != "" {
|
||||
if r, err := openGeoIPFile(path); err == nil {
|
||||
return &geoipDB{reader: r, origin: "local", cache: map[string]string{}}
|
||||
} else {
|
||||
logger.Warningf("geoip: local country database %s unusable (%v); using the embedded snapshot", path, err)
|
||||
}
|
||||
}
|
||||
if r, err := maxminddb.FromBytes(geoipCountryEmbedded); err == nil {
|
||||
if err := probeGeoIPReader(r); err == nil {
|
||||
return &geoipDB{reader: r, origin: "embedded", cache: map[string]string{}}
|
||||
} else {
|
||||
logger.Warningf("geoip: embedded country snapshot fails validation: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warningf("geoip: embedded country snapshot will not open: %v", err)
|
||||
}
|
||||
return &geoipDB{origin: "none", cache: map[string]string{}}
|
||||
}
|
||||
|
||||
// openGeoIPFile opens a database file and checks it answers.
|
||||
func openGeoIPFile(path string) (*maxminddb.Reader, error) {
|
||||
r, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := probeGeoIPReader(r); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// probeGeoIPReader is the functional validation: the well-known
|
||||
// probe address must resolve to a country code, and a v6 probe must
|
||||
// at least answer without an error. (maxminddb's Verify is NOT used:
|
||||
// it also audits metadata niceties — the DB-IP/sapics files carry an
|
||||
// empty description map and fail it despite being fully readable,
|
||||
// verified against the factory snapshot on 2026-10-24. A candidate
|
||||
// that opens and answers both probes is a working country DB; a
|
||||
// truncated or foreign file fails to open or errors a lookup.)
|
||||
func probeGeoIPReader(r *maxminddb.Reader) error {
|
||||
var rec countryRecord
|
||||
if err := r.Lookup(net.ParseIP(geoipCountryProbeIP), &rec); err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.CountryCode == "" {
|
||||
return fmt.Errorf("probe address %s resolves to no country", geoipCountryProbeIP)
|
||||
}
|
||||
return r.Lookup(net.ParseIP(geoipCountryProbeIPv6), &rec)
|
||||
}
|
||||
|
||||
// verifyGeoIPFile validates a fresh download in place before it may
|
||||
// replace the live database.
|
||||
func verifyGeoIPFile(path string) error {
|
||||
r, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
return probeGeoIPReader(r)
|
||||
}
|
||||
|
||||
// ---- lookups used by the public views ----
|
||||
|
||||
// countryCodeForIP resolves one public IP literal to its ISO country
|
||||
// code, "" for anything non-public, unknown, or unanswerable. The
|
||||
// public gate runs first so private/held addresses never reach the
|
||||
// database (they have no country, exactly as on the hub).
|
||||
func countryCodeForIP(ipStr string) string {
|
||||
ipStr = strings.TrimSpace(ipStr)
|
||||
if !IsPublicIPLiteral(ipStr) {
|
||||
return ""
|
||||
}
|
||||
db := currentGeoIP()
|
||||
if db == nil || db.reader == nil {
|
||||
return ""
|
||||
}
|
||||
return db.lookup(ipStr)
|
||||
}
|
||||
|
||||
// probeCountryAuto is a node's auto-detected country: the first
|
||||
// public address in the hub's order — the held interface v4, then
|
||||
// held v6, then the master-observed exit v4, then exit v6 — resolved
|
||||
// locally. "" when the node shows no public address or the database
|
||||
// has no answer for it.
|
||||
func probeCountryAuto(a *model.AgentNode) string {
|
||||
for _, s := range []string{a.IPv4, a.IPv6, a.SeenIPv4, a.SeenIPv6} {
|
||||
if IsPublicIPLiteral(s) {
|
||||
return countryCodeForIP(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeCountry resolves the pair the views publish (hub api.rs): the
|
||||
// effective country is the operator's pin when one is set, else the
|
||||
// auto-detected code; country_auto always carries the detected code.
|
||||
func probeCountry(pin string, a *model.AgentNode) (country, auto string) {
|
||||
auto = probeCountryAuto(a)
|
||||
if pin != "" {
|
||||
return pin, auto
|
||||
}
|
||||
return auto, auto
|
||||
}
|
||||
|
||||
// ---- weekly auto-update ----
|
||||
|
||||
type geoipFetchResult int
|
||||
|
||||
const (
|
||||
geoipFetchUnchanged geoipFetchResult = iota
|
||||
geoipFetchUpdated
|
||||
)
|
||||
|
||||
// downloadGeoIPCountry performs one conditional download of url into
|
||||
// destPath. The candidate lands in a temp file in the destination
|
||||
// directory, is fully validated there, and only then renames over
|
||||
// destPath (atomic on the same filesystem); any failure leaves
|
||||
// destPath and the live database untouched.
|
||||
func downloadGeoIPCountry(client *http.Client, url, destPath, etag string, ifModifiedSince time.Time) (geoipFetchResult, string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
if !ifModifiedSince.IsZero() {
|
||||
req.Header.Set("If-Modified-Since", ifModifiedSince.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return geoipFetchUnchanged, etag, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("update server answered %s", resp.Status)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(destPath)
|
||||
tmp, err := os.CreateTemp(dir, ".geoip-country-*.tmp")
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
n, err := io.Copy(tmp, io.LimitReader(resp.Body, geoipCountryMaxBytes))
|
||||
if err := tmp.Close(); err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if n == 0 {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("update download was empty")
|
||||
}
|
||||
if err := verifyGeoIPFile(tmpName); err != nil {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("downloaded database rejected: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, destPath); err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
return geoipFetchUpdated, resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// UpdateGeoIPCountry runs one update pass: a conditional download
|
||||
// from the configured URL, a validated atomic replace of the local
|
||||
// copy, and a hot swap of the live reader. No configured URL or any
|
||||
// failure keeps the current database; a failure logs a single
|
||||
// warning (the hub discipline: a broken mirror must not spam).
|
||||
func UpdateGeoIPCountry() {
|
||||
url := geoipCountryUpdateURL()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
dest := GeoIPCountryLocalPath()
|
||||
var lastMod time.Time
|
||||
if fi, err := os.Stat(dest); err == nil {
|
||||
lastMod = fi.ModTime()
|
||||
}
|
||||
etag, _ := probeSetting(geoipCountryETagSetting)
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
res, newETag, err := downloadGeoIPCountry(client, url, dest, etag, lastMod)
|
||||
if err != nil {
|
||||
logger.Warningf("geoip: country database update from %s failed: %v (keeping the current database)", url, err)
|
||||
return
|
||||
}
|
||||
if res != geoipFetchUpdated {
|
||||
return
|
||||
}
|
||||
if newETag != "" && newETag != etag {
|
||||
_ = setProbeSetting(geoipCountryETagSetting, newETag)
|
||||
}
|
||||
r, err := openGeoIPFile(dest)
|
||||
if err != nil {
|
||||
logger.Warningf("geoip: updated country database %s will not reopen: %v", dest, err)
|
||||
return
|
||||
}
|
||||
swapGeoIP(&geoipDB{reader: r, origin: "local", cache: map[string]string{}})
|
||||
logger.Infof("geoip: country database updated from %s", url)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// Q7 node country flags: local MMDB resolution on the master. The
|
||||
// known-address expectations below are calibrated against the
|
||||
// embedded factory snapshot (DB-IP Lite country database, retrieved
|
||||
// 2026-10-24; provenance in geoipdata/NOTICE): these are long-lived
|
||||
// anycast/ISP addresses whose country attribution is stable across
|
||||
// editions — 8.8.8.8 (Google DNS) US, 223.5.5.5 (AliDNS) CN,
|
||||
// 1.1.1.1 (Cloudflare/APNIC) AU, 2400:3200::1 (AliDNS v6) CN.
|
||||
|
||||
// useEmbeddedGeoIP swaps the embedded snapshot in as the live
|
||||
// database for one test, restoring whatever was live before.
|
||||
func useEmbeddedGeoIP(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := currentGeoIP() // fire the lazy load so the Once is spent
|
||||
r, err := maxminddb.FromBytes(geoipCountryEmbedded)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded snapshot will not open: %v", err)
|
||||
}
|
||||
swapGeoIP(&geoipDB{reader: r, origin: "embedded", cache: map[string]string{}})
|
||||
t.Cleanup(func() { swapGeoIP(prev) })
|
||||
}
|
||||
|
||||
func TestCountryCodeForIPKnownAddresses(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
for ip, want := range map[string]string{
|
||||
"8.8.8.8": "US",
|
||||
"223.5.5.5": "CN",
|
||||
"1.1.1.1": "AU",
|
||||
"2400:3200::1": "CN",
|
||||
" 8.8.8.8 ": "US", // surrounding space is trimmed
|
||||
} {
|
||||
if got := countryCodeForIP(ip); got != want {
|
||||
t.Errorf("countryCodeForIP(%q) = %q, want %q", ip, got, want)
|
||||
}
|
||||
}
|
||||
// Non-public and unlisted addresses have no country.
|
||||
for _, ip := range []string{
|
||||
"192.168.0.1", // RFC1918
|
||||
"10.0.0.5", // RFC1918
|
||||
"127.0.0.1", // loopback
|
||||
"100.64.0.1", // CGNAT shared range
|
||||
"203.0.113.9", // public documentation range: unlisted in the DB
|
||||
"", // empty
|
||||
"example.com", // not a literal
|
||||
"999.1.1.1", // not an address
|
||||
} {
|
||||
if got := countryCodeForIP(ip); got != "" {
|
||||
t.Errorf("countryCodeForIP(%q) = %q, want empty", ip, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeCountryAutoSourceOrder(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
// Held v4 leads over held v6 (hub order).
|
||||
a := &model.AgentNode{IPv4: "8.8.8.8", IPv6: "2400:3200::1", SeenIPv4: "223.5.5.5"}
|
||||
if got := probeCountryAuto(a); got != "US" {
|
||||
t.Errorf("held v4 first: got %q, want US", got)
|
||||
}
|
||||
// A private held v4 is skipped; the held public v6 beats the
|
||||
// observed exit v4.
|
||||
a = &model.AgentNode{IPv4: "10.0.0.5", IPv6: "2400:3200::1", SeenIPv4: "8.8.8.8"}
|
||||
if got := probeCountryAuto(a); got != "CN" {
|
||||
t.Errorf("held v6 over exit v4: got %q, want CN", got)
|
||||
}
|
||||
// Exit addresses are the fallback when the node holds none.
|
||||
a = &model.AgentNode{SeenIPv4: "223.5.5.5"}
|
||||
if got := probeCountryAuto(a); got != "CN" {
|
||||
t.Errorf("exit v4 fallback: got %q, want CN", got)
|
||||
}
|
||||
a = &model.AgentNode{SeenIPv6: "2400:3200::1"}
|
||||
if got := probeCountryAuto(a); got != "CN" {
|
||||
t.Errorf("exit v6 fallback: got %q, want CN", got)
|
||||
}
|
||||
// No public address anywhere: no country.
|
||||
a = &model.AgentNode{IPv4: "192.168.1.2", SeenIPv4: "10.1.1.1"}
|
||||
if got := probeCountryAuto(a); got != "" {
|
||||
t.Errorf("private only: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeCountryPinWins(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
a := &model.AgentNode{IPv4: "223.5.5.5"}
|
||||
country, auto := probeCountry("JP", a)
|
||||
if country != "JP" || auto != "CN" {
|
||||
t.Errorf("pin set: got (%q, %q), want (JP, CN)", country, auto)
|
||||
}
|
||||
country, auto = probeCountry("", a)
|
||||
if country != "CN" || auto != "CN" {
|
||||
t.Errorf("no pin: got (%q, %q), want (CN, CN)", country, auto)
|
||||
}
|
||||
country, auto = probeCountry("JP", &model.AgentNode{})
|
||||
if country != "JP" || auto != "" {
|
||||
t.Errorf("pin without address: got (%q, %q), want (JP, empty)", country, auto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProbeNodeViewCountry(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
src := probeNodeSource{
|
||||
name: "flagged",
|
||||
agent: model.AgentNode{IPv4: "8.8.8.8"},
|
||||
meta: probe.Meta{Public: true},
|
||||
}
|
||||
anon := buildProbeNodeView(src, false)
|
||||
if anon["country"] != "US" {
|
||||
t.Errorf("anonymous country = %v, want US", anon["country"])
|
||||
}
|
||||
admin := buildProbeNodeView(src, true)
|
||||
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||
t.Errorf("admin pair = (%v, %v, pin %v), want (US, US, empty)",
|
||||
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||
}
|
||||
src.meta.CountryPin = "DE"
|
||||
if got := buildProbeNodeView(src, false)["country"]; got != "DE" {
|
||||
t.Errorf("pinned country = %v, want DE", got)
|
||||
}
|
||||
if got := buildProbeNodeView(src, true)["country_auto"]; got != "US" {
|
||||
t.Errorf("country_auto under a pin = %v, want US", got)
|
||||
}
|
||||
// A node with no public address keeps the pre-flag empty country.
|
||||
src = probeNodeSource{name: "dark", agent: model.AgentNode{IPv4: "192.168.0.9"}, meta: probe.Meta{Public: true}}
|
||||
if got := buildProbeNodeView(src, false)["country"]; got != "" {
|
||||
t.Errorf("private node country = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenGeoIPFileValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := filepath.Join(dir, "bad.mmdb")
|
||||
if err := os.WriteFile(bad, []byte("this is not an mmdb"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openGeoIPFile(bad); err == nil {
|
||||
t.Fatal("garbage file opened as a country database")
|
||||
}
|
||||
good := filepath.Join(dir, "good.mmdb")
|
||||
if err := os.WriteFile(good, geoipCountryEmbedded, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := openGeoIPFile(good)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded bytes as a file rejected: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
db := &geoipDB{reader: r, cache: map[string]string{}}
|
||||
if got := db.lookup("223.5.5.5"); got != "CN" {
|
||||
t.Errorf("file-backed lookup = %q, want CN", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the weekly updater's download/validate/replace core ----
|
||||
|
||||
func TestDownloadGeoIPCountryUpdates(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"db-v1"`)
|
||||
_, _ = w.Write(geoipCountryEmbedded)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "geoip-country.mmdb")
|
||||
res, etag, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
if res != geoipFetchUpdated || etag != `"db-v1"` {
|
||||
t.Fatalf("got (res %v, etag %q), want (updated, \"db-v1\")", res, etag)
|
||||
}
|
||||
fi, err := os.Stat(dest)
|
||||
if err != nil || fi.Size() != int64(len(geoipCountryEmbedded)) {
|
||||
t.Fatalf("dest stat = %v (err %v), want %d bytes", fi, err, len(geoipCountryEmbedded))
|
||||
}
|
||||
// The replaced file is a working database.
|
||||
r, err := openGeoIPFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("replaced file will not open: %v", err)
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
|
||||
func TestDownloadGeoIPCountryNotModified(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-None-Match") == `"db-v1"` || r.Header.Get("If-Modified-Since") != "" {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(geoipCountryEmbedded)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "geoip-country.mmdb")
|
||||
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ETag path.
|
||||
res, _, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, `"db-v1"`, time.Time{})
|
||||
if err != nil || res != geoipFetchUnchanged {
|
||||
t.Fatalf("etag path: got (res %v, err %v), want unchanged", res, err)
|
||||
}
|
||||
// If-Modified-Since path.
|
||||
res, _, err = downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Now())
|
||||
if err != nil || res != geoipFetchUnchanged {
|
||||
t.Fatalf("modified-since path: got (res %v, err %v), want unchanged", res, err)
|
||||
}
|
||||
body, _ := os.ReadFile(dest)
|
||||
if string(body) != "ORIGINAL" {
|
||||
t.Fatalf("dest rewritten on a 304: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadGeoIPCountryRejectsBad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "geoip-country.mmdb")
|
||||
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertKept := func(name string) {
|
||||
t.Helper()
|
||||
body, _ := os.ReadFile(dest)
|
||||
if string(body) != "ORIGINAL" {
|
||||
t.Fatalf("%s: dest replaced despite the failure: %q", name, body)
|
||||
}
|
||||
}
|
||||
|
||||
garbage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("definitely not an mmdb, just bytes"))
|
||||
}))
|
||||
defer garbage.Close()
|
||||
if _, _, err := downloadGeoIPCountry(garbage.Client(), garbage.URL, dest, "", time.Time{}); err == nil {
|
||||
t.Fatal("garbage download accepted")
|
||||
}
|
||||
assertKept("garbage")
|
||||
|
||||
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer broken.Close()
|
||||
if _, _, err := downloadGeoIPCountry(broken.Client(), broken.URL, dest, "", time.Time{}); err == nil {
|
||||
t.Fatal("500 download accepted")
|
||||
}
|
||||
assertKept("500")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
Country database attribution
|
||||
============================
|
||||
|
||||
The embedded country.mmdb is the DB-IP Lite country database
|
||||
(dbip-country, IPv4+IPv6 combined), converted to MMDB format by the
|
||||
sapics/ip-location-db project:
|
||||
|
||||
https://github.com/sapics/ip-location-db
|
||||
|
||||
The underlying data is made available by DB-IP (https://db-ip.com)
|
||||
under the Creative Commons Attribution 4.0 International License
|
||||
(CC BY 4.0):
|
||||
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
Attribution: IP geolocation data by DB-IP, https://db-ip.com
|
||||
|
||||
Snapshot provenance (factory copy embedded in the binary):
|
||||
source file : dbip-country.mmdb (sapics/ip-location-db "latest" release)
|
||||
retrieved : 2026-10-24
|
||||
size : 8300093 bytes
|
||||
sha256 : fcecf33b565fcda088bbcb7a959b5b35c8e5195ed64fcd67daa3cd9384f6272b
|
||||
|
||||
Locally updated copies (downloaded by the master's weekly updater
|
||||
into the data folder as geoip-country.mmdb) supersede this snapshot
|
||||
at runtime; they are the same database from the same source.
|
||||
Binary file not shown.
@@ -1253,12 +1253,6 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if _, err := database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
|
||||
return err
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
nodeID := *inbound.NodeID
|
||||
if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, inbound.Tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if inbound.Enable {
|
||||
if inbound.NodeID != nil {
|
||||
markDirty = true
|
||||
@@ -1792,13 +1786,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
// the move handling off nodeChanged, not off the stored value.
|
||||
oldInbound.NodeID = targetNodeID
|
||||
if nodeChanged {
|
||||
// Move: validate the resolved tag against the target node's
|
||||
// namespace, exactly like a same-node edit does for its node.
|
||||
if targetNodeID != nil {
|
||||
if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, *targetNodeID, oldInbound.Tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Node sides converge through the dirty marks written at the
|
||||
// end of this transaction (source + target): the source node's
|
||||
// next reconcile sweeps the inbound's old tag, the target's
|
||||
@@ -1912,11 +1899,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nodeID := *oldInbound.NodeID
|
||||
if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, oldInbound.Tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Save(oldInbound).Error; err != nil {
|
||||
|
||||
@@ -9,16 +9,8 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) {
|
||||
db := database.GetDB()
|
||||
var ips []model.InboundClientIps
|
||||
err := db.Model(&model.InboundClientIps{}).Find(&ips).Error
|
||||
return ips, err
|
||||
}
|
||||
|
||||
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
|
||||
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
|
||||
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
|
||||
@@ -59,98 +51,6 @@ func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientI
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeInboundClientIps folds client IPs synced from another node into the local
|
||||
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
|
||||
// without resurrecting stale entries. Existing rows are updated in place; brand-new
|
||||
// clients (typically node-only clients with no local row) are created with a fresh
|
||||
// local id.
|
||||
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
|
||||
db := database.GetDB()
|
||||
var currentIps []model.InboundClientIps
|
||||
if err := db.Model(&model.InboundClientIps{}).Find(¤tIps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
|
||||
for i := range currentIps {
|
||||
currentMap[currentIps[i].ClientEmail] = ¤tIps[i]
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
cutoff := now - clientIpStaleAfterSeconds
|
||||
|
||||
// Node syncs run concurrently (one goroutine per node) and shared clients
|
||||
// appear in several nodes' reports. Locking rows in each node's arbitrary
|
||||
// report order lets two merges grab the same rows in opposite order, which
|
||||
// Postgres aborts as a deadlock (40P01) — take them in one global order.
|
||||
sort.Slice(incomingIps, func(i, j int) bool {
|
||||
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
|
||||
})
|
||||
|
||||
tx := db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, incoming := range incomingIps {
|
||||
if incoming.ClientEmail == "" || incoming.Ips == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var incomingEntries []clientIpEntry
|
||||
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
|
||||
|
||||
current, exists := currentMap[incoming.ClientEmail]
|
||||
if !exists {
|
||||
// New client we've never seen locally. Drop stale entries up front and
|
||||
// skip the row entirely if nothing is fresh, so we don't persist a row
|
||||
// that is dead on arrival.
|
||||
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
|
||||
if len(fresh) == 0 {
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(fresh)
|
||||
incoming.Ips = string(b)
|
||||
// Never carry the remote node's primary key into the local table: id
|
||||
// spaces are independent across nodes and the remote id would collide
|
||||
// with an unrelated local row. OnConflict guards the race where
|
||||
// check_client_ip_job creates the same brand-new email between the
|
||||
// snapshot above and this insert.
|
||||
incoming.Id = 0
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client_email"}},
|
||||
DoNothing: true,
|
||||
}).Create(&incoming).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var oldEntries []clientIpEntry
|
||||
if current.Ips != "" {
|
||||
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
|
||||
}
|
||||
|
||||
merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
|
||||
b, _ := json.Marshal(merged)
|
||||
mergedStr := string(b)
|
||||
|
||||
// A concurrent check_client_ip_job db.Save on the same row can interleave
|
||||
// with this update (benign last-writer-wins; any dropped IP reappears on the
|
||||
// next scan/sync), so only write when the blob actually changed.
|
||||
if current.Ips != mergedStr {
|
||||
if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
|
||||
// The caller only renames onto a free identity, so a row already sitting on
|
||||
// newEmail is stale tracking data — drop it instead of failing the edit.
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
|
||||
// for a single test, mirroring the harness used by the other service tests.
|
||||
func setupClientIpTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
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() })
|
||||
}
|
||||
|
||||
func marshalIps(t *testing.T, entries ...clientIpEntry) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ips: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// readClientIps returns the stored IP entries for an email as a map[ip]timestamp,
|
||||
// plus whether the row exists at all.
|
||||
func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
|
||||
t.Helper()
|
||||
var row model.InboundClientIps
|
||||
err := database.GetDB().Where("client_email = ?", email).First(&row).Error
|
||||
if database.IsNotFound(err) {
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read client ips for %s: %v", email, err)
|
||||
}
|
||||
var entries []clientIpEntry
|
||||
if row.Ips != "" {
|
||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
out := make(map[string]int64, len(entries))
|
||||
for _, e := range entries {
|
||||
out[e.IP] = e.Timestamp
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
db := database.GetDB()
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Local client occupies id 1.
|
||||
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
|
||||
if err := db.Create(local).Error; err != nil {
|
||||
t.Fatalf("seed local row: %v", err)
|
||||
}
|
||||
|
||||
// Incoming node-only client carries the remote node's id 1, which must not
|
||||
// collide with the local row.
|
||||
incoming := []model.InboundClientIps{{
|
||||
Id: 1,
|
||||
ClientEmail: "node@x",
|
||||
Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
|
||||
}}
|
||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
// Local row is untouched.
|
||||
if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now {
|
||||
t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok)
|
||||
}
|
||||
|
||||
// Node row exists with its own ip and a freshly assigned id (not the remote 1).
|
||||
var nodeRow model.InboundClientIps
|
||||
if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil {
|
||||
t.Fatalf("node@x not created: %v", err)
|
||||
}
|
||||
if nodeRow.Id == local.Id {
|
||||
t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id)
|
||||
}
|
||||
if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now {
|
||||
t.Fatalf("node@x missing expected ip: %v", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
db := database.GetDB()
|
||||
now := time.Now().Unix()
|
||||
|
||||
if err := db.Create(&model.InboundClientIps{
|
||||
ClientEmail: "a@x",
|
||||
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
incoming := []model.InboundClientIps{{
|
||||
ClientEmail: "a@x",
|
||||
Ips: marshalIps(t,
|
||||
clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
|
||||
clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
|
||||
),
|
||||
}}
|
||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
ips, _ := readClientIps(t, "a@x")
|
||||
if len(ips) != 2 {
|
||||
t.Fatalf("want 2 ips, got %v", ips)
|
||||
}
|
||||
if ips["1.1.1.1"] != now-50 {
|
||||
t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"])
|
||||
}
|
||||
if ips["2.2.2.2"] != now-10 {
|
||||
t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
db := database.GetDB()
|
||||
now := time.Now().Unix()
|
||||
|
||||
if err := db.Create(&model.InboundClientIps{
|
||||
ClientEmail: "a@x",
|
||||
Ips: marshalIps(t,
|
||||
clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
|
||||
clientIpEntry{IP: "fresh", Timestamp: now - 60},
|
||||
),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
incoming := []model.InboundClientIps{{
|
||||
ClientEmail: "a@x",
|
||||
Ips: marshalIps(t,
|
||||
clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
|
||||
clientIpEntry{IP: "incFresh", Timestamp: now - 10},
|
||||
),
|
||||
}}
|
||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
ips, _ := readClientIps(t, "a@x")
|
||||
if len(ips) != 2 {
|
||||
t.Fatalf("want only fresh ips, got %v", ips)
|
||||
}
|
||||
if _, ok := ips["old"]; ok {
|
||||
t.Fatalf("stale local ip not dropped: %v", ips)
|
||||
}
|
||||
if _, ok := ips["incStale"]; ok {
|
||||
t.Fatalf("stale incoming ip not dropped: %v", ips)
|
||||
}
|
||||
if ips["fresh"] != now-60 || ips["incFresh"] != now-10 {
|
||||
t.Fatalf("fresh ips wrong: %v", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
|
||||
incoming := []model.InboundClientIps{{
|
||||
ClientEmail: "b@x",
|
||||
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
|
||||
}}
|
||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := readClientIps(t, "b@x"); ok {
|
||||
t.Fatalf("all-stale node-only client should not create a row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
|
||||
incoming := []model.InboundClientIps{
|
||||
{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
|
||||
{ClientEmail: "c@x", Ips: ""},
|
||||
}
|
||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("blank rows should be skipped, but %d row(s) created", count)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,67 +119,6 @@ func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[str
|
||||
return upsertNodeClientIps(panelGuid, observed)
|
||||
}
|
||||
|
||||
// MergeClientIpsByGuid folds a node's guid-keyed attribution report (its own
|
||||
// panelGuid subtree plus any descendants) into the local table, preserving which
|
||||
// physical node each IP is on across a chain. When node is non-nil and its own
|
||||
// panelGuid is ambiguous (shared with another node or the master — a cloned
|
||||
// server), the node's own subtree is remapped to its node-unique key so two
|
||||
// clones don't collapse into one attribution row; descendant subtrees keep their
|
||||
// distinct GUIDs. A nil node merges the report verbatim.
|
||||
func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string]map[string][]model.ClientIpEntry) error {
|
||||
if node != nil && node.Guid != "" {
|
||||
if eff := effectiveNodeKey(node); eff != node.Guid {
|
||||
if sub, ok := trees[node.Guid]; ok {
|
||||
delete(trees, node.Guid)
|
||||
if existing, ok := trees[eff]; ok {
|
||||
for email, ips := range sub {
|
||||
existing[email] = append(existing[email], ips...)
|
||||
}
|
||||
} else {
|
||||
trees[eff] = sub
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for guid, perEmail := range trees {
|
||||
if err := upsertNodeClientIps(guid, perEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientIpsByGuid returns this panel's full attribution subtree (guid -> email
|
||||
// -> fresh IPs), dropping stale entries. It is what the clientIpsByGuid endpoint
|
||||
// serves to a parent panel.
|
||||
func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.ClientIpEntry, error) {
|
||||
db := database.GetDB()
|
||||
var rows []model.NodeClientIp
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
|
||||
out := make(map[string]map[string][]model.ClientIpEntry)
|
||||
for _, row := range rows {
|
||||
if row.NodeGuid == "" || row.Email == "" || row.Ips == "" {
|
||||
continue
|
||||
}
|
||||
var entries []model.ClientIpEntry
|
||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
fresh := mergeModelClientIpEntries(nil, entries, cutoff)
|
||||
if len(fresh) == 0 {
|
||||
continue
|
||||
}
|
||||
if out[row.NodeGuid] == nil {
|
||||
out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
|
||||
}
|
||||
out[row.NodeGuid][row.Email] = fresh
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetClientIpNodeAttribution returns, for one client email, a map of IP -> the
|
||||
// guid that most recently observed it (within the stale window). Used to label
|
||||
// each IP in the panel with the node it is connecting to.
|
||||
|
||||
@@ -9,6 +9,37 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// clientIpTrees reads the node client-IP store back as guid -> email ->
|
||||
// entries (fresh entries only). It replaces the removed GetClientIpsByGuid
|
||||
// service read, whose only remaining consumers were these tests.
|
||||
func clientIpTrees(t *testing.T) map[string]map[string][]model.ClientIpEntry {
|
||||
t.Helper()
|
||||
var rows []model.NodeClientIp
|
||||
if err := database.GetDB().Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read ip store: %v", err)
|
||||
}
|
||||
cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
|
||||
out := make(map[string]map[string][]model.ClientIpEntry)
|
||||
for _, row := range rows {
|
||||
if row.NodeGuid == "" || row.Email == "" || row.Ips == "" {
|
||||
continue
|
||||
}
|
||||
var entries []model.ClientIpEntry
|
||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
fresh := mergeModelClientIpEntries(nil, entries, cutoff)
|
||||
if len(fresh) == 0 {
|
||||
continue
|
||||
}
|
||||
if out[row.NodeGuid] == nil {
|
||||
out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
|
||||
}
|
||||
out[row.NodeGuid][row.Email] = fresh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
@@ -20,10 +51,7 @@ func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
trees, err := svc.GetClientIpsByGuid()
|
||||
if err != nil {
|
||||
t.Fatalf("byGuid: %v", err)
|
||||
}
|
||||
trees := clientIpTrees(t)
|
||||
got := trees["guid-A"]["u@x"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 entries, got %v", got)
|
||||
@@ -50,7 +78,7 @@ func TestRecordLocalClientIps_MergesAndDropsStale(t *testing.T) {
|
||||
t.Fatalf("record 2: %v", err)
|
||||
}
|
||||
|
||||
trees, _ := svc.GetClientIpsByGuid()
|
||||
trees := clientIpTrees(t)
|
||||
got := map[string]int64{}
|
||||
for _, e := range trees["g"]["u@x"] {
|
||||
got[e.IP] = e.Timestamp
|
||||
@@ -103,8 +131,8 @@ func TestGetClientIpNodeAttribution_NewestGuidWins(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("record gA: %v", err)
|
||||
}
|
||||
if err := svc.MergeClientIpsByGuid(nil, map[string]map[string][]model.ClientIpEntry{
|
||||
"gB": {"u@x": {{IP: "9.9.9.9", Timestamp: now}}},
|
||||
if err := upsertNodeClientIps("gB", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "9.9.9.9", Timestamp: now}},
|
||||
}); err != nil {
|
||||
t.Fatalf("merge gB: %v", err)
|
||||
}
|
||||
@@ -145,8 +173,8 @@ func TestGetClientIpsWithNodes_LabelsNodes(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("record local: %v", err)
|
||||
}
|
||||
if err := svc.MergeClientIpsByGuid(nil, map[string]map[string][]model.ClientIpEntry{
|
||||
"node-guid": {"u@x": {{IP: "2.2.2.2", Timestamp: now}}},
|
||||
if err := upsertNodeClientIps("node-guid", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "2.2.2.2", Timestamp: now}},
|
||||
}); err != nil {
|
||||
t.Fatalf("merge node: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,131 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/xray"
|
||||
)
|
||||
|
||||
// statefulFakePanel is a node panel double with real state: added inbounds
|
||||
// appear in its list, deleted ones disappear. It records every mutation so
|
||||
// move tests can assert what reconcile actually pushed where.
|
||||
type statefulFakePanel struct {
|
||||
mu sync.Mutex
|
||||
byTag map[string]int
|
||||
byID map[int]string
|
||||
nextID int
|
||||
added []string
|
||||
updated []int
|
||||
deleted []int
|
||||
}
|
||||
|
||||
func newStatefulFakePanel(t *testing.T, seed map[string]int) (*httptest.Server, *statefulFakePanel) {
|
||||
// moveTestNode creates a plain online node row for move tests. Nodes
|
||||
// are never dialed anymore, so no fake panel stands behind the row.
|
||||
func moveTestNode(t *testing.T, name string) *model.Node {
|
||||
t.Helper()
|
||||
p := &statefulFakePanel{byTag: map[string]int{}, byID: map[int]string{}, nextID: 1000}
|
||||
for tag, id := range seed {
|
||||
p.byTag[tag] = id
|
||||
p.byID[id] = tag
|
||||
n := &model.Node{
|
||||
Name: name,
|
||||
Scheme: "http",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
Enable: true,
|
||||
Status: "online",
|
||||
InboundsAdoptedAt: 1,
|
||||
}
|
||||
writeOK := func(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||
if err := database.GetDB().Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||
type row struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Listen string `json:"listen"`
|
||||
}
|
||||
p.mu.Lock()
|
||||
rows := make([]row, 0, len(p.byTag))
|
||||
for tag, id := range p.byTag {
|
||||
rows = append(rows, row{Id: id, Tag: tag})
|
||||
}
|
||||
p.mu.Unlock()
|
||||
writeOK(w, rows)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tag := r.Form.Get("tag")
|
||||
p.mu.Lock()
|
||||
p.nextID++
|
||||
id := p.nextID
|
||||
p.byTag[tag] = id
|
||||
p.byID[id] = tag
|
||||
p.added = append(p.added, tag)
|
||||
p.mu.Unlock()
|
||||
writeOK(w, map[string]any{"id": id, "tag": tag})
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.updated = append(p.updated, id)
|
||||
p.mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if tag, ok := p.byID[id]; ok {
|
||||
delete(p.byID, id)
|
||||
delete(p.byTag, tag)
|
||||
}
|
||||
p.deleted = append(p.deleted, id)
|
||||
p.mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, p
|
||||
}
|
||||
|
||||
func (p *statefulFakePanel) tags() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]string, 0, len(p.byTag))
|
||||
for tag := range p.byTag {
|
||||
out = append(out, tag)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *statefulFakePanel) addedTags() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.added...)
|
||||
}
|
||||
|
||||
func (p *statefulFakePanel) deletedIDs() []int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]int(nil), p.deleted...)
|
||||
return n
|
||||
}
|
||||
|
||||
const moveTestSettings = `{"clients":[{"id":"11111111-2222-3333-4444-555555555555","email":"mv@example.com","enable":true}]}`
|
||||
@@ -177,10 +80,8 @@ func clientTrafficTotals(t *testing.T, email string) (int64, int64) {
|
||||
func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
tsA, panelA := newStatefulFakePanel(t, map[string]int{"movable": 11})
|
||||
tsB, panelB := newStatefulFakePanel(t, nil)
|
||||
nodeA := reconcileTestNode(t, tsA, "move-a", "all", nil)
|
||||
nodeB := reconcileTestNode(t, tsB, "move-b", "all", nil)
|
||||
nodeA := moveTestNode(t, "move-a")
|
||||
nodeB := moveTestNode(t, "move-b")
|
||||
|
||||
seeded := seedMoveInbound(t, "movable", 2443, &nodeA.Id)
|
||||
|
||||
@@ -205,28 +106,8 @@ func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) {
|
||||
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
|
||||
t.Fatalf("client traffic changed across move: up=%d down=%d, want 111/222", up, down)
|
||||
}
|
||||
|
||||
// Reconcile the source: the moved inbound must be swept from panel A.
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeA, nil), nodeA); err != nil {
|
||||
t.Fatalf("ReconcileNode source: %v", err)
|
||||
}
|
||||
if got := panelA.deletedIDs(); len(got) != 1 || got[0] != 11 {
|
||||
t.Fatalf("source panel deletions = %v, want [11]", got)
|
||||
}
|
||||
if tags := panelA.tags(); len(tags) != 0 {
|
||||
t.Fatalf("source panel still holds tags %v after reconcile", tags)
|
||||
}
|
||||
|
||||
// Reconcile the target: the inbound must be pushed to panel B.
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeB, nil), nodeB); err != nil {
|
||||
t.Fatalf("ReconcileNode target: %v", err)
|
||||
}
|
||||
if got := panelB.addedTags(); len(got) != 1 || got[0] != "movable" {
|
||||
t.Fatalf("target panel additions = %v, want [movable]", got)
|
||||
}
|
||||
if tags := panelB.tags(); len(tags) != 1 || tags[0] != "movable" {
|
||||
t.Fatalf("target panel tags = %v, want [movable]", tags)
|
||||
}
|
||||
// Convergence itself is the nodes' next config pull (both sides are
|
||||
// dirty-marked above); the master no longer pushes to node panels.
|
||||
}
|
||||
|
||||
// An auto-generated tag carries the node prefix: a move re-derives it under
|
||||
@@ -234,10 +115,8 @@ func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) {
|
||||
func TestUpdateInbound_MoveRegeneratesAutoTagPrefix(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
tsA, _ := newStatefulFakePanel(t, nil)
|
||||
tsB, _ := newStatefulFakePanel(t, nil)
|
||||
nodeA := reconcileTestNode(t, tsA, "tag-a", "all", nil)
|
||||
nodeB := reconcileTestNode(t, tsB, "tag-b", "all", nil)
|
||||
nodeA := moveTestNode(t, "tag-a")
|
||||
nodeB := moveTestNode(t, "tag-b")
|
||||
|
||||
oldTag := fmt.Sprintf("n%d-in-2444-tcp", nodeA.Id)
|
||||
seeded := seedMoveInbound(t, oldTag, 2444, &nodeA.Id)
|
||||
@@ -262,8 +141,7 @@ func TestUpdateInbound_MoveRegeneratesAutoTagPrefix(t *testing.T) {
|
||||
func TestUpdateInbound_MoveFlipsAgentConfig(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
tsA, _ := newStatefulFakePanel(t, nil)
|
||||
nodePanel := reconcileTestNode(t, tsA, "cfg-panel", "all", nil)
|
||||
nodePanel := moveTestNode(t, "cfg-panel")
|
||||
agentNode := &model.Node{Name: "cfg-agent", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
if err := database.GetDB().Create(agentNode).Error; err != nil {
|
||||
t.Fatalf("create agent node row: %v", err)
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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/web/runtime"
|
||||
)
|
||||
|
||||
// The traditional-node path: NodeTrafficSyncJob fetches a TrafficSnapshot
|
||||
// from each node panel (runtime.Remote.FetchTrafficSnapshot) and hands it
|
||||
// to InboundService.SetRemoteTraffic. Before the online-store restoration,
|
||||
// the snapshot's OnlineTree / ActiveInboundTree were dropped on the floor
|
||||
// inside that call (the store setters were no-op stubs), so polled nodes
|
||||
// never showed anyone online. This drives the exact public entry the job
|
||||
// calls and asserts the snapshot's online data lands in the store.
|
||||
func TestSetRemoteTraffic_FeedsOnlineStore(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
svc := InboundService{}
|
||||
|
||||
const nodeID = 4242
|
||||
const nodeGuid = "trad-node-guid-4242"
|
||||
svc.ClearNodeOnlineClients(nodeID)
|
||||
t.Cleanup(func() { svc.ClearNodeOnlineClients(nodeID) })
|
||||
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "trad-node",
|
||||
Address: "10.0.4.2",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: nodeGuid,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"trad@example.com"}]}`,
|
||||
},
|
||||
},
|
||||
OnlineTree: map[string][]string{nodeGuid: {"trad@example.com"}},
|
||||
ActiveInboundTree: map[string][]string{nodeGuid: {"in-443-tcp"}},
|
||||
}
|
||||
|
||||
if _, err := svc.SetRemoteTraffic(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("SetRemoteTraffic: %v", err)
|
||||
}
|
||||
|
||||
assertStringSet(t, "online union", svc.GetOnlineClients(), []string{"trad@example.com"})
|
||||
byGuid := svc.GetOnlineClientsByGuid()
|
||||
assertStringSet(t, "online byGuid", byGuid[nodeGuid], []string{"trad@example.com"})
|
||||
|
||||
// The active tree is normalized from node-side tags to central tags;
|
||||
// whatever tag the adopted central inbound carries is what must show.
|
||||
var central model.Inbound
|
||||
if err := db.Where("node_id = ?", nodeID).First(¢ral).Error; err != nil {
|
||||
t.Fatalf("load central inbound: %v", err)
|
||||
}
|
||||
active := svc.GetActiveInboundsByGuid()
|
||||
assertStringSet(t, "active byGuid", active[nodeGuid], []string{central.Tag})
|
||||
|
||||
// A failed probe makes the job clear the node; its clients must stop
|
||||
// showing as online immediately.
|
||||
svc.ClearNodeOnlineClients(nodeID)
|
||||
if got := svc.GetOnlineClients(); len(got) != 0 {
|
||||
t.Fatalf("online union after clear = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Old-build nodes have no GUID tree endpoint: the job passes their flat
|
||||
// OnlineEmails in the snapshot and SetRemoteTraffic must key them under
|
||||
// the node's own effective identity.
|
||||
func TestSetRemoteTraffic_FlatOnlineEmailsFallback(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
svc := InboundService{}
|
||||
|
||||
const nodeID = 4243
|
||||
const nodeGuid = "flat-node-guid-4243"
|
||||
svc.ClearNodeOnlineClients(nodeID)
|
||||
t.Cleanup(func() { svc.ClearNodeOnlineClients(nodeID) })
|
||||
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "flat-node",
|
||||
Address: "10.0.4.3",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: nodeGuid,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{
|
||||
Tag: "in-8443-tcp",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"flat@example.com"}]}`,
|
||||
},
|
||||
},
|
||||
OnlineEmails: []string{"flat@example.com"},
|
||||
}
|
||||
|
||||
if _, err := svc.SetRemoteTraffic(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("SetRemoteTraffic: %v", err)
|
||||
}
|
||||
|
||||
assertStringSet(t, "online union (flat)", svc.GetOnlineClients(), []string{"flat@example.com"})
|
||||
byGuid := svc.GetOnlineClientsByGuid()
|
||||
assertStringSet(t, "online byGuid (flat)", byGuid[nodeGuid], []string{"flat@example.com"})
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeNodePanel serves just enough of the node API for ReconcileNode: the
|
||||
// inbound list plus update/del endpoints, recording which remote ids get
|
||||
// deleted.
|
||||
func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var deleted []int
|
||||
writeOK := func(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||
type row struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
rows := make([]row, 0, len(tagToID))
|
||||
for tag, id := range tagToID {
|
||||
rows = append(rows, row{Id: id, Tag: tag})
|
||||
}
|
||||
writeOK(w, rows)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeOK(w, nil)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
deleted = append(deleted, id)
|
||||
mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, func() []int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
out := append([]int(nil), deleted...)
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
|
||||
t.Helper()
|
||||
u, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server URL: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server port: %v", err)
|
||||
}
|
||||
n := &model.Node{
|
||||
Name: name,
|
||||
Scheme: "http",
|
||||
Address: u.Hostname(),
|
||||
Port: port,
|
||||
BasePath: "/",
|
||||
ApiToken: "tok",
|
||||
Enable: true,
|
||||
AllowPrivateAddress: true,
|
||||
Status: "online",
|
||||
InboundSyncMode: mode,
|
||||
InboundTags: tags,
|
||||
InboundsAdoptedAt: 1,
|
||||
}
|
||||
if err := database.GetDB().Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// In "selected" sync mode the panel never imports the unselected inbounds, so
|
||||
// reconcile must not treat their absence from the local DB as a deletion: only
|
||||
// a *selected* tag missing locally may be swept from the node.
|
||||
func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ts, deletedIDs := fakeNodePanel(t, map[string]int{
|
||||
"keep": 1,
|
||||
"selected-gone": 2,
|
||||
"unmanaged": 3,
|
||||
})
|
||||
node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
|
||||
seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
|
||||
|
||||
svc := InboundService{}
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
|
||||
t.Fatalf("ReconcileNode: %v", err)
|
||||
}
|
||||
|
||||
got := deletedIDs()
|
||||
if len(got) != 1 || got[0] != 2 {
|
||||
t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// "all" mode keeps the original anti-entropy contract: every remote inbound
|
||||
// missing from the local DB is deleted on the node.
|
||||
func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ts, deletedIDs := fakeNodePanel(t, map[string]int{
|
||||
"keep": 1,
|
||||
"gone-a": 2,
|
||||
"gone-b": 3,
|
||||
})
|
||||
node := reconcileTestNode(t, ts, "all-node", "all", nil)
|
||||
seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
|
||||
|
||||
svc := InboundService{}
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
|
||||
t.Fatalf("ReconcileNode: %v", err)
|
||||
}
|
||||
|
||||
got := deletedIDs()
|
||||
if len(got) != 2 || got[0] != 2 || got[1] != 3 {
|
||||
t.Fatalf("deleted remote ids = %v, want [2 3]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A node whose pre-existing inbounds were never adopted into the central DB
|
||||
// has zero local rows for legitimate reasons: reconcile before that first
|
||||
// adoption must not sweep — it would delete every real inbound on the node
|
||||
// right after onboarding (add node, save it again, watch it get wiped).
|
||||
func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ts, deletedIDs := fakeNodePanel(t, map[string]int{
|
||||
"real-a": 1,
|
||||
"real-b": 2,
|
||||
"real-c": 3,
|
||||
})
|
||||
node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
|
||||
node.InboundsAdoptedAt = 0
|
||||
|
||||
svc := InboundService{}
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
|
||||
t.Fatalf("ReconcileNode: %v", err)
|
||||
}
|
||||
|
||||
if got := deletedIDs(); len(got) != 0 {
|
||||
t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One inbound the node rejects (e.g. a legacy protocol failing the node's
|
||||
// request validation, #5685) must not abort the reconcile: the healthy inbound
|
||||
// is still pushed, the delete sweep still runs, and the returned error names
|
||||
// the failed tag so the caller keeps the dirty flag set for retry.
|
||||
func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
updated := map[int]int{}
|
||||
var deleted []int
|
||||
tagToID := map[string]int{"legacy": 1, "healthy": 2, "gone": 3}
|
||||
writeOK := func(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||
type row struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
rows := make([]row, 0, len(tagToID))
|
||||
for tag, id := range tagToID {
|
||||
rows = append(rows, row{Id: id, Tag: tag})
|
||||
}
|
||||
writeOK(w, rows)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if id == tagToID["legacy"] {
|
||||
http.Error(w, "request body failed validation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
updated[id]++
|
||||
mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
deleted = append(deleted, id)
|
||||
mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
node := reconcileTestNode(t, ts, "half-broken-node", "all", nil)
|
||||
seedInboundConflictNode(t, "legacy", "", 1080, model.Protocol("socks"), ``, `{"auth":"noauth"}`, &node.Id)
|
||||
seedInboundConflictNode(t, "healthy", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
|
||||
|
||||
svc := InboundService{}
|
||||
err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
|
||||
if err == nil {
|
||||
t.Fatal("ReconcileNode: want an error naming the rejected inbound, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `reconcile inbound "legacy"`) {
|
||||
t.Fatalf("ReconcileNode error = %q, want it to name inbound \"legacy\"", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
healthyPushes := updated[tagToID["healthy"]]
|
||||
gotDeleted := append([]int(nil), deleted...)
|
||||
mu.Unlock()
|
||||
if healthyPushes != 1 {
|
||||
t.Fatalf("healthy inbound pushed %d times, want 1", healthyPushes)
|
||||
}
|
||||
sort.Ints(gotDeleted)
|
||||
if len(gotDeleted) != 1 || gotDeleted[0] != tagToID["gone"] {
|
||||
t.Fatalf("deleted remote ids = %v, want [%d] (sweep must still run past the failure)", gotDeleted, tagToID["gone"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNode_AdoptsCompatibleOriginInboundWithoutRemoteMutation(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
mutations := 0
|
||||
writeOK := func(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeOK(w, []map[string]any{{"id": 41, "tag": "already-deployed", "listen": "", "port": 8443, "protocol": "vless"}})
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mu.Lock()
|
||||
mutations++
|
||||
mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
node := reconcileTestNode(t, ts, "adopt-node", "all", nil)
|
||||
node.Guid = "origin-guid"
|
||||
if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
|
||||
t.Fatalf("update node guid: %v", err)
|
||||
}
|
||||
seedInboundConflictNode(t, "desired-name", "", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
|
||||
t.Fatalf("set origin guid: %v", err)
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
rt := runtime.NewRemote(node, nil)
|
||||
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
|
||||
t.Fatalf("first ReconcileNode: %v", err)
|
||||
}
|
||||
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
|
||||
t.Fatalf("second ReconcileNode: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
got := mutations
|
||||
mu.Unlock()
|
||||
if got != 0 {
|
||||
t.Fatalf("remote mutations = %d, want 0 while adopting compatible deployed inbound", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNode_AmbiguousCompatibleInboundsAreNotSwept(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ts, deletedIDs := fakeNodePanel(t, map[string]int{"alias-a": 51, "alias-b": 52})
|
||||
node := reconcileTestNode(t, ts, "ambiguous-node", "all", nil)
|
||||
node.Guid = "origin-guid"
|
||||
if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
|
||||
t.Fatalf("update node guid: %v", err)
|
||||
}
|
||||
seedInboundConflictNode(t, "desired-name", "", 0, model.Protocol(""), `{}`, `{"clients":[]}`, &node.Id)
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
|
||||
t.Fatalf("set origin guid: %v", err)
|
||||
}
|
||||
|
||||
err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous compatible remote inbounds") {
|
||||
t.Fatalf("ReconcileNode error = %v, want ambiguity error", err)
|
||||
}
|
||||
if got := deletedIDs(); len(got) != 0 {
|
||||
t.Fatalf("deleted ambiguous candidates = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNode_IncompatiblePortOccupantRemainsLoud(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
writeOK := func(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeOK(w, []map[string]any{{"id": 42, "tag": "port-owner", "listen": "", "port": 9443, "protocol": "trojan"}})
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "msg": "port already occupied", "obj": nil})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
node := reconcileTestNode(t, ts, "drift-node", "all", nil)
|
||||
node.Guid = "origin-guid"
|
||||
seedInboundConflictNode(t, "desired-name", "", 9443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
|
||||
t.Fatalf("set origin guid: %v", err)
|
||||
}
|
||||
|
||||
err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
|
||||
if err == nil || !strings.Contains(err.Error(), "port already occupied") {
|
||||
t.Fatalf("ReconcileNode error = %v, want loud incompatible-port error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInboundTagAllowed(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
svc := NodeService{}
|
||||
|
||||
selected := &model.Node{
|
||||
Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
|
||||
InboundSyncMode: "selected", InboundTags: []string{"a"},
|
||||
}
|
||||
if err := db.Create(selected).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
|
||||
t.Fatalf("EnsureInboundTagAllowed add: %v", err)
|
||||
}
|
||||
var got model.Node
|
||||
if err := db.First(&got, selected.Id).Error; err != nil {
|
||||
t.Fatalf("reload node: %v", err)
|
||||
}
|
||||
if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
|
||||
t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
|
||||
}
|
||||
|
||||
if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
|
||||
t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
|
||||
}
|
||||
if err := db.First(&got, selected.Id).Error; err != nil {
|
||||
t.Fatalf("reload node: %v", err)
|
||||
}
|
||||
if len(got.InboundTags) != 2 {
|
||||
t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
|
||||
}
|
||||
|
||||
all := &model.Node{
|
||||
Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
|
||||
InboundSyncMode: "all",
|
||||
}
|
||||
if err := db.Create(all).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
|
||||
t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
|
||||
}
|
||||
var gotAll model.Node
|
||||
if err := db.First(&gotAll, all.Id).Error; err != nil {
|
||||
t.Fatalf("reload node: %v", err)
|
||||
}
|
||||
if len(gotAll.InboundTags) != 0 {
|
||||
t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
// Removing a central inbound also drops its clients' traffic history, so the
|
||||
// sweep must name what it removed — a silent drop is undiagnosable in the field.
|
||||
// captureLogLines installs a logger hook collecting "LEVEL message" lines
|
||||
// for the rest of the test. It replaces reads of the in-memory log buffer,
|
||||
// which was removed together with the web log viewer.
|
||||
func captureLogLines(t *testing.T) *[]string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
lines := &[]string{}
|
||||
prev := logger.SetLogHook(func(level, msg string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
*lines = append(*lines, level+" "+msg)
|
||||
})
|
||||
t.Cleanup(func() { logger.SetLogHook(prev) })
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) {
|
||||
logger.InitLogger(logging.WARNING)
|
||||
logLines := captureLogLines(t)
|
||||
setupBulkDB(t)
|
||||
nodeID, _ := setupNodeRuntime(t)
|
||||
|
||||
gone := nodeInbound(t, nodeID, 31777, nil)
|
||||
survivor := nodeInbound(t, nodeID, 31778, nil)
|
||||
|
||||
// The snapshot reports only the survivor, so the other row is swept.
|
||||
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
|
||||
Tag: survivor.Tag, Port: survivor.Port, Protocol: model.VLESS, Enable: true,
|
||||
Settings: survivor.Settings,
|
||||
}}}
|
||||
if _, err := (&InboundService{}).setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var remaining int64
|
||||
if err := database.GetDB().Model(model.Inbound{}).Where("id = ?", gone.Id).Count(&remaining).Error; err != nil {
|
||||
t.Fatalf("count swept inbound: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Fatalf("fixture did not sweep inbound %d; the log assertion below would be meaningless", gone.Id)
|
||||
}
|
||||
|
||||
want := strconv.Itoa(gone.Id)
|
||||
for _, line := range *logLines {
|
||||
if strings.Contains(line, gone.Tag) && strings.Contains(line, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("sweep removed inbound %q (id %d) without naming it in the log", gone.Tag, gone.Id)
|
||||
}
|
||||
@@ -26,15 +26,11 @@ import (
|
||||
const depletedClientsClause = "reset = 0 and reset_day = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
|
||||
|
||||
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
|
||||
var disabledNodeIDs []int
|
||||
err = submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
needRestart, clientsDisabled, _, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
return inner
|
||||
})
|
||||
if err == nil && len(disabledNodeIDs) > 0 {
|
||||
s.restartRemoteNodesOnDisable(disabledNodeIDs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -161,34 +161,6 @@ func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) {
|
||||
overlayGlobalTraffic(db, ptrs)
|
||||
}
|
||||
|
||||
// GetNodeClientTraffics returns this panel's aggregated traffic rows for the
|
||||
// clients known to live on the given node (those with a delta baseline) —
|
||||
// the payload for Remote.PushGlobalClientTraffics. The rows carry the global
|
||||
// overlay so a mid-chain panel forwards the widest view it has seen, not just
|
||||
// its own aggregate.
|
||||
func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
var emails []string
|
||||
if err := db.Model(&model.NodeClientTraffic{}).
|
||||
Where("node_id = ?", nodeID).
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]*xray.ClientTraffic, 0, len(emails))
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var page []*xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, page...)
|
||||
}
|
||||
overlayGlobalTraffic(db, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// overlayInboundsClientStats applies the global overlay to every preloaded
|
||||
// ClientStats row across the given inbounds. UI read paths only — never the
|
||||
// full /panel/api/inbounds/list payload, which doubles as the traffic
|
||||
|
||||
+85
-608
@@ -2,15 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,14 +12,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -54,70 +46,6 @@ type HeartbeatPatch struct {
|
||||
|
||||
type NodeService struct{}
|
||||
|
||||
// FetchCertFingerprint connects to the node over HTTPS without verifying the
|
||||
// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
|
||||
// can offer a "fetch and pin current certificate" action.
|
||||
func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
|
||||
addr, err := netsafe.NormalizeHost(n.Address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheme := n.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if scheme != "https" {
|
||||
return "", common.NewError("certificate pinning is only available for https nodes")
|
||||
}
|
||||
if n.Port <= 0 || n.Port > 65535 {
|
||||
return "", common.NewError("node port must be 1-65535")
|
||||
}
|
||||
probeURL := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
|
||||
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
|
||||
http.MethodGet, probeURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check]
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
||||
return "", common.NewError("node did not present a TLS certificate")
|
||||
}
|
||||
sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
|
||||
return base64.StdEncoding.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// decryptToken exposes plaintext to callers. Failures blank only this token
|
||||
// and surface through LastError instead of dropping the node row.
|
||||
func decryptToken(n *model.Node) {
|
||||
if n == nil || n.ApiToken == "" {
|
||||
return
|
||||
}
|
||||
pt, err := nodetoken.Decrypt(n.Id, n.ApiToken)
|
||||
if err != nil {
|
||||
n.ApiToken = ""
|
||||
if n.LastError == "" {
|
||||
n.LastError = "token decrypt failed: " + err.Error()
|
||||
}
|
||||
return
|
||||
}
|
||||
n.ApiToken = pt
|
||||
}
|
||||
|
||||
func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||
db := database.GetDB()
|
||||
var nodes []*model.Node
|
||||
@@ -125,9 +53,6 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||
if err != nil || len(nodes) == 0 {
|
||||
return nodes, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
decryptToken(n)
|
||||
}
|
||||
|
||||
type inboundRow struct {
|
||||
Id int
|
||||
@@ -390,7 +315,6 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decryptToken(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -446,6 +370,8 @@ func (s *NodeService) normalize(n *model.Node) error {
|
||||
// node's.
|
||||
func (s *NodeService) normalizeMode(n *model.Node, agentEndpoint bool) error {
|
||||
n.Name = strings.TrimSpace(n.Name)
|
||||
// The per-node ApiToken column outlives its write path (legacy
|
||||
// rows, no schema change in B6); keep its stored form trimmed.
|
||||
n.ApiToken = strings.TrimSpace(n.ApiToken)
|
||||
if n.Name == "" {
|
||||
return common.NewError("node name is required")
|
||||
@@ -465,37 +391,6 @@ func (s *NodeService) normalizeMode(n *model.Node, agentEndpoint bool) error {
|
||||
if n.Scheme != "http" && n.Scheme != "https" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" && n.TlsVerifyMode != "mtls" {
|
||||
n.TlsVerifyMode = "verify"
|
||||
}
|
||||
if n.TlsVerifyMode == "mtls" && n.Scheme != "https" {
|
||||
return common.NewError("mtls requires the node scheme to be https")
|
||||
}
|
||||
n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
|
||||
if n.InboundSyncMode != "selected" {
|
||||
n.InboundSyncMode = "all"
|
||||
n.InboundTags = nil
|
||||
} else {
|
||||
seen := make(map[string]struct{}, len(n.InboundTags))
|
||||
tags := make([]string, 0, len(n.InboundTags))
|
||||
for _, tag := range n.InboundTags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[tag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
n.InboundTags = tags
|
||||
}
|
||||
if n.TlsVerifyMode == "pin" {
|
||||
if _, err := runtime.DecodeCertPin(n.PinnedCertSha256); err != nil {
|
||||
return common.NewError(err.Error())
|
||||
}
|
||||
}
|
||||
n.BasePath = normalizeBasePath(n.BasePath)
|
||||
return nil
|
||||
}
|
||||
@@ -508,45 +403,19 @@ func (s *NodeService) create(n *model.Node, agentEndpoint bool) error {
|
||||
if err := s.normalizeMode(n, agentEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
db := database.GetDB()
|
||||
if !nodetoken.Enabled() {
|
||||
return db.Create(n).Error
|
||||
}
|
||||
plaintext := n.ApiToken
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// The id-bound ciphertext can only be produced after insertion. Never put
|
||||
// plaintext in the initial tuple: PostgreSQL WAL would retain it.
|
||||
n.ApiToken = ""
|
||||
defer func() { n.ApiToken = plaintext }()
|
||||
if err := tx.Create(n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
enc, err := nodetoken.Encrypt(n.Id, plaintext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enc == plaintext {
|
||||
return nil // off-mode / empty token: nothing to rewrite
|
||||
}
|
||||
// DB column gets ciphertext; the in-memory struct keeps plaintext so the
|
||||
// create response echoes the same usable value GetById would return.
|
||||
return tx.Model(model.Node{}).Where("id = ?", n.Id).Update("api_token", enc).Error
|
||||
})
|
||||
return database.GetDB().Create(n).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) {
|
||||
if err := req.validateCredentials(true); err != nil {
|
||||
if err := req.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isAgent := req.NodeType == "agent"
|
||||
if isAgent {
|
||||
// A brand-new agent node has no binding yet, so no resolved
|
||||
// address can be known: the placeholder sanitizes to empty and
|
||||
// is filled in by later writes once the agent reports.
|
||||
sanitizeAgentEndpoint(req, "")
|
||||
}
|
||||
// Every node is agent-managed: a brand-new node has no binding yet,
|
||||
// so no resolved address can be known — the placeholder sanitizes
|
||||
// to empty and is filled in by later writes once the agent reports.
|
||||
sanitizeAgentEndpoint(req, "")
|
||||
n := req.toNode()
|
||||
if err := s.create(n, isAgent); err != nil {
|
||||
if err := s.create(n, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeView(n), nil
|
||||
@@ -576,24 +445,10 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
||||
if err := s.normalize(in); err != nil {
|
||||
return err
|
||||
}
|
||||
inboundTagsJSON, err := json.Marshal(in.InboundTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db := database.GetDB()
|
||||
existing := &model.Node{}
|
||||
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
|
||||
if err := db.Where("id = ?", id).First(&model.Node{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Blank means keep the hidden stored token; non-blank values are encrypted.
|
||||
apiToken := existing.ApiToken
|
||||
if in.ApiToken != "" {
|
||||
enc, eerr := nodetoken.Encrypt(id, in.ApiToken)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
apiToken = enc
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name,
|
||||
"remark": in.Remark,
|
||||
@@ -601,65 +456,30 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
||||
"address": in.Address,
|
||||
"port": in.Port,
|
||||
"base_path": in.BasePath,
|
||||
"api_token": apiToken,
|
||||
"enable": in.Enable,
|
||||
"allow_private_address": in.AllowPrivateAddress,
|
||||
"tls_verify_mode": in.TlsVerifyMode,
|
||||
"pinned_cert_sha256": in.PinnedCertSha256,
|
||||
"inbound_sync_mode": in.InboundSyncMode,
|
||||
"inbound_tags": string(inboundTagsJSON),
|
||||
"outbound_tag": in.OutboundTag,
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.MarkNodeDirtyTx(tx, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error {
|
||||
if err := req.validateCredentials(false); err != nil {
|
||||
if err := req.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
isAgent := req.NodeType == "agent" || s.IsAgentManaged(id)
|
||||
if isAgent {
|
||||
sanitizeAgentEndpoint(req, s.resolvedAgentAddress(id))
|
||||
}
|
||||
sanitizeAgentEndpoint(req, s.resolvedAgentAddress(id))
|
||||
in := req.toNode()
|
||||
if err := s.normalizeMode(in, isAgent); err != nil {
|
||||
return err
|
||||
}
|
||||
inboundTagsJSON, err := json.Marshal(in.InboundTags)
|
||||
if err != nil {
|
||||
if err := s.normalizeMode(in, true); err != nil {
|
||||
return err
|
||||
}
|
||||
db := database.GetDB()
|
||||
existing := &model.Node{}
|
||||
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
|
||||
if err := db.Where("id = ?", id).First(&model.Node{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
apiToken := existing.ApiToken
|
||||
switch {
|
||||
case req.ClearApiToken:
|
||||
apiToken = ""
|
||||
case req.ApiToken != nil:
|
||||
apiToken, err = nodetoken.Encrypt(id, *req.ApiToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Agent nodes authenticate with their agent token, never a panel API
|
||||
// token — the requirement below must not make them uneditable.
|
||||
if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
||||
return common.NewError("apiToken is required unless mtls is enabled")
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name,
|
||||
"remark": in.Remark,
|
||||
@@ -667,14 +487,8 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
||||
"address": in.Address,
|
||||
"port": in.Port,
|
||||
"base_path": in.BasePath,
|
||||
"api_token": apiToken,
|
||||
"enable": in.Enable,
|
||||
"allow_private_address": in.AllowPrivateAddress,
|
||||
"tls_verify_mode": in.TlsVerifyMode,
|
||||
"pinned_cert_sha256": in.PinnedCertSha256,
|
||||
"inbound_sync_mode": in.InboundSyncMode,
|
||||
"inbound_tags": string(inboundTagsJSON),
|
||||
"outbound_tag": in.OutboundTag,
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
@@ -684,7 +498,7 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
||||
// node row: flip every binding of this node in the same
|
||||
// transaction, so the flag and the node edit land together.
|
||||
// Nil means the form didn't touch the switch.
|
||||
if isAgent && req.ProbeOnly != nil {
|
||||
if req.ProbeOnly != nil {
|
||||
if err := tx.Model(&model.AgentNode{}).
|
||||
Where("node_id = ?", id).
|
||||
Update("probe_only", *req.ProbeOnly).Error; err != nil {
|
||||
@@ -695,222 +509,9 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NodeService) RuntimeNodeFromRequest(id int, req *NodeMutationRequest) (*model.Node, error) {
|
||||
if err := req.validateCredentials(id == 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var n *model.Node
|
||||
if id > 0 {
|
||||
existing, err := s.GetById(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n = existing
|
||||
} else {
|
||||
n = &model.Node{}
|
||||
}
|
||||
overlay := req.toNode()
|
||||
overlay.Id = id
|
||||
if req.ApiToken == nil {
|
||||
overlay.ApiToken = n.ApiToken
|
||||
}
|
||||
if req.ClearApiToken {
|
||||
overlay.ApiToken = ""
|
||||
}
|
||||
*n = *overlay
|
||||
if err := s.normalize(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.ApiToken == "" && n.Enable && n.TlsVerifyMode != "mtls" {
|
||||
return nil, common.NewError("apiToken is required unless mtls is enabled")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*model.Node, error) {
|
||||
if req == nil {
|
||||
return nil, common.NewError("node request is required")
|
||||
}
|
||||
n := req.toNode()
|
||||
if n.Scheme == "" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
}
|
||||
if err := s.normalize(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MigrateNodeTokensToActiveKey uses compare-and-swap to avoid clobbering live
|
||||
// changes. Current-key rows are skipped; changed and skipped counts are returned.
|
||||
func (s *NodeService) MigrateNodeTokensToActiveKey() (int, int, error) {
|
||||
codec := nodetoken.Active()
|
||||
if !codec.Enabled() {
|
||||
return 0, 0, errors.New("node-token encryption is off; set NODE_TOKEN_ENCRYPTION=migration|required and a key first")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var nodes []*model.Node
|
||||
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
changed, skipped := 0, 0
|
||||
for _, n := range nodes {
|
||||
old := n.ApiToken
|
||||
if old == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if codec.EncryptedWithActive(old) {
|
||||
if _, err := codec.Decrypt(n.Id, old); err != nil {
|
||||
return changed, skipped, fmt.Errorf("node %d validate active ciphertext: %w", n.Id, err)
|
||||
}
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
plain, err := codec.Decrypt(n.Id, old) // plaintext passes through; old-key ciphertext is decrypted
|
||||
if err != nil {
|
||||
return changed, skipped, fmt.Errorf("node %d decrypt: %w", n.Id, err)
|
||||
}
|
||||
enc, err := codec.Encrypt(n.Id, plain)
|
||||
if err != nil {
|
||||
return changed, skipped, fmt.Errorf("node %d encrypt: %w", n.Id, err)
|
||||
}
|
||||
res := db.Model(model.Node{}).Where("id = ? AND api_token = ?", n.Id, old).Update("api_token", enc)
|
||||
if res.Error != nil {
|
||||
return changed, skipped, res.Error
|
||||
}
|
||||
if res.RowsAffected == 1 {
|
||||
changed++
|
||||
} else {
|
||||
skipped++ // raced with a live update; a later run handles it
|
||||
}
|
||||
}
|
||||
return changed, skipped, nil
|
||||
}
|
||||
|
||||
func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
|
||||
if err := s.normalize(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.OutboundTag == "" {
|
||||
return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
|
||||
}
|
||||
// Mirror ProbeWithOutbound: a node being added/edited has no persistent
|
||||
// egress bridge yet, so route the list call through a temporary one or the
|
||||
// remote panel stays unreachable and the request times out.
|
||||
var options []runtime.RemoteInboundOption
|
||||
var err error
|
||||
s.withOutboundBridge(n.Id, n.OutboundTag, func(proxyURL string) {
|
||||
options, err = runtime.NewRemote(n, staticEgressResolver(proxyURL)).ListInboundOptions(ctx)
|
||||
})
|
||||
return options, err
|
||||
}
|
||||
|
||||
// staticEgressResolver hands a fixed proxy URL to runtime.NewRemote. An empty
|
||||
// string yields a direct connection, so it doubles as the graceful fallback
|
||||
// when a temporary bridge can't be built.
|
||||
type staticEgressResolver string
|
||||
|
||||
func (r staticEgressResolver) NodeEgressProxyURL(int) string { return string(r) }
|
||||
|
||||
// EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
|
||||
// selection when the node syncs in "selected" mode. Without it, the next
|
||||
// traffic sync would filter the tag out of the snapshot and the orphan sweep
|
||||
// would silently delete the central row the panel just created or renamed.
|
||||
// Tags are only ever added (never removed): on a rename the node may keep
|
||||
// reporting the old tag until the remote update lands, and a leftover entry
|
||||
// that matches nothing is harmless.
|
||||
func (s *NodeService) EnsureInboundTagAllowed(nodeID int, tag string) error {
|
||||
return s.EnsureInboundTagAllowedTx(database.GetDB(), nodeID, tag)
|
||||
}
|
||||
|
||||
func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag string) error {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if nodeID <= 0 || tag == "" {
|
||||
return nil
|
||||
}
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
node := &model.Node{}
|
||||
if err := tx.Where("id = ?", nodeID).First(node).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if node.InboundSyncMode != "selected" {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(node.InboundTags, tag) {
|
||||
return nil
|
||||
}
|
||||
buf, err := json.Marshal(append(node.InboundTags, tag))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(model.Node{}).Where("id = ?", nodeID).
|
||||
Updates(map[string]any{"inbound_tags": string(buf)}).Error
|
||||
}
|
||||
|
||||
func nodeSelectedTagSet(n *model.Node) map[string]struct{} {
|
||||
if n == nil || n.InboundSyncMode != "selected" {
|
||||
return nil
|
||||
}
|
||||
prefix := nodeTagPrefix(&n.Id)
|
||||
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
|
||||
for _, tag := range n.InboundTags {
|
||||
allowed[tag] = struct{}{}
|
||||
if prefix != "" {
|
||||
if stripped, found := strings.CutPrefix(tag, prefix); found {
|
||||
allowed[stripped] = struct{}{}
|
||||
} else {
|
||||
allowed[prefix+tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// A deselected tag is still served by the node — FilterNodeSnapshot just stops
|
||||
// reporting it — so its absence must never be read as "the node deleted it".
|
||||
func unmanagedTagPredicate(n *model.Node) func(string) bool {
|
||||
managed := nodeSelectedTagSet(n)
|
||||
if managed == nil {
|
||||
return func(string) bool { return false }
|
||||
}
|
||||
return func(tag string) bool {
|
||||
_, ok := managed[tag]
|
||||
return !ok
|
||||
}
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
}
|
||||
allowed := nodeSelectedTagSet(n)
|
||||
for _, tag := range snap.ManagedAliases {
|
||||
allowed[tag] = struct{}{}
|
||||
}
|
||||
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
|
||||
for _, inbound := range snap.Inbounds {
|
||||
if inbound == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[inbound.Tag]; ok {
|
||||
filtered = append(filtered, inbound)
|
||||
}
|
||||
}
|
||||
snap.Inbounds = filtered
|
||||
}
|
||||
|
||||
func (s *NodeService) Delete(id int) error {
|
||||
db := database.GetDB()
|
||||
// Refuse to delete a node that still owns inbounds: dropping the node row
|
||||
@@ -952,9 +553,6 @@ func (s *NodeService) Delete(id int) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
|
||||
nodeMetrics.drop(nodeMetricKey(id, "mem"))
|
||||
return nil
|
||||
@@ -965,36 +563,9 @@ func (s *NodeService) SetEnable(id int, enable bool) error {
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWebCertFiles asks a node for its own web TLS certificate/key file paths,
|
||||
// used by "Set Cert from Panel" so a node-assigned inbound gets paths that
|
||||
// exist on the node rather than the central panel. See issue #4854.
|
||||
func (s *NodeService) GetWebCertFiles(id int) (*runtime.WebCertFiles, error) {
|
||||
n, err := s.GetById(id)
|
||||
if err != nil || n == nil {
|
||||
return nil, fmt.Errorf("node not found")
|
||||
}
|
||||
if !n.Enable {
|
||||
return nil, fmt.Errorf("node is disabled")
|
||||
}
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("runtime manager unavailable")
|
||||
}
|
||||
remote, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return remote.GetWebCertFiles(ctx)
|
||||
}
|
||||
|
||||
// NodeUpdateResult reports the outcome of triggering a panel self-update on one
|
||||
// node so the UI can show per-node success/failure for a bulk request.
|
||||
type NodeUpdateResult struct {
|
||||
@@ -1075,24 +646,7 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
|
||||
res.OK = true
|
||||
}
|
||||
default:
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
res.Error = "runtime manager unavailable"
|
||||
break
|
||||
}
|
||||
remote, remoteErr := mgr.RemoteFor(n)
|
||||
if remoteErr != nil {
|
||||
res.Error = remoteErr.Error()
|
||||
break
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
updErr := remote.UpdatePanel(ctx, dev)
|
||||
cancel()
|
||||
if updErr != nil {
|
||||
res.Error = updErr.Error()
|
||||
} else {
|
||||
res.OK = true
|
||||
}
|
||||
res.Error = "node is not agent-managed"
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
@@ -1187,15 +741,6 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
|
||||
Update("config_dirty", false).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) MarkNodeInboundsAdopted(id int) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(model.Node{}).
|
||||
Where("id = ? AND inbounds_adopted_at = 0", id).
|
||||
Update("inbounds_adopted_at", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
|
||||
if id <= 0 {
|
||||
return false, "", false, 0, errors.New("invalid node id")
|
||||
@@ -1234,144 +779,76 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
|
||||
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
|
||||
}
|
||||
|
||||
// agentOnlineWindow is how fresh an agent heartbeat must be for its
|
||||
// node to read online — the same 180s the heartbeat job expires agent
|
||||
// nodes by (agentHeartbeatStaleAfter in internal/web/job: max(3
|
||||
// heartbeat intervals, 120s), so two lost heartbeats don't flap).
|
||||
const agentOnlineWindow = 180 * time.Second
|
||||
|
||||
var errAgentOffline = errors.New("agent node offline: no fresh heartbeat")
|
||||
|
||||
// agentHeartbeatVerdict is the pure decision behind the agent-node
|
||||
// probe: a fresh stamp reads online with the heartbeat AGE as the
|
||||
// latency (there is no dial to time); a stale or never-seen stamp
|
||||
// reads offline with LatencyMs -1. LastError stays empty either way —
|
||||
// the answer comes from the heartbeat, not from a dial failure.
|
||||
func agentHeartbeatVerdict(lastSeenMs int64, now time.Time) (HeartbeatPatch, bool) {
|
||||
patch := HeartbeatPatch{LatencyMs: -1}
|
||||
if lastSeenMs <= 0 {
|
||||
return patch, false
|
||||
}
|
||||
patch.LastHeartbeat = lastSeenMs / 1000
|
||||
if age := now.Sub(time.UnixMilli(lastSeenMs)); age <= agentOnlineWindow {
|
||||
patch.LastHeartbeat = now.Unix()
|
||||
patch.LatencyMs = int(age / time.Millisecond)
|
||||
return patch, true
|
||||
}
|
||||
return patch, false
|
||||
}
|
||||
|
||||
// agentNodeProbe answers a probe for an agent-managed node from the
|
||||
// bound agent's last_seen stamp (Unix ms, the unit the agent
|
||||
// controller stamps). ok=false means the node has no bound agent — or
|
||||
// the lookup failed — and the caller takes the dial path. The stored
|
||||
// telemetry fields carry through unchanged (same as the heartbeat
|
||||
// job's agent-expiry patch); only the error is dropped, so a ⚡ click
|
||||
// also heals the bogus dial errors recorded before this change.
|
||||
func (s *NodeService) agentNodeProbe(n *model.Node) (patch HeartbeatPatch, err error, ok bool) {
|
||||
var lastSeen *int64 // MAX() over zero rows is NULL
|
||||
qerr := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("node_id = ?", n.Id).
|
||||
Select("MAX(last_seen)").
|
||||
Scan(&lastSeen).Error
|
||||
if qerr != nil || lastSeen == nil {
|
||||
return HeartbeatPatch{}, nil, false
|
||||
}
|
||||
patch, online := agentHeartbeatVerdict(*lastSeen, time.Now())
|
||||
patch.XrayVersion = n.XrayVersion
|
||||
patch.PanelVersion = n.PanelVersion
|
||||
patch.Guid = n.Guid
|
||||
patch.CpuPct = n.CpuPct
|
||||
patch.MemPct = n.MemPct
|
||||
patch.UptimeSecs = n.UptimeSecs
|
||||
patch.NetUp = n.NetUp
|
||||
patch.NetDown = n.NetDown
|
||||
patch.XrayState = n.XrayState
|
||||
patch.XrayError = n.XrayError
|
||||
if !online {
|
||||
return patch, errAgentOffline, true
|
||||
}
|
||||
return patch, nil, true
|
||||
}
|
||||
|
||||
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
|
||||
proxyURL := ""
|
||||
if n.OutboundTag != "" {
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
proxyURL = mgr.NodeEgressProxyURL(n.Id)
|
||||
}
|
||||
}
|
||||
return s.probe(ctx, n, proxyURL)
|
||||
}
|
||||
|
||||
func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outboundTag string) (HeartbeatPatch, error) {
|
||||
if outboundTag == "" {
|
||||
return s.Probe(ctx, n)
|
||||
}
|
||||
var patch HeartbeatPatch
|
||||
var err error
|
||||
s.withOutboundBridge(n.Id, outboundTag, func(proxyURL string) {
|
||||
if proxyURL == "" {
|
||||
patch, err = s.Probe(ctx, n)
|
||||
return
|
||||
}
|
||||
patch, err = s.probe(ctx, n, proxyURL)
|
||||
})
|
||||
return patch, err
|
||||
}
|
||||
|
||||
// withOutboundBridge used to stand up a temporary loopback SOCKS5 inbound
|
||||
// in the panel's own running xray to reach a node through its connection
|
||||
// outbound. The lite master runs no local core, so probes always take the
|
||||
// direct-connection fallback this function used when xray was down.
|
||||
func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
|
||||
fn("")
|
||||
}
|
||||
|
||||
func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
|
||||
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
|
||||
|
||||
addr, err := netsafe.NormalizeHost(n.Address)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
// Every node is agent-managed: it has no panel to dial — its
|
||||
// address row is a placeholder ("agent") — so a probe answers
|
||||
// from the agent heartbeat instead of dialing a dead URL and
|
||||
// persisting a LastError the agent heartbeat never clears
|
||||
// (V2.2.1 ②). A node with no bound agent reads offline.
|
||||
if patch, err, ok := s.agentNodeProbe(n); ok {
|
||||
return patch, err
|
||||
}
|
||||
scheme := n.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if n.Port <= 0 || n.Port > 65535 {
|
||||
patch.LastError = "node port must be 1-65535"
|
||||
return patch, errors.New(patch.LastError)
|
||||
}
|
||||
probeURL := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
|
||||
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
|
||||
http.MethodGet, probeURL.String(), nil)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
}
|
||||
if n.ApiToken != "" {
|
||||
token, derr := nodetoken.Decrypt(n.Id, n.ApiToken)
|
||||
if derr != nil {
|
||||
patch.LastError = derr.Error()
|
||||
return patch, derr
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client, err := runtime.HTTPClientForNode(n, proxyURL)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
patch.LatencyMs = int(time.Since(start) / time.Millisecond)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
patch.LastError = fmt.Sprintf("HTTP %d from remote panel", resp.StatusCode)
|
||||
return patch, errors.New(patch.LastError)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
Obj *struct {
|
||||
CpuPct float64 `json:"cpu"`
|
||||
Mem struct {
|
||||
Current uint64 `json:"current"`
|
||||
Total uint64 `json:"total"`
|
||||
} `json:"mem"`
|
||||
Xray struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
} `json:"xray"`
|
||||
PanelVersion string `json:"panelVersion"`
|
||||
PanelGuid string `json:"panelGuid"`
|
||||
Uptime uint64 `json:"uptime"`
|
||||
NetIO struct {
|
||||
Up uint64 `json:"up"`
|
||||
Down uint64 `json:"down"`
|
||||
} `json:"netIO"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
patch.LastError = "decode response: " + err.Error()
|
||||
return patch, err
|
||||
}
|
||||
if !envelope.Success || envelope.Obj == nil {
|
||||
patch.LastError = "remote returned success=false: " + envelope.Msg
|
||||
return patch, errors.New(patch.LastError)
|
||||
}
|
||||
o := envelope.Obj
|
||||
patch.CpuPct = o.CpuPct
|
||||
if o.Mem.Total > 0 {
|
||||
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
|
||||
}
|
||||
patch.XrayVersion = o.Xray.Version
|
||||
patch.XrayState = o.Xray.State
|
||||
patch.XrayError = o.Xray.ErrorMsg
|
||||
patch.PanelVersion = o.PanelVersion
|
||||
patch.Guid = o.PanelGuid
|
||||
patch.UptimeSecs = o.Uptime
|
||||
patch.NetUp = o.NetIO.Up
|
||||
patch.NetDown = o.NetIO.Down
|
||||
return patch, nil
|
||||
return HeartbeatPatch{LatencyMs: -1}, errAgentOffline
|
||||
}
|
||||
|
||||
type ProbeResultUI struct {
|
||||
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
// Agent nodes have no panel endpoint: the node form submits the
|
||||
// placeholder pair address="agent", port=2053 for them. The write paths
|
||||
// must never store that pair — an agent node's row carries its resolved
|
||||
// address when one is known, or empty when it is not — while traditional
|
||||
// nodes keep the strict endpoint validation they always had.
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
// address when one is known, or empty when it is not.
|
||||
|
||||
func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
@@ -21,7 +18,7 @@ func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) {
|
||||
|
||||
view, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "agent-new", Enable: true, Scheme: "https",
|
||||
Address: "agent", Port: 2053, NodeType: "agent",
|
||||
Address: "agent", Port: 2053,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFromRequest(agent): %v", err)
|
||||
@@ -40,34 +37,23 @@ func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFromRequest_TraditionalKeepsEndpoint(t *testing.T) {
|
||||
func TestCreateFromRequest_RealAddressKeptVerbatim(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &NodeService{}
|
||||
|
||||
view, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "trad-new", Enable: true, Scheme: "https",
|
||||
Address: "198.51.100.10", Port: 8443, ApiToken: strPtr("tok-1"),
|
||||
Name: "real-new", Enable: true, Scheme: "https",
|
||||
Address: "198.51.100.10", Port: 8443,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFromRequest(traditional): %v", err)
|
||||
t.Fatalf("CreateFromRequest(real address): %v", err)
|
||||
}
|
||||
stored, err := svc.GetById(view.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetById: %v", err)
|
||||
}
|
||||
if stored.Address != "198.51.100.10" || stored.Port != 8443 {
|
||||
t.Fatalf("stored traditional endpoint = %q:%d, want 198.51.100.10:8443", stored.Address, stored.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFromRequest_TraditionalEmptyAddressStillRejected(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &NodeService{}
|
||||
|
||||
if _, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "trad-bad", Enable: true, Scheme: "https", ApiToken: strPtr("tok-2"),
|
||||
}); err == nil {
|
||||
t.Fatal("traditional create with an empty address must still fail validation")
|
||||
t.Fatalf("stored endpoint = %q:%d, want 198.51.100.10:8443", stored.Address, stored.Port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +81,7 @@ func TestUpdateFromRequest_AgentPlaceholderReplacedByResolved(t *testing.T) {
|
||||
// Exactly what the edit form submits for an agent node today.
|
||||
err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{
|
||||
Name: "agent-upd", Enable: true, Scheme: "https",
|
||||
Address: "agent", Port: 2053, NodeType: "agent",
|
||||
Address: "agent", Port: 2053,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFromRequest(agent): %v", err)
|
||||
@@ -116,7 +102,7 @@ func TestUpdateFromRequest_AgentNoReport_SanitizesToEmpty(t *testing.T) {
|
||||
|
||||
err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{
|
||||
Name: "agent-silent", Enable: true, Scheme: "https",
|
||||
Address: "agent", Port: 2053, NodeType: "agent",
|
||||
Address: "agent", Port: 2053,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFromRequest(agent): %v", err)
|
||||
@@ -137,7 +123,7 @@ func TestUpdateFromRequest_AgentRealAddressKeptVerbatim(t *testing.T) {
|
||||
|
||||
err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{
|
||||
Name: "agent-real", Enable: true, Scheme: "https",
|
||||
Address: "edge.example.com", Port: 443, NodeType: "agent",
|
||||
Address: "edge.example.com", Port: 443,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFromRequest(agent, real address): %v", err)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// V2.2.1 ②: the ⚡ probe for an agent-managed node is synthesized
|
||||
// from the agent's heartbeat stamp instead of dialing the placeholder
|
||||
// address. These tests pin the pure verdict: fresh stamp → online
|
||||
// with the heartbeat age as latency; stale / never-seen → offline
|
||||
// with LatencyMs -1; LastError empty in every case (a dial error must
|
||||
// never be recorded for an agent node again).
|
||||
|
||||
func TestAgentHeartbeatVerdictFreshIsOnlineWithAgeLatency(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
lastSeenMs := now.Add(-30 * time.Second).UnixMilli()
|
||||
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
|
||||
if !online {
|
||||
t.Fatal("30s-old heartbeat must read online")
|
||||
}
|
||||
if patch.LatencyMs != 30_000 {
|
||||
t.Fatalf("LatencyMs = %d, want heartbeat age 30000", patch.LatencyMs)
|
||||
}
|
||||
if patch.LastHeartbeat != now.Unix() {
|
||||
t.Fatalf("LastHeartbeat = %d, want now %d", patch.LastHeartbeat, now.Unix())
|
||||
}
|
||||
if patch.LastError != "" {
|
||||
t.Fatalf("LastError = %q, want empty", patch.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeartbeatVerdictWindowEdge(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow).UnixMilli(), now); !online {
|
||||
t.Fatal("heartbeat exactly at the window edge must read online")
|
||||
}
|
||||
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow-time.Second).UnixMilli(), now); online {
|
||||
t.Fatal("heartbeat past the window must read offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeartbeatVerdictStaleIsOffline(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
lastSeenMs := now.Add(-10 * time.Minute).UnixMilli()
|
||||
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
|
||||
if online {
|
||||
t.Fatal("10min-old heartbeat must read offline")
|
||||
}
|
||||
if patch.LatencyMs != -1 {
|
||||
t.Fatalf("LatencyMs = %d, want -1", patch.LatencyMs)
|
||||
}
|
||||
if patch.LastHeartbeat != lastSeenMs/1000 {
|
||||
t.Fatalf("LastHeartbeat = %d, want the truthful last-seen %d", patch.LastHeartbeat, lastSeenMs/1000)
|
||||
}
|
||||
if patch.LastError != "" {
|
||||
t.Fatalf("LastError = %q, want empty", patch.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeartbeatVerdictNeverSeenIsOffline(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
patch, online := agentHeartbeatVerdict(0, now)
|
||||
if online {
|
||||
t.Fatal("never-seen agent must read offline")
|
||||
}
|
||||
if patch.LatencyMs != -1 || patch.LastHeartbeat != 0 || patch.LastError != "" {
|
||||
t.Fatalf("patch = %+v, want zero stamp, latency -1, no error", patch)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/xray"
|
||||
)
|
||||
|
||||
// TestMergeActivationExpiry covers the pure reconciliation rule in isolation.
|
||||
@@ -41,977 +34,3 @@ func TestMergeActivationExpiry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisableIsStale(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
const quota = int64(100)
|
||||
cases := []struct {
|
||||
name string
|
||||
master *xray.ClientTraffic
|
||||
node xray.ClientTraffic
|
||||
deltaUp int64
|
||||
deltaDown int64
|
||||
wantStale bool
|
||||
}{
|
||||
{name: "nil master", node: xray.ClientTraffic{ExpiryTime: earlyAbs}},
|
||||
{
|
||||
name: "matching limits are a genuine verdict",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota},
|
||||
node: xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota},
|
||||
wantStale: false,
|
||||
},
|
||||
{
|
||||
name: "node still holds the pre-extension deadline",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs},
|
||||
node: xray.ClientTraffic{ExpiryTime: earlyAbs},
|
||||
wantStale: true,
|
||||
},
|
||||
{
|
||||
name: "node still holds the pre-top-up quota",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: 2 * quota, Up: 60, Down: 50},
|
||||
node: xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota},
|
||||
wantStale: true,
|
||||
},
|
||||
{
|
||||
name: "master itself expired",
|
||||
master: &xray.ClientTraffic{ExpiryTime: earlyAbs},
|
||||
node: xray.ClientTraffic{ExpiryTime: earlyAbs - 1000},
|
||||
},
|
||||
{
|
||||
name: "master itself over quota",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota, Up: 60, Down: 50},
|
||||
node: xray.ClientTraffic{ExpiryTime: earlyAbs, Total: quota},
|
||||
},
|
||||
{
|
||||
name: "this tick's deltas cross the master quota",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota, Up: 40, Down: 50},
|
||||
node: xray.ClientTraffic{ExpiryTime: earlyAbs, Total: quota},
|
||||
deltaUp: 10,
|
||||
deltaDown: 10,
|
||||
},
|
||||
{
|
||||
// Same row without the deltas: the crossing above is the deltas' doing.
|
||||
name: "under the master quota before this tick's deltas",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota, Up: 40, Down: 50},
|
||||
node: xray.ClientTraffic{ExpiryTime: earlyAbs, Total: quota},
|
||||
wantStale: true,
|
||||
},
|
||||
{
|
||||
name: "un-activated node duration against an absolute master",
|
||||
master: &xray.ClientTraffic{ExpiryTime: lateAbs},
|
||||
node: xray.ClientTraffic{ExpiryTime: -2592000000},
|
||||
wantStale: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := nodeDisableIsStale(c.master, c.node, now, c.deltaUp, c.deltaDown); got != c.wantStale {
|
||||
t.Fatalf("nodeDisableIsStale(...) = %v, want %v", got, c.wantStale)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeFirstConnectExpiry_NotClobbered reproduces the multi-node bug: a
|
||||
// client is attached to inbounds on two nodes with a "start after first connect"
|
||||
// expiry. The client connects only on node 1, which activates an absolute
|
||||
// deadline; node 2 never sees a connection and keeps reporting the negative
|
||||
// duration. The shared per-email client_traffics row must hold the activated
|
||||
// deadline — a later node-2 sync must not reset it back to "not started".
|
||||
func TestNodeFirstConnectExpiry_NotClobbered(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
createNodeInbound(t, db, 2, "n2-in", 41002)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "delayed"
|
||||
const duration = int64(-2592000000) // 30 days, not yet started
|
||||
|
||||
// Both nodes start out reporting the un-activated negative duration.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != duration {
|
||||
t.Fatalf("before any connection: expiry = %d, want %d", got, duration)
|
||||
}
|
||||
|
||||
// Client connects on node 1: it activates an absolute deadline.
|
||||
const activated = int64(1893456000000) // some absolute ms timestamp
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
|
||||
// Node 2 (no connection there) keeps reporting the negative duration. This
|
||||
// must NOT reset the activated deadline.
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("node 2 clobbered the activated deadline: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
|
||||
// Subsequent node 1 syncs keep the same absolute deadline.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, ExpiryTime: activated, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("after further node 1 sync: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeFirstConnectExpiry_NotClobbered_WithSettings exercises the full
|
||||
// production sync path — snapshots carrying real settings JSON, which drives the
|
||||
// GetClients/SyncInbound branch inside setRemoteTrafficLocked — to prove that
|
||||
// branch does not re-derive the per-email client_traffics.expiry_time from the
|
||||
// node's (still negative) settings and undo the merge guard.
|
||||
func TestNodeFirstConnectExpiry_NotClobbered_WithSettings(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
|
||||
createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "delayed")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "delayed"
|
||||
const duration = int64(-2592000000)
|
||||
const activated = int64(1893456000000)
|
||||
|
||||
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
|
||||
actSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":1893456000000}]}`
|
||||
|
||||
// Both nodes start un-activated.
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
|
||||
|
||||
// Node 1 activates (both its ClientStats and its settings now carry the
|
||||
// absolute deadline, like a real node after adjustTraffics).
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", actSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
|
||||
// Node 2 still reports the negative duration in BOTH ClientStats and
|
||||
// settings. Neither the merge nor SyncInbound may reset the deadline.
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("node 2 settings-sync clobbered the deadline: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeRenewExtendsExpiry: node auto-renew (reset + later expiry + counter
|
||||
// drop) must still move master expiry forward via nodeClientRenewed.
|
||||
func TestNodeRenewExtendsExpiry(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "renewing"
|
||||
const first = int64(1893456000000)
|
||||
const renewed = first + int64(2592000000) // +30 days after auto-renew
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 0, Down: 0, ExpiryTime: first, Reset: 30, Enable: true,
|
||||
})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 100, Down: 100, ExpiryTime: first, Reset: 30, Enable: true,
|
||||
})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != first {
|
||||
t.Fatalf("after activation: expiry = %d, want %d", got, first)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 5, Down: 5, ExpiryTime: renewed, Reset: 30, Enable: true,
|
||||
})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != renewed {
|
||||
t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeRenew_WithMatchingSettings: renew still applies when settings JSON
|
||||
// also carries the later absolute (guard must not block real renewals).
|
||||
func TestNodeRenew_WithMatchingSettings(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "renew-settings"
|
||||
firstSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, renewFirstExpiry)
|
||||
renewSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, renewSecondExpiry)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", firstSettings, xray.ClientTraffic{
|
||||
Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true,
|
||||
})
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", firstSettings, xray.ClientTraffic{
|
||||
Email: email, Up: 100, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true,
|
||||
})
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", renewSettings, xray.ClientTraffic{
|
||||
Email: email, Up: 5, Down: 5, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true,
|
||||
})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != renewSecondExpiry {
|
||||
t.Fatalf("renewal with matching settings: got %d want %d", got, renewSecondExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
// A node still holding the pre-extension deadline must not undo the extension on
|
||||
// traffics, client records or the adopted settings JSON (#6228).
|
||||
func TestNodeStaleExpiryAfterExtend_NotClobbered(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "extended")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "extended"
|
||||
expired, extended := earlyAbs, lateAbs
|
||||
|
||||
staleSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false})
|
||||
if got := readTraffic(t, db, email); got.ExpiryTime != expired || got.Enable {
|
||||
t.Fatalf("after expiry: expiry=%d enable=%v, want expiry=%d enable=false",
|
||||
got.ExpiryTime, got.Enable, expired)
|
||||
}
|
||||
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master extend traffic: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master extend record: %v", err)
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false})
|
||||
|
||||
got := readTraffic(t, db, email)
|
||||
if got.ExpiryTime != extended {
|
||||
t.Fatalf("stale node expiry clobbered traffics: expiry=%d, want %d", got.ExpiryTime, extended)
|
||||
}
|
||||
if !got.Enable {
|
||||
t.Fatal("stale node disable latched traffics.enable off after extension")
|
||||
}
|
||||
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
t.Fatalf("read client record: %v", err)
|
||||
}
|
||||
if rec.ExpiryTime != extended {
|
||||
t.Fatalf("stale SyncInbound clobbered record expiry: %d, want %d", rec.ExpiryTime, extended)
|
||||
}
|
||||
if !rec.Enable {
|
||||
t.Fatal("stale SyncInbound latched clients.enable off after extension")
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("read inbound: %v", err)
|
||||
}
|
||||
clients, err := svc.GetClients(&ib)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, c := range clients {
|
||||
if c.Email != email {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if c.ExpiryTime != extended {
|
||||
t.Fatalf("adopted settings kept stale expiry: %d, want %d", c.ExpiryTime, extended)
|
||||
}
|
||||
if !c.Enable {
|
||||
t.Fatal("adopted settings kept enable=false after extension")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("client missing from adopted inbound settings after stale sync")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeStaleLift_MarksNodeDirty: a lifecycle lift must mark the node dirty
|
||||
// and store lifted settings centrally so reconcile can re-push (#6228).
|
||||
func TestNodeStaleLift_MarksNodeDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
node := &model.Node{Name: "lift-n", Address: "127.0.0.1", Port: 2097, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
createNodeInboundWithClient(t, db, node.Id, "n1-in", 41001, "extended")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "extended"
|
||||
expired, extended := earlyAbs, lateAbs
|
||||
staleSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired)
|
||||
|
||||
syncNodeWithSettings(t, svc, node.Id, "n1-in", staleSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master extend: %v", err)
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", node.Id).
|
||||
Updates(map[string]any{"config_dirty": false, "config_dirty_at": int64(0)}).Error; err != nil {
|
||||
t.Fatalf("clear dirty: %v", err)
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, node.Id, "n1-in", staleSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false})
|
||||
|
||||
var n model.Node
|
||||
if err := db.Select("config_dirty").Where("id = ?", node.Id).First(&n).Error; err != nil {
|
||||
t.Fatalf("read node: %v", err)
|
||||
}
|
||||
if !n.ConfigDirty {
|
||||
t.Fatal("lifecycle lift must mark the node dirty so reconcile re-pushes")
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("read inbound: %v", err)
|
||||
}
|
||||
clients, err := svc.GetClients(&ib)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, c := range clients {
|
||||
if c.Email != email {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if c.ExpiryTime != extended || !c.Enable {
|
||||
t.Fatalf("lifted settings not stored: expiry=%d enable=%v", c.ExpiryTime, c.Enable)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("client missing from lifted inbound settings")
|
||||
}
|
||||
if ib.Settings == staleSettings {
|
||||
t.Fatal("central settings must differ from pre-lift wire blob (FP basis)")
|
||||
}
|
||||
}
|
||||
|
||||
// A node disable decided on the master's own limits is genuine and must still
|
||||
// one-way-merge enable=false onto the master (#4917).
|
||||
func TestNodeQuotaDisable_SameExpiryStillLatches(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "quota"
|
||||
const expiry = int64(1893456000000)
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 10, Down: 10, Total: 100, ExpiryTime: expiry, Enable: true,
|
||||
})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 60, Down: 50, Total: 100, ExpiryTime: expiry, Enable: false,
|
||||
})
|
||||
if got := readTraffic(t, db, email); got.Enable {
|
||||
t.Fatal("same-expiry node disable must still latch master enable off (#4917)")
|
||||
}
|
||||
}
|
||||
|
||||
// Once the master row is itself depleted, a node disable latches even though the
|
||||
// node's limits lag — otherwise genuine quota cuts would be skipped.
|
||||
func TestNodeQuotaDisable_OlderExpiryStillLatchesWhenOverQuota(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "quota-lag"
|
||||
early, late := earlyAbs, lateAbs
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 10, Down: 10, Total: 100, ExpiryTime: late, Enable: true,
|
||||
})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{
|
||||
"expiry_time": late, "enable": true, "up": int64(60), "down": int64(50), "total": int64(100),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed over-quota master: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 60, Down: 50, Total: 100, ExpiryTime: early, Enable: false,
|
||||
})
|
||||
if got := readTraffic(t, db, email); got.Enable {
|
||||
t.Fatal("over-quota master must still adopt node disable despite older node expiry")
|
||||
}
|
||||
if got := readTraffic(t, db, email); got.ExpiryTime != late {
|
||||
t.Fatalf("expiry should stay at master extension: got %d want %d", got.ExpiryTime, late)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeMasterShorten_NotClobberedWhileDirty: master shortened expiry while
|
||||
// config_dirty; a lagging longer node snapshot must not raise client_traffics.
|
||||
func TestNodeMasterShorten_NotClobberedWhileDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shortened")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "shortened"
|
||||
longExp, shortExp := lateAbs, earlyAbs
|
||||
longSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, longExp)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", longSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master shorten: %v", err)
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).
|
||||
Updates(map[string]any{"config_dirty": true, "config_dirty_at": int64(1)}).Error; err != nil {
|
||||
t.Fatalf("mark dirty: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "n1-in", Settings: longSettings,
|
||||
ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: longExp, Enable: true, Up: 5, Down: 5}},
|
||||
}},
|
||||
}
|
||||
before := readTraffic(t, db, email)
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Update("total", int64(999)).Error; err != nil {
|
||||
t.Fatalf("master total: %v", err)
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, true, false); err != nil {
|
||||
t.Fatalf("dirty sync: %v", err)
|
||||
}
|
||||
got := readTraffic(t, db, email)
|
||||
if got.ExpiryTime != shortExp {
|
||||
t.Fatalf("dirty sync raised expiry: got %d want %d", got.ExpiryTime, shortExp)
|
||||
}
|
||||
if got.Total != 999 {
|
||||
t.Fatalf("dirty sync adopted node total: got %d want 999", got.Total)
|
||||
}
|
||||
if got.Up < before.Up+5 || got.Down < before.Down+5 {
|
||||
t.Fatalf("dirty sync must still accumulate traffic: before=(%d,%d) after=(%d,%d)",
|
||||
before.Up, before.Down, got.Up, got.Down)
|
||||
}
|
||||
}
|
||||
|
||||
// The master's shortened absolute survives a snapshot whose ClientStats still
|
||||
// report the longer deadline (#6228).
|
||||
func TestNodeMasterShorten_LaggingClientStatsIgnored(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "short-settings")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "short-settings"
|
||||
longExp, shortExp := lateAbs, earlyAbs
|
||||
longSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, longExp)
|
||||
shortSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, shortExp)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", longSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master shorten: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "n1-in", Settings: shortSettings,
|
||||
ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: longExp, Enable: true}},
|
||||
}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("clean sync: %v", err)
|
||||
}
|
||||
if got := readTraffic(t, db, email); got.ExpiryTime != shortExp {
|
||||
t.Fatalf("lagging ClientStats raised expiry: got %d want %d", got.ExpiryTime, shortExp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeMasterShorten_CleanSiblingCannotRaise: a clean sibling still holding
|
||||
// the longer deadline in settings+ClientStats must not undo a master shorten.
|
||||
func TestNodeMasterShorten_CleanSiblingCannotRaise(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "sib-short")
|
||||
createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "sib-short")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "sib-short"
|
||||
longExp, shortExp := lateAbs, earlyAbs
|
||||
longSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, longExp)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", longSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true})
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", longSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master shorten: %v", err)
|
||||
}
|
||||
|
||||
// Sibling 2 is clean and still reports the old longer deadline.
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", longSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true})
|
||||
if got := readTraffic(t, db, email); got.ExpiryTime != shortExp {
|
||||
t.Fatalf("clean sibling raised shortened expiry: got %d want %d", got.ExpiryTime, shortExp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeMasterShorten_LaggingStatsNotTreatedAsRenew: after shorten, ClientStats
|
||||
// may still show the longer deadline with Reset+dip — must not call renewal.
|
||||
func TestNodeMasterShorten_LaggingStatsNotTreatedAsRenew(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "short-renew-trap"
|
||||
longExp, shortExp := lateAbs, earlyAbs
|
||||
shortSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, shortExp)
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 0, Down: 0, ExpiryTime: longExp, Reset: 30, Enable: true,
|
||||
})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 100, Down: 100, ExpiryTime: longExp, Reset: 30, Enable: true,
|
||||
})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master shorten: %v", err)
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", shortSettings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, ExpiryTime: longExp, Reset: 30, Enable: true})
|
||||
if got := readTraffic(t, db, email); got.ExpiryTime != shortExp {
|
||||
t.Fatalf("lagging stats treated as renew: got %d want %d", got.ExpiryTime, shortExp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeStaleExpiryAfterExtend_WhileDirty keeps master extend while dirty.
|
||||
func TestNodeStaleExpiryAfterExtend_WhileDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "extended-dirty")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "extended-dirty"
|
||||
expired, extended := earlyAbs, lateAbs
|
||||
staleSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master extend: %v", err)
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).
|
||||
Updates(map[string]any{"config_dirty": true, "config_dirty_at": int64(1)}).Error; err != nil {
|
||||
t.Fatalf("mark dirty: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "n1-in", Settings: staleSettings,
|
||||
ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: expired, Enable: false}},
|
||||
}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, true, false); err != nil {
|
||||
t.Fatalf("dirty sync: %v", err)
|
||||
}
|
||||
got := readTraffic(t, db, email)
|
||||
if got.ExpiryTime != extended || !got.Enable {
|
||||
t.Fatalf("dirty sync clobbered extend: expiry=%d enable=%v", got.ExpiryTime, got.Enable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeRenewal_SkippedWhileDirty: renewal-shaped stats must not adopt
|
||||
// expiry/enable over a pending master push.
|
||||
func TestNodeRenewal_SkippedWhileDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "renew-dirty"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 100, Down: 100, ExpiryTime: earlyAbs, Enable: true, Reset: 30,
|
||||
})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": earlyAbs, "enable": true, "up": int64(100), "down": int64(100)}).Error; err != nil {
|
||||
t.Fatalf("seed master: %v", err)
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).
|
||||
Updates(map[string]any{"config_dirty": true, "config_dirty_at": int64(1)}).Error; err != nil {
|
||||
t.Fatalf("mark dirty: %v", err)
|
||||
}
|
||||
// Renewal shape: later expiry + counters below baseline.
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "n1-in",
|
||||
ClientStats: []xray.ClientTraffic{{
|
||||
Email: email, Up: 10, Down: 10, ExpiryTime: lateAbs, Enable: true, Reset: 30,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, true, false); err != nil {
|
||||
t.Fatalf("dirty sync: %v", err)
|
||||
}
|
||||
got := readTraffic(t, db, email)
|
||||
if got.ExpiryTime != earlyAbs {
|
||||
t.Fatalf("renewal while dirty raised expiry: got %d want %d", got.ExpiryTime, earlyAbs)
|
||||
}
|
||||
|
||||
// Same renewal-shaped stats on a clean tick must still advance expiry —
|
||||
// dirty must not have burned the dipped baseline.
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).
|
||||
Updates(map[string]any{"config_dirty": false, "config_dirty_at": int64(0)}).Error; err != nil {
|
||||
t.Fatalf("clear dirty: %v", err)
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("clean sync: %v", err)
|
||||
}
|
||||
if got := readTraffic(t, db, email); got.ExpiryTime != lateAbs {
|
||||
t.Fatalf("renewal after dirty clear did not apply: got %d want %d", got.ExpiryTime, lateAbs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeExtend_FreshSettingsLaggingDisable: after extend, settings may already
|
||||
// show the new absolute while ClientStats still report enable=false + old expiry.
|
||||
func TestNodeExtend_FreshSettingsLaggingDisable(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "ext-lag")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "ext-lag"
|
||||
expired, extended := earlyAbs, lateAbs
|
||||
staleSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired)
|
||||
freshSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, extended)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil {
|
||||
t.Fatalf("master extend: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "n1-in", Settings: freshSettings,
|
||||
ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: expired, Enable: false}},
|
||||
}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("clean sync: %v", err)
|
||||
}
|
||||
got := readTraffic(t, db, email)
|
||||
if got.ExpiryTime != extended {
|
||||
t.Fatalf("expiry clobbered: got %d want %d", got.ExpiryTime, extended)
|
||||
}
|
||||
if !got.Enable {
|
||||
t.Fatal("lagging ClientStats enable=false latched master off after extend")
|
||||
}
|
||||
}
|
||||
|
||||
// A client that used no traffic never dips below its baseline, so the renewal
|
||||
// counter is the only evidence the node auto-renewed (#6228).
|
||||
func TestNodeRenew_ZeroTrafficUsesResetCount(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "idle-renew"
|
||||
firstSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, earlyAbs)
|
||||
renewSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, lateAbs)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", firstSettings, xray.ClientTraffic{
|
||||
Email: email, ExpiryTime: earlyAbs, Reset: 30, Enable: true,
|
||||
})
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", renewSettings, xray.ClientTraffic{
|
||||
Email: email, ExpiryTime: lateAbs, Reset: 30, ResetCount: 1, Enable: true,
|
||||
})
|
||||
|
||||
got := readTraffic(t, db, email)
|
||||
if got.ExpiryTime != lateAbs {
|
||||
t.Fatalf("zero-traffic renewal dropped: expiry=%d want %d", got.ExpiryTime, lateAbs)
|
||||
}
|
||||
if got.ResetCount != 1 {
|
||||
t.Fatalf("renewal count not persisted, so the next renewal cannot be seen: got %d want 1", got.ResetCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A quota top-up leaves the expiry alone, so the node's lagging disable must be
|
||||
// recognised by the stale quota it was decided against (#6228).
|
||||
func TestNodeQuotaTopUp_LaggingDisableIgnored(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "topped-up"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 10, Down: 10, Total: 100, ExpiryTime: lateAbs, Enable: true,
|
||||
})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{
|
||||
"total": int64(500), "enable": true, "up": int64(60), "down": int64(50),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("master top-up: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: email, Up: 60, Down: 50, Total: 100, ExpiryTime: lateAbs, Enable: false,
|
||||
})
|
||||
if got := readTraffic(t, db, email); !got.Enable {
|
||||
t.Fatal("node disable decided on the pre-top-up quota latched over the raised one")
|
||||
}
|
||||
}
|
||||
|
||||
// The settings lift is authoritative in both directions: a blob predating a
|
||||
// master disable must not carry enable=true back into central settings (#4917).
|
||||
func TestNodeStaleEnable_LiftedOffInSettings(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "cut-off")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "cut-off"
|
||||
liveSettings := fmt.Sprintf(
|
||||
`{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, lateAbs)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", liveSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: lateAbs, Enable: true})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("master disable: %v", err)
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", liveSettings,
|
||||
xray.ClientTraffic{Email: email, ExpiryTime: lateAbs, Enable: true})
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("read inbound: %v", err)
|
||||
}
|
||||
clients, err := svc.GetClients(&ib)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, c := range clients {
|
||||
if c.Email != email {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if c.Enable {
|
||||
t.Fatal("adopted settings re-enabled a client the master disabled")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("client missing from adopted inbound settings")
|
||||
}
|
||||
}
|
||||
|
||||
// The tick whose push just landed freezes only the lifecycle merge: adoption,
|
||||
// new client rows and traffic accumulation must keep working (#6228).
|
||||
func TestNodeJustPushed_FreezesLifecycleOnly(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const kept = "kept"
|
||||
const fresh = "fresh"
|
||||
extended := lateAbs + 86400000
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{
|
||||
Email: kept, Up: 10, Down: 10, Total: 100, ExpiryTime: lateAbs, Enable: true,
|
||||
})
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", kept).
|
||||
Updates(map[string]any{"expiry_time": extended, "total": int64(500)}).Error; err != nil {
|
||||
t.Fatalf("master edit: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "n1-in",
|
||||
ClientStats: []xray.ClientTraffic{
|
||||
{Email: kept, Up: 20, Down: 20, Total: 100, ExpiryTime: lateAbs, Enable: false},
|
||||
{Email: fresh, Up: 5, Down: 5, ExpiryTime: lateAbs, Enable: true},
|
||||
},
|
||||
}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, true); err != nil {
|
||||
t.Fatalf("just-pushed sync: %v", err)
|
||||
}
|
||||
|
||||
got := readTraffic(t, db, kept)
|
||||
if got.ExpiryTime != extended || got.Total != 500 || !got.Enable {
|
||||
t.Fatalf("just-pushed tick adopted lagging lifecycle: expiry=%d total=%d enable=%v",
|
||||
got.ExpiryTime, got.Total, got.Enable)
|
||||
}
|
||||
if got.Up != 10 || got.Down != 10 {
|
||||
t.Fatalf("just-pushed tick dropped this tick's traffic: up=%d down=%d, want 10/10", got.Up, got.Down)
|
||||
}
|
||||
if row := readTraffic(t, db, fresh); row.Email != fresh {
|
||||
t.Fatalf("just-pushed tick skipped adoption of a new client: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientTrafficMergeSQLMatchesHelpers pins the dialect SQL expressions
|
||||
// against the Go helpers so the in-memory replay after UPDATE cannot drift.
|
||||
func TestClientTrafficMergeSQLMatchesHelpers(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
|
||||
const email = "sql-merge"
|
||||
now := time.Now().UnixMilli()
|
||||
cases := []struct {
|
||||
name string
|
||||
masterExpiry int64
|
||||
masterEnable bool
|
||||
masterUp, masterDown, tot int64
|
||||
deltaUp, deltaDown int64
|
||||
nodeExpiry, nodeTotal int64
|
||||
nodeEnable bool
|
||||
wantExpiry int64
|
||||
wantEnable bool
|
||||
}{
|
||||
{
|
||||
name: "stale expiry+disable after extend",
|
||||
masterExpiry: lateAbs, masterEnable: true,
|
||||
nodeExpiry: earlyAbs, nodeEnable: false,
|
||||
wantExpiry: lateAbs, wantEnable: true,
|
||||
},
|
||||
{
|
||||
name: "same expiry quota disable",
|
||||
masterExpiry: lateAbs, masterEnable: true,
|
||||
masterUp: 60, masterDown: 50, tot: 100,
|
||||
nodeExpiry: lateAbs, nodeTotal: 100, nodeEnable: false,
|
||||
wantExpiry: lateAbs, wantEnable: false,
|
||||
},
|
||||
{
|
||||
name: "older expiry but master over quota",
|
||||
masterExpiry: lateAbs, masterEnable: true,
|
||||
masterUp: 60, masterDown: 50, tot: 100,
|
||||
nodeExpiry: earlyAbs, nodeTotal: 100, nodeEnable: false,
|
||||
wantExpiry: lateAbs, wantEnable: false,
|
||||
},
|
||||
{
|
||||
name: "master absolute ignores later node",
|
||||
masterExpiry: earlyAbs, masterEnable: true,
|
||||
nodeExpiry: lateAbs, nodeEnable: true,
|
||||
wantExpiry: earlyAbs, wantEnable: true,
|
||||
},
|
||||
{
|
||||
name: "negative node keeps absolute",
|
||||
masterExpiry: lateAbs, masterEnable: true,
|
||||
nodeExpiry: -2592000000, nodeEnable: true,
|
||||
wantExpiry: lateAbs, wantEnable: true,
|
||||
},
|
||||
{
|
||||
name: "expired master latches node disable",
|
||||
masterExpiry: earlyAbs, masterEnable: true,
|
||||
nodeExpiry: earlyAbs - 1000, nodeEnable: false,
|
||||
wantExpiry: earlyAbs, wantEnable: false,
|
||||
},
|
||||
{
|
||||
name: "older expiry crosses quota via deltas",
|
||||
masterExpiry: lateAbs, masterEnable: true,
|
||||
masterUp: 40, masterDown: 50, tot: 100,
|
||||
deltaUp: 10, deltaDown: 10,
|
||||
nodeExpiry: earlyAbs, nodeTotal: 100, nodeEnable: false,
|
||||
wantExpiry: lateAbs, wantEnable: false,
|
||||
},
|
||||
}
|
||||
|
||||
enableExpr := database.ClientTrafficEnableMergeExpr()
|
||||
expiryExpr := database.ClientTrafficExpiryMergeExpr()
|
||||
for i, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rowEmail := fmt.Sprintf("%s-%d", email, i)
|
||||
if err := db.Create(&xray.ClientTraffic{
|
||||
InboundId: 1, Email: rowEmail, Enable: c.masterEnable,
|
||||
ExpiryTime: c.masterExpiry, Up: c.masterUp, Down: c.masterDown, Total: c.tot,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
master := &xray.ClientTraffic{
|
||||
ExpiryTime: c.masterExpiry, Enable: c.masterEnable,
|
||||
Up: c.masterUp, Down: c.masterDown, Total: c.tot,
|
||||
}
|
||||
wantExpiry := mergeActivationExpiry(c.masterExpiry, c.nodeExpiry)
|
||||
wantEnable := c.masterEnable
|
||||
node := xray.ClientTraffic{ExpiryTime: c.nodeExpiry, Total: c.nodeTotal}
|
||||
if !c.nodeEnable && !nodeDisableIsStale(master, node, now, c.deltaUp, c.deltaDown) {
|
||||
wantEnable = false
|
||||
}
|
||||
if wantExpiry != c.wantExpiry || wantEnable != c.wantEnable {
|
||||
t.Fatalf("helper expectation drift: helpers=(%d,%v) fixture=(%d,%v)",
|
||||
wantExpiry, wantEnable, c.wantExpiry, c.wantEnable)
|
||||
}
|
||||
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(
|
||||
`UPDATE client_traffics SET enable = %s, expiry_time = %s WHERE email = ?`,
|
||||
enableExpr, expiryExpr,
|
||||
),
|
||||
c.nodeEnable, c.nodeExpiry, c.nodeTotal, now, c.deltaUp, c.deltaDown,
|
||||
c.nodeExpiry,
|
||||
rowEmail,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("SQL merge: %v", err)
|
||||
}
|
||||
got := readTraffic(t, db, rowEmail)
|
||||
if got.ExpiryTime != c.wantExpiry || got.Enable != c.wantEnable {
|
||||
t.Fatalf("SQL merge got expiry=%d enable=%v, want expiry=%d enable=%v",
|
||||
got.ExpiryTime, got.Enable, c.wantExpiry, c.wantEnable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Relative to the run: the merge rules now compare the master deadline against
|
||||
// wall-clock now, so fixed timestamps would rot into the wrong side of it.
|
||||
var (
|
||||
earlyAbs = time.Now().UnixMilli() - 30*86400000
|
||||
lateAbs = time.Now().UnixMilli() + 30*86400000
|
||||
)
|
||||
|
||||
// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
|
||||
// the deadline (positive ClientStats) while its settings JSON still carries the
|
||||
// negative duration, so SyncInbound keeps writing the stale value into the
|
||||
// client record and the Clients page shows "not started" forever.
|
||||
func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "delayed"
|
||||
const duration = int64(-2592000000)
|
||||
const activated = int64(1798448344010)
|
||||
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
|
||||
|
||||
if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
|
||||
t.Fatalf("seed client record: %v", err)
|
||||
}
|
||||
|
||||
readRecordExpiry := func() int64 {
|
||||
t.Helper()
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
t.Fatalf("read client record: %v", err)
|
||||
}
|
||||
return rec.ExpiryTime
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
|
||||
if got := readRecordExpiry(); got != duration {
|
||||
t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
if got := readRecordExpiry(); got != activated {
|
||||
t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const (
|
||||
renewFirstExpiry = int64(1893456000000)
|
||||
renewSecondExpiry = renewFirstExpiry + int64(2592000000)
|
||||
renewPeriodDays = 30
|
||||
)
|
||||
|
||||
// TestNodeRenew_ResetsMasterTraffic reproduces #5843: a node-side auto-renew
|
||||
// extends the deadline and resets the node's counters, and the master must
|
||||
// start a fresh quota window instead of keeping the old period's usage.
|
||||
func TestNodeRenew_ResetsMasterTraffic(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "renew-reset"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 950, 150, "before renewal")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 5, Down: 2, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
ct := readTraffic(t, db, email)
|
||||
assertUpDown(t, ct, 5, 2, "after renewal")
|
||||
if ct.ExpiryTime != renewSecondExpiry {
|
||||
t.Errorf("renewal expiry = %d, want %d", ct.ExpiryTime, renewSecondExpiry)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 15, Down: 12, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 15, 12, "accumulating in the new period")
|
||||
}
|
||||
|
||||
// TestNodeRenew_ReenablesDepletedClient: the node re-enables a client it
|
||||
// renews; the master's depleted-disable must lift with the fresh window.
|
||||
func TestNodeRenew_ReenablesDepletedClient(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "renew-enable"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("force-disable master row: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
if ct := readTraffic(t, db, email); !ct.Enable {
|
||||
t.Error("renewal must re-enable the master row when the node reports the client enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeRenew_ClearsGlobalTraffic mirrors autoRenewClients: stale cross-panel
|
||||
// pushes for the renewed email must not re-deplete the fresh window.
|
||||
func TestNodeRenew_ClearsGlobalTraffic(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "renew-global"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
if err := db.Create(&model.ClientGlobalTraffic{MasterGuid: "m1", Email: email, Up: 800, Down: 90}).Error; err != nil {
|
||||
t.Fatalf("seed global traffic: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 3, Down: 1, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
|
||||
var cnt int64
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count global traffic: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("renewal must clear stale global-traffic rows, found %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeCounterDip_SameExpiry_KeepsTraffic: a plain counter dip (#5456) with
|
||||
// no deadline movement stays on the clamp path even for a renewable client.
|
||||
func TestNodeCounterDip_SameExpiry_KeepsTraffic(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "dip-only"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 10, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 950, 150, "dip without renewal")
|
||||
}
|
||||
|
||||
// TestNodeExpiryAdvance_RisingCounters_Accumulates: a manual deadline extension
|
||||
// while traffic keeps flowing is not a renewal and must keep accumulating.
|
||||
func TestNodeExpiryAdvance_RisingCounters_Accumulates(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "extend-only"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 50, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 160, Down: 90, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 160, 90, "extension while accumulating")
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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/xray"
|
||||
)
|
||||
|
||||
func initTrafficTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
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() })
|
||||
return database.GetDB()
|
||||
}
|
||||
|
||||
func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) {
|
||||
t.Helper()
|
||||
nid := nodeID
|
||||
ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create node inbound %q: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
// createNodeInboundWithClient mirrors createNodeInbound but stores the client
|
||||
// in the settings JSON, which the node sync turns into a client_inbounds link.
|
||||
func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag string, port int, email string) {
|
||||
t.Helper()
|
||||
nid := nodeID
|
||||
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid, Settings: settings}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create node inbound %q: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) {
|
||||
t.Helper()
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// syncNodeWithSettings mirrors syncNode but carries a real settings JSON on
|
||||
// the snapshot inbound, like production nodes do — the sync mirrors snapshot
|
||||
// settings onto the central row, and the shared-accumulator guard reads the
|
||||
// clients out of those settings.
|
||||
func syncNodeWithSettings(t *testing.T, svc *InboundService, nodeID int, tag, settings string, stats ...xray.ClientTraffic) {
|
||||
t.Helper()
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic {
|
||||
t.Helper()
|
||||
var ct xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil {
|
||||
t.Fatalf("read client_traffics %q: %v", email, err)
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) {
|
||||
t.Helper()
|
||||
if ct.Up != wantUp || ct.Down != wantDown {
|
||||
t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
createNodeInbound(t, db, 2, "n2-in", 41002)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "shared"
|
||||
|
||||
// First-ever sync from each node: the row is created at 0 and the current
|
||||
// node counters become the baseline. Only deltas beyond those baselines count.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "after baselines — historical node traffic not imported")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
|
||||
|
||||
assertUpDown(t, readTraffic(t, db, email), 110, 110, "after both nodes grow — deltas (50+60) accrue")
|
||||
}
|
||||
|
||||
func TestSingleNode_MirrorsCorrectly(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "solo"
|
||||
// First sync: row seeded at 0, current counter becomes the baseline.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync — historical traffic not imported")
|
||||
|
||||
// Second sync: delta (700-500=200 / 800-600=200) accrues normally.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 200, 200, "second sync — delta accrues")
|
||||
}
|
||||
|
||||
func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "newnode-client"
|
||||
const histUp, histDown int64 = 6_000_000_000, 200_000_000_000
|
||||
|
||||
syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp, Down: histDown, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), histUp, histDown, "node-add: client history imported with its brand-new inbound")
|
||||
|
||||
syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp + 1024, Down: histDown + 2048, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count")
|
||||
}
|
||||
|
||||
// TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot
|
||||
// fetched just before a client's deletion still names the email. Applying it
|
||||
// must neither recreate the client_traffics row (at zero) nor re-add the
|
||||
// client to the central inbound's settings while the delete tombstone lives.
|
||||
func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "victim-5739"
|
||||
withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
|
||||
if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in").
|
||||
Update("settings", `{"clients": []}`).Error; err != nil {
|
||||
t.Fatalf("clear central settings: %v", err)
|
||||
}
|
||||
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
t.Fatalf("delete stats row: %v", err)
|
||||
}
|
||||
tombstoneClientEmail(email)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true})
|
||||
|
||||
var rows int64
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil {
|
||||
t.Fatalf("count stats rows: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows)
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if strings.Contains(ib.Settings, email) {
|
||||
t.Errorf("deleted client re-added to central settings: %s", ib.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "deleted-ghost"
|
||||
const stale int64 = 50_000_000_000
|
||||
|
||||
tombstoneClientEmail(email)
|
||||
syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: stale, Down: stale, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "tombstoned client must not resurrect via node-add seed")
|
||||
}
|
||||
|
||||
func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "legacy"
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed pre-existing row: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues")
|
||||
}
|
||||
|
||||
// TestGhostData_NoPhantomTraffic reproduces the 50 GB phantom traffic bug:
|
||||
// a node retains stale data for a deleted client (e.g. de-1 was offline during
|
||||
// deletion). When the master first syncs this email again it must NOT import the
|
||||
// stale counter — it sets Up=0 and records the current node value as the
|
||||
// baseline so only future deltas count.
|
||||
func TestGhostData_NoPhantomTraffic(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "de1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "pouya"
|
||||
const staleBytes int64 = 50 * 1024 * 1024 * 1024 // 50 GB stale on de-1
|
||||
|
||||
// Node reports a client with a large pre-existing counter (ghost data from a
|
||||
// deleted account that survived on the node). Master has no row for this email.
|
||||
syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes, Down: staleBytes, Enable: true})
|
||||
|
||||
ct := readTraffic(t, db, email)
|
||||
if ct.Up >= staleBytes || ct.Down >= staleBytes {
|
||||
t.Errorf("ghost data imported: up=%d down=%d; stale node counter must not count as new traffic", ct.Up, ct.Down)
|
||||
}
|
||||
assertUpDown(t, ct, 0, 0, "first sync of ghost email — imported as 0, not 50 GB")
|
||||
|
||||
// Subsequent syncs only add incremental usage beyond the baseline.
|
||||
syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes + 1024, Down: staleBytes + 2048, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts")
|
||||
}
|
||||
|
||||
func TestNodeCounterReset_NoReAdd(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "restart"
|
||||
// First sync seeds at 0 (baseline=900). Second sync adds delta 950-900=50.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset")
|
||||
|
||||
// Node reboot drops the counter to 50. delta=50-950=-900 is a counter reset,
|
||||
// not new traffic: add 0 and rebaseline to 50, never re-add the node's full
|
||||
// cumulative counter onto the master total (#5456).
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
|
||||
ct := readTraffic(t, db, email)
|
||||
if ct.Up < 0 || ct.Down < 0 {
|
||||
t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
|
||||
}
|
||||
assertUpDown(t, ct, 50, 50, "after node counter reset: rebaselined, not re-added")
|
||||
|
||||
// Post-reset accrual resumes from the new baseline: 80-50=30.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 80, Down: 80, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 80, 80, "post-reset delta accrues from rebaselined counter")
|
||||
}
|
||||
|
||||
func TestCentralReset_NoReAdd(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
createNodeInbound(t, db, 2, "n2-in", 41002)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "reset"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
|
||||
t.Fatalf("simulate central reset: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true})
|
||||
|
||||
assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
|
||||
}
|
||||
|
||||
// A real reset (ResetClientTrafficByEmail) must clear the per-node baseline so
|
||||
// the node's pre-reset cumulative — including traffic it counted but had not yet
|
||||
// synced — cannot leak back onto the master after the reset (#5476, #5390).
|
||||
func TestCentralResetClearsNodeBaseline_NoLeak(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
StartTrafficWriter()
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "reset-revert"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 200, 200, "before reset")
|
||||
|
||||
if err := svc.ResetClientTrafficByEmail(email); err != nil {
|
||||
t.Fatalf("ResetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "right after reset")
|
||||
|
||||
var baselines int64
|
||||
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&baselines).Error; err != nil {
|
||||
t.Fatalf("count baselines: %v", err)
|
||||
}
|
||||
if baselines != 0 {
|
||||
t.Fatalf("reset must clear node baseline rows, found %d", baselines)
|
||||
}
|
||||
|
||||
// Node still reports its pre-reset cumulative (340 > last synced 300: usage it
|
||||
// had not synced before the reset). It must not revert the reset.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 340, Down: 340, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "stale node counter must not revert reset")
|
||||
|
||||
// Genuine post-reset usage accrues from the rebaselined counter: 370-340=30.
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 370, Down: 370, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 30, 30, "post-reset usage accrues")
|
||||
}
|
||||
|
||||
func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")
|
||||
createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "shared"
|
||||
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
// Baselines set: node1=100, node2=200. Row seeded at 0.
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
// node1 delta=50, node2 delta=60 → total=110.
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 110, 110, "baseline sum")
|
||||
|
||||
// Node 1 rebuilt (reinstall / another master's reconcile): its inbound
|
||||
// vanishes from the snapshot. The shared accumulator must survive — losing
|
||||
// it would let the next node sync re-seed the row with that node's counter
|
||||
// alone, showing only the last panel's number instead of the sum.
|
||||
if _, err := svc.setRemoteTrafficLocked(1, &runtime.TrafficSnapshot{}, false, false); err != nil {
|
||||
t.Fatalf("sync node 1 with empty snapshot: %v", err)
|
||||
}
|
||||
assertUpDown(t, readTraffic(t, db, email), 110, 110, "after node 1 inbound removal")
|
||||
|
||||
// Node 2 keeps accruing onto the surviving row. node2 delta=300-260=40 → 150.
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 150, 150, "after node 2 grows")
|
||||
}
|
||||
|
||||
func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")
|
||||
createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "shared"
|
||||
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
// First syncs set baselines (node1=100, node2=200). Row seeded at 0.
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
|
||||
// Client detached from node 1's inbound only: its stats vanish from that
|
||||
// inbound's snapshot while node 2 still hosts the email.
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", `{"clients": []}`)
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "after client left node 1 — row unchanged at 0")
|
||||
|
||||
// Node 2 delta: 240-200=40 → accrues to 40.
|
||||
syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 240, Down: 240, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 40, 40, "node 2 keeps accruing")
|
||||
}
|
||||
|
||||
// TestStatsUnderSiblingInbound_KeepsNodeBaseline reproduces the recurring
|
||||
// sweep bug behind #5202: the client is attached to two inbounds of the SAME
|
||||
// node, the node reports its stats under n1-a only, but the master-side row
|
||||
// is owned by n1-b's mirror. The per-email sweep for n1-b must not drop the
|
||||
// (node, email) baseline that n1-a's delta computation needs — doing so every
|
||||
// cycle froze the client's counter permanently.
|
||||
func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "fresh")
|
||||
createNodeInbound(t, db, 1, "n1-b", 41002)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "fresh"
|
||||
var ibB model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-b").First(&ibB).Error; err != nil {
|
||||
t.Fatalf("load n1-b: %v", err)
|
||||
}
|
||||
// Master-side row created when the client was added on the panel, owned by
|
||||
// n1-b's mirror (e.g. the client form targeted that inbound).
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ibB.Id, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed master row: %v", err)
|
||||
}
|
||||
|
||||
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
sync := func(up, down int64) {
|
||||
t.Helper()
|
||||
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
|
||||
{Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: up, Down: down, Enable: true}}},
|
||||
{Tag: "n1-b", Settings: `{"clients": []}`},
|
||||
}}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sync(630, 630)
|
||||
var baselines int64
|
||||
if err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ? AND email = ?", 1, email).Count(&baselines).Error; err != nil {
|
||||
t.Fatalf("count baselines: %v", err)
|
||||
}
|
||||
if baselines != 1 {
|
||||
t.Fatalf("baseline must survive the sibling-inbound sweep, found %d rows", baselines)
|
||||
}
|
||||
|
||||
sync(700, 700)
|
||||
assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives")
|
||||
}
|
||||
|
||||
// TestMultiAttach_SameNode_DivergentSiblings reproduces #5274: a client is
|
||||
// attached to several inbounds of the SAME node. Xray reports client traffic
|
||||
// globally per email, so the node's enriched inbound list copies one shared
|
||||
// counter onto every inbound the client is on. When those copies diverge — a
|
||||
// legacy per-inbound row surviving the v3.2.x→v3.3.x upgrade, or any drift —
|
||||
// the per-inbound delta loop used to treat the lower sibling as a node-counter
|
||||
// reset and re-add its full value, inflating the client far past real usage.
|
||||
// The merge must collapse the email to its node-wide total and count it once.
|
||||
func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "multi")
|
||||
createNodeInboundWithClient(t, db, 1, "n1-b", 41002, "multi")
|
||||
createNodeInboundWithClient(t, db, 1, "n1-c", 41003, "multi")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "multi"
|
||||
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
|
||||
// The three inbounds report the same email with diverging values; the
|
||||
// node's true per-email total is the largest (the shared global counter).
|
||||
sync := func(a, b, c int64) {
|
||||
t.Helper()
|
||||
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
|
||||
{Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: a, Down: a, Enable: true}}},
|
||||
{Tag: "n1-b", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: b, Down: b, Enable: true}}},
|
||||
{Tag: "n1-c", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: c, Down: c, Enable: true}}},
|
||||
}}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// First-ever sync: row seeded at 0, canon (max of siblings = 100) becomes the
|
||||
// baseline. The node total is NOT imported as historical traffic — this prevents
|
||||
// ghost data from a previously-deleted account being counted as real usage.
|
||||
sync(100, 50, 80)
|
||||
assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync seeds at 0 — not the sum (230) nor the max (100)")
|
||||
|
||||
// Second sync: canon grew from 100→150, delta=50 accrues. Siblings 60 and 90
|
||||
// do not re-add their full values — the canon clamp prevents inflation.
|
||||
sync(150, 60, 90)
|
||||
assertUpDown(t, readTraffic(t, db, email), 50, 50, "second sync: only the 50-unit increment counts, not every sibling")
|
||||
|
||||
// Equal siblings (the healthy current-schema case): canon grew from 150→200,
|
||||
// delta=50 accrues once.
|
||||
sync(200, 200, 200)
|
||||
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
|
||||
}
|
||||
|
||||
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "gone"
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil {
|
||||
t.Fatalf("seed node baseline 1: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil {
|
||||
t.Fatalf("seed node baseline 2: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DelClientStat(db, email); err != nil {
|
||||
t.Fatalf("DelClientStat: %v", err)
|
||||
}
|
||||
|
||||
var cnt int64
|
||||
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count baselines: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("expected node baselines cleaned, found %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDelete_CleansNodeBaselines(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
nodeSvc := NodeService{}
|
||||
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil {
|
||||
t.Fatalf("seed node 7 a: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil {
|
||||
t.Fatalf("seed node 7 b: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil {
|
||||
t.Fatalf("seed node 8 c: %v", err)
|
||||
}
|
||||
|
||||
if err := nodeSvc.Delete(7); err != nil {
|
||||
t.Fatalf("NodeService.Delete(7): %v", err)
|
||||
}
|
||||
|
||||
var sevenCnt, eightCnt int64
|
||||
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt)
|
||||
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt)
|
||||
if sevenCnt != 0 {
|
||||
t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt)
|
||||
}
|
||||
if eightCnt != 1 {
|
||||
t.Errorf("node 8 baseline should survive, found %d", eightCnt)
|
||||
}
|
||||
}
|
||||
@@ -9,30 +9,23 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
)
|
||||
|
||||
// NodeView is the browser/API read contract for nodes. Credentials are
|
||||
// write-only: responses expose only whether a node has a token configured.
|
||||
// NodeView is the browser/API read contract for nodes.
|
||||
type NodeView struct {
|
||||
Id int `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"edge-1"`
|
||||
Remark string `json:"remark" example:"Primary edge"`
|
||||
Scheme string `json:"scheme" example:"https"`
|
||||
Address string `json:"address" example:"node.example.com"`
|
||||
Port int `json:"port" example:"2053"`
|
||||
BasePath string `json:"basePath" example:"/"`
|
||||
HasApiToken bool `json:"hasApiToken" example:"true"`
|
||||
Enable bool `json:"enable" example:"true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"`
|
||||
TlsVerifyMode string `json:"tlsVerifyMode" example:"verify"`
|
||||
PinnedCertSha256 string `json:"pinnedCertSha256" example:""`
|
||||
InboundSyncMode string `json:"inboundSyncMode" example:"all"`
|
||||
InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
|
||||
OutboundTag string `json:"outboundTag" example:"direct"`
|
||||
// NodeType is "agent" for lightweight agent-managed nodes (an agent is
|
||||
// bound to the node) and "traditional" otherwise. It is derived from the
|
||||
// agent binding, not stored on the node row; read paths that serve the
|
||||
// panel UI annotate it via annotateNodeTypes. Empty on views built
|
||||
// outside those paths.
|
||||
NodeType string `json:"nodeType,omitempty" example:"traditional"`
|
||||
Id int `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"edge-1"`
|
||||
Remark string `json:"remark" example:"Primary edge"`
|
||||
Scheme string `json:"scheme" example:"https"`
|
||||
Address string `json:"address" example:"node.example.com"`
|
||||
Port int `json:"port" example:"2053"`
|
||||
BasePath string `json:"basePath" example:"/"`
|
||||
Enable bool `json:"enable" example:"true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"`
|
||||
// NodeType is "agent" for agent-managed nodes (an agent is bound to
|
||||
// the node) and "traditional" for a legacy row with no binding. It
|
||||
// is derived from the agent binding, not stored on the node row;
|
||||
// read paths that serve the panel UI annotate it via
|
||||
// annotateNodeTypes. Empty on views built outside those paths.
|
||||
NodeType string `json:"nodeType,omitempty" example:"agent"`
|
||||
// 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
|
||||
@@ -84,8 +77,6 @@ type NodeView struct {
|
||||
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"`
|
||||
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
|
||||
}
|
||||
@@ -102,14 +93,8 @@ func toNodeView(n *model.Node) *NodeView {
|
||||
Address: n.Address,
|
||||
Port: n.Port,
|
||||
BasePath: n.BasePath,
|
||||
HasApiToken: n.ApiToken != "",
|
||||
Enable: n.Enable,
|
||||
AllowPrivateAddress: n.AllowPrivateAddress,
|
||||
TlsVerifyMode: n.TlsVerifyMode,
|
||||
PinnedCertSha256: n.PinnedCertSha256,
|
||||
InboundSyncMode: n.InboundSyncMode,
|
||||
InboundTags: n.InboundTags,
|
||||
OutboundTag: n.OutboundTag,
|
||||
Guid: n.Guid,
|
||||
Status: n.Status,
|
||||
LastHeartbeat: n.LastHeartbeat,
|
||||
@@ -136,8 +121,6 @@ func toNodeView(n *model.Node) *NodeView {
|
||||
ActiveEmails: n.ActiveEmails,
|
||||
DisabledEmails: n.DisabledEmails,
|
||||
DepletedEmails: n.DepletedEmails,
|
||||
ParentGuid: n.ParentGuid,
|
||||
Transitive: n.Transitive,
|
||||
CreatedAt: n.CreatedAt,
|
||||
UpdatedAt: n.UpdatedAt,
|
||||
}
|
||||
@@ -280,83 +263,37 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
}
|
||||
}
|
||||
|
||||
// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
|
||||
// only as input. On update, nil means keep the stored token; replacement and
|
||||
// clearing are explicit and mutually exclusive.
|
||||
// NodeMutationRequest is the node write contract. Every node is
|
||||
// agent-managed: it dials out to the master and authenticates with the
|
||||
// agent token issued on creation, so the request carries no panel
|
||||
// endpoint credentials.
|
||||
type NodeMutationRequest struct {
|
||||
Id int `json:"id" form:"id"`
|
||||
Name string `json:"name" form:"name" validate:"required"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken *string `json:"apiToken,omitempty" form:"apiToken"`
|
||||
ClearApiToken bool `json:"clearApiToken,omitempty" form:"clearApiToken"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"`
|
||||
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" validate:"omitempty,oneof=verify skip pin mtls"`
|
||||
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256"`
|
||||
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"`
|
||||
InboundTags []string `json:"inboundTags" form:"inboundTags"`
|
||||
OutboundTag string `json:"outboundTag" form:"outboundTag"`
|
||||
// NodeType distinguishes traditional panel-to-panel nodes from lightweight
|
||||
// agent nodes. Agent nodes skip the panel reachability probe and the API
|
||||
// token requirement; an agent token is issued on creation instead.
|
||||
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"`
|
||||
Id int `json:"id" form:"id"`
|
||||
Name string `json:"name" form:"name" validate:"required"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"`
|
||||
// ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A
|
||||
// pointer so updates can tell "leave the flag alone" (nil) from an
|
||||
// explicit on/off; applied to the bound agent rows, agent nodes
|
||||
// only. On create it presets the flag on the freshly issued agent.
|
||||
// explicit on/off; applied to the bound agent rows. On create it
|
||||
// presets the flag on the freshly issued agent.
|
||||
ProbeOnly *bool `json:"probeOnly,omitempty" form:"probeOnly"`
|
||||
}
|
||||
|
||||
func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
||||
func (r *NodeMutationRequest) validate() error {
|
||||
if r == nil {
|
||||
return common.NewError("node request is required")
|
||||
}
|
||||
// Agent nodes dial out to the master; address/port are not needed.
|
||||
// Traditional nodes still require them.
|
||||
if r.NodeType != "agent" {
|
||||
if strings.TrimSpace(r.Address) == "" {
|
||||
return common.NewError("address is required")
|
||||
}
|
||||
if r.Port < 1 || r.Port > 65535 {
|
||||
return common.NewError("port must be between 1 and 65535")
|
||||
}
|
||||
}
|
||||
if r.ApiToken != nil && r.ClearApiToken {
|
||||
return common.NewError("apiToken and clearApiToken are mutually exclusive")
|
||||
}
|
||||
if r.ApiToken != nil {
|
||||
*r.ApiToken = strings.TrimSpace(*r.ApiToken)
|
||||
if *r.ApiToken == "" {
|
||||
if create {
|
||||
return common.NewError("apiToken is required unless mtls is enabled")
|
||||
}
|
||||
r.ApiToken = nil
|
||||
}
|
||||
}
|
||||
if create {
|
||||
if r.ClearApiToken {
|
||||
return common.NewError("credentials cannot be cleared while creating a node")
|
||||
}
|
||||
// Agent nodes authenticate via agent token, not panel API token.
|
||||
if r.NodeType != "agent" && r.ApiToken == nil && r.TlsVerifyMode != "mtls" {
|
||||
return common.NewError("apiToken is required unless mtls is enabled")
|
||||
}
|
||||
}
|
||||
if r.ClearApiToken && r.Enable && r.TlsVerifyMode != "mtls" {
|
||||
return common.NewError("disable the node or enable mtls before clearing its apiToken")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAgentPlaceholderAddress reports whether addr is the placeholder the
|
||||
// node form submits for agent nodes ("agent": they dial out to the master
|
||||
// and have no panel endpoint to store). Keep in sync with the guard in
|
||||
// internal/web/runtime's baseURL, which refuses to dial the placeholder
|
||||
// for rows written before this sanitization existed.
|
||||
// and have no panel endpoint to store).
|
||||
func isAgentPlaceholderAddress(addr string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(addr), "agent")
|
||||
}
|
||||
@@ -378,7 +315,7 @@ func sanitizeAgentEndpoint(req *NodeMutationRequest, resolved string) {
|
||||
}
|
||||
|
||||
func (r *NodeMutationRequest) toNode() *model.Node {
|
||||
n := &model.Node{
|
||||
return &model.Node{
|
||||
Id: r.Id,
|
||||
Name: r.Name,
|
||||
Remark: r.Remark,
|
||||
@@ -388,14 +325,5 @@ func (r *NodeMutationRequest) toNode() *model.Node {
|
||||
BasePath: r.BasePath,
|
||||
Enable: r.Enable,
|
||||
AllowPrivateAddress: r.AllowPrivateAddress,
|
||||
TlsVerifyMode: r.TlsVerifyMode,
|
||||
PinnedCertSha256: r.PinnedCertSha256,
|
||||
InboundSyncMode: r.InboundSyncMode,
|
||||
InboundTags: r.InboundTags,
|
||||
OutboundTag: r.OutboundTag,
|
||||
}
|
||||
if r.ApiToken != nil {
|
||||
n.ApiToken = *r.ApiToken
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestNodeCredentialsNeverMarshal(t *testing.T) {
|
||||
raw, err := json.Marshal(&model.Node{
|
||||
Id: 7,
|
||||
Name: "node",
|
||||
ApiToken: "plain-secret-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal node: %v", err)
|
||||
}
|
||||
out := string(raw)
|
||||
if strings.Contains(out, "plain-secret-token") || strings.Contains(out, "apiToken") {
|
||||
t.Fatalf("model.Node JSON leaked api token field: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeViewExposesOnlyCredentialPresence(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &NodeService{}
|
||||
reqToken := "write-only-secret"
|
||||
view, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "node-view",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
ApiToken: &reqToken,
|
||||
Enable: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create from request: %v", err)
|
||||
}
|
||||
if !view.HasApiToken {
|
||||
t.Fatal("create view should report credential presence")
|
||||
}
|
||||
|
||||
got, err := svc.GetViewById(view.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get view: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal view: %v", err)
|
||||
}
|
||||
out := string(raw)
|
||||
if !strings.Contains(out, `"hasApiToken":true`) {
|
||||
t.Fatalf("view does not report credential presence: %s", out)
|
||||
}
|
||||
if strings.Contains(out, reqToken) || strings.Contains(out, "apiToken") {
|
||||
t.Fatalf("NodeView leaked plaintext or apiToken key: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCredentialMutationSemantics(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &NodeService{}
|
||||
|
||||
initial := "initial-token"
|
||||
view, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "mut",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
ApiToken: &initial,
|
||||
Enable: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
before := rawStoredNodeToken(t, view.Id)
|
||||
if before != initial {
|
||||
t.Fatalf("stored token = %q, want %q", before, initial)
|
||||
}
|
||||
|
||||
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
|
||||
Name: "mut-renamed",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
Enable: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("keep-token update: %v", err)
|
||||
}
|
||||
if after := rawStoredNodeToken(t, view.Id); after != before {
|
||||
t.Fatalf("omitted token should keep existing token: %q -> %q", before, after)
|
||||
}
|
||||
|
||||
blank := " "
|
||||
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
|
||||
Name: "mut-blank",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
ApiToken: &blank,
|
||||
Enable: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("blank apiToken should keep existing token on update: %v", err)
|
||||
}
|
||||
if afterBlank := rawStoredNodeToken(t, view.Id); afterBlank != before {
|
||||
t.Fatalf("blank token should keep existing token: %q -> %q", before, afterBlank)
|
||||
}
|
||||
|
||||
next := "next-token"
|
||||
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
|
||||
Name: "mut",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
ApiToken: &next,
|
||||
Enable: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("replace token: %v", err)
|
||||
}
|
||||
if replaced := rawStoredNodeToken(t, view.Id); replaced != next {
|
||||
t.Fatalf("replace token stored %q, want %q", replaced, next)
|
||||
}
|
||||
|
||||
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
|
||||
Name: "mut",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
ClearApiToken: true,
|
||||
Enable: true,
|
||||
}); err == nil {
|
||||
t.Fatal("enabled non-mtls node must not clear apiToken")
|
||||
}
|
||||
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
|
||||
Name: "mut",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2096,
|
||||
ClearApiToken: true,
|
||||
Enable: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("clear disabled token: %v", err)
|
||||
}
|
||||
if cleared := rawStoredNodeToken(t, view.Id); cleared != "" {
|
||||
t.Fatalf("clear token left stored value %q", cleared)
|
||||
}
|
||||
|
||||
if _, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "mtls-only",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2097,
|
||||
Enable: true,
|
||||
TlsVerifyMode: "mtls",
|
||||
}); err != nil {
|
||||
t.Fatalf("mtls create without token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeUpdateRequiresTokenWhenNoStoredTokenAndMtlsDisabled(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &NodeService{}
|
||||
|
||||
view, err := svc.CreateFromRequest(&NodeMutationRequest{
|
||||
Name: "mtls-empty",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2098,
|
||||
Enable: true,
|
||||
TlsVerifyMode: "mtls",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create mtls node: %v", err)
|
||||
}
|
||||
blank := ""
|
||||
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
|
||||
Name: "mtls-empty",
|
||||
Scheme: "https",
|
||||
Address: "127.0.0.1",
|
||||
Port: 2098,
|
||||
ApiToken: &blank,
|
||||
Enable: true,
|
||||
BasePath: "/",
|
||||
OutboundTag: "",
|
||||
}); err == nil {
|
||||
t.Fatal("enabled non-mtls node without stored token must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func rawStoredNodeToken(t *testing.T, id int) string {
|
||||
t.Helper()
|
||||
var n model.Node
|
||||
if err := database.GetDB().Select("api_token").Where("id = ?", id).First(&n).Error; err != nil {
|
||||
t.Fatalf("load raw node token: %v", err)
|
||||
}
|
||||
return n.ApiToken
|
||||
}
|
||||
@@ -8,103 +8,8 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// While a node is config-dirty (a local edit committed before it could be
|
||||
// mirrored to the node), the traffic pull must not overwrite the central
|
||||
// inbound's config columns from the node's stale snapshot — only traffic
|
||||
// counters may advance. Otherwise a reconnecting node reverts the edit.
|
||||
func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
id := node.Id
|
||||
|
||||
const desiredSettings = `{"clients":[{"email":"a@x"}]}`
|
||||
central := &model.Inbound{
|
||||
UserId: 1,
|
||||
NodeID: &id,
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: desiredSettings,
|
||||
}
|
||||
if err := db.Create(central).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"b@x"}]}`,
|
||||
Up: 500,
|
||||
Down: 700,
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(id, snap, true, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked dirty: %v", err)
|
||||
}
|
||||
|
||||
var got model.Inbound
|
||||
if err := db.First(&got, central.Id).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
if got.Settings != desiredSettings {
|
||||
t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings)
|
||||
}
|
||||
if got.Up != 500 || got.Down != 700 {
|
||||
t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRemoteTraffic_MissingDisabledInboundIsNotSwept(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
node := &model.Node{Name: "disabled-snapshot", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := &model.Inbound{
|
||||
UserId: 1, NodeID: &node.Id, Tag: "disabled", Enable: false,
|
||||
Port: 24443, Protocol: model.VLESS, Settings: `{"clients":[]}`,
|
||||
}
|
||||
reported := &model.Inbound{
|
||||
UserId: 1, NodeID: &node.Id, Tag: "reported", Enable: true,
|
||||
Port: 24444, Protocol: model.VLESS, Settings: `{"clients":[]}`,
|
||||
}
|
||||
if err := db.Create(disabled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(reported).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
|
||||
Tag: reported.Tag, Enable: true,
|
||||
Port: reported.Port, Protocol: reported.Protocol, Settings: reported.Settings,
|
||||
}}}
|
||||
if _, err := (&InboundService{}).setRemoteTrafficLocked(node.Id, snap, false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Inbound{}).Where("id=?", disabled.Id).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("disabled inbound rows=%d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a *disabled* client attached to a node inbound must still propagate
|
||||
// to the node. The node's own DB carries the (disabled) client, so the central
|
||||
// panel has to mark the node dirty (→ reconcile) instead of dropping the delete
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 6
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "host-node",
|
||||
Address: "10.0.0.6",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "host-node-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Id: 77,
|
||||
Tag: "host-adopt-443",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
}},
|
||||
HostGroups: []*entity.HostGroup{{
|
||||
GroupId: "g-node",
|
||||
InboundIds: []int{77, 99},
|
||||
Hosts: []string{"cdn.example.com:8443"},
|
||||
Remark: "cdn",
|
||||
Security: "tls",
|
||||
Sni: "sni.example.com",
|
||||
Fingerprint: "firefox",
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var central model.Inbound
|
||||
if err := db.Where("tag = ?", "host-adopt-443").First(¢ral).Error; err != nil {
|
||||
t.Fatalf("load adopted inbound: %v", err)
|
||||
}
|
||||
var hosts []model.Host
|
||||
if err := db.Where("inbound_id = ?", central.Id).Find(&hosts).Error; err != nil {
|
||||
t.Fatalf("load adopted hosts: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("adopted host rows = %d, want 1", len(hosts))
|
||||
}
|
||||
h := hosts[0]
|
||||
if h.GroupId != "g-node" || h.Address != "cdn.example.com" || h.Port != 8443 ||
|
||||
h.Security != "tls" || h.Sni != "sni.example.com" || h.Fingerprint != "firefox" || h.Remark != "cdn" {
|
||||
t.Fatalf("adopted host mismatch: %+v", h)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&model.Host{}).Count(&total).Error; err != nil {
|
||||
t.Fatalf("count hosts: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
// NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
|
||||
// public half) to copy into a node's mTLS trust setting, minting the CA and the
|
||||
// master client cert on first call so the panel is ready to present a client
|
||||
// certificate to mtls nodes.
|
||||
func (s *NodeService) NodeMtlsCaCert() (string, error) {
|
||||
settings := SettingService{}
|
||||
ca, err := settings.EnsureNodeMtlsCA()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := settings.EnsureMasterClientCert(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(ca.CertPEM), nil
|
||||
}
|
||||
|
||||
// ReloadMasterMtlsClient validates the master credential currently stored by
|
||||
// the panel and drops cached mTLS connection pools. This makes an intentional
|
||||
// out-of-process credential rotation take effect without restarting x-ui (and
|
||||
// therefore without stopping the xray child process in the same service).
|
||||
func (s *NodeService) ReloadMasterMtlsClient() error {
|
||||
stored, err := (&SettingService{}).LoadMasterClientCert()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tls.X509KeyPair(stored.CertPEM, stored.KeyPEM); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.InvalidateMasterClientConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetNodeMtlsTrustCA stores the CA certificate bundle trusted for incoming
|
||||
// node-API clients. An empty value clears it; changes apply after restart.
|
||||
func (s *NodeService) SetNodeMtlsTrustCA(caPem string) error {
|
||||
caPem = strings.TrimSpace(caPem)
|
||||
if caPem != "" {
|
||||
if _, err := parseCertificateBundlePEM([]byte(caPem)); err != nil {
|
||||
return common.NewError("invalid trust CA certificate bundle: ", err)
|
||||
}
|
||||
}
|
||||
return (&SettingService{}).setString(settingNodeMtlsClientCA, caPem)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestReloadMasterMtlsClientDoesNotMintMissingCredential(t *testing.T) {
|
||||
_ = setupSettingMtlsDB(t)
|
||||
runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
pair, err := (&SettingService{}).EnsureMasterClientCert()
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
||||
})
|
||||
t.Cleanup(func() { runtime.SetMasterClientCertProvider(nil) })
|
||||
if err := (&NodeService{}).ReloadMasterMtlsClient(); err == nil {
|
||||
t.Fatal("reload on a fresh database unexpectedly succeeded")
|
||||
}
|
||||
var count int64
|
||||
keys := []string{settingNodeMtlsCaCert, settingNodeMtlsCaKey, settingNodeMtlsClientCert, settingNodeMtlsClientKey}
|
||||
if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count mTLS settings: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("reload created %d mTLS setting rows, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKeepsMtls(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
cases := []struct {
|
||||
name string
|
||||
in model.Node
|
||||
wantMode string
|
||||
wantErr bool
|
||||
}{
|
||||
{"mtls over https preserved", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", TlsVerifyMode: "mtls"}, "mtls", false},
|
||||
{"mtls over http rejected", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "http", TlsVerifyMode: "mtls"}, "", true},
|
||||
{"unknown mode clamped to verify", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", TlsVerifyMode: "bogus"}, "verify", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
n := c.in
|
||||
err := s.normalize(&n)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if n.TlsVerifyMode != c.wantMode {
|
||||
t.Fatalf("TlsVerifyMode = %q, want %q", n.TlsVerifyMode, c.wantMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeTlsVerifyModeValidatorAcceptsMtls(t *testing.T) {
|
||||
v := validator.New(validator.WithRequiredStructEnabled())
|
||||
base := model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", ApiToken: "t"}
|
||||
|
||||
for _, m := range []string{"verify", "skip", "pin", "mtls"} {
|
||||
n := base
|
||||
n.TlsVerifyMode = m
|
||||
if err := v.Struct(n); err != nil {
|
||||
t.Fatalf("validator rejected valid TlsVerifyMode %q: %v", m, err)
|
||||
}
|
||||
}
|
||||
bad := base
|
||||
bad.TlsVerifyMode = "bogus"
|
||||
if err := v.Struct(bad); err == nil {
|
||||
t.Fatal("validator must reject an unknown TlsVerifyMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMtlsCaCert(t *testing.T) {
|
||||
_ = setupSettingMtlsDB(t)
|
||||
|
||||
got, err := (&NodeService{}).NodeMtlsCaCert()
|
||||
if err != nil {
|
||||
t.Fatalf("NodeMtlsCaCert: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(got))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
t.Fatalf("NodeMtlsCaCert must return a CERTIFICATE PEM, got %q", got)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse returned cert: %v", err)
|
||||
}
|
||||
if !cert.IsCA {
|
||||
t.Fatal("NodeMtlsCaCert must return the CA certificate (IsCA)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNodeMtlsTrustCA(t *testing.T) {
|
||||
_ = setupSettingMtlsDB(t)
|
||||
ns := &NodeService{}
|
||||
settings := SettingService{}
|
||||
|
||||
ca, err := settings.EnsureNodeMtlsCA()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeMtlsCA: %v", err)
|
||||
}
|
||||
|
||||
if err := ns.SetNodeMtlsTrustCA(string(ca.CertPEM)); err != nil {
|
||||
t.Fatalf("SetNodeMtlsTrustCA(valid): %v", err)
|
||||
}
|
||||
pool, err := settings.NodeMtlsClientCAPool()
|
||||
if err != nil || pool == nil {
|
||||
t.Fatalf("valid trust CA must persist + build a pool: pool=%v err=%v", pool, err)
|
||||
}
|
||||
|
||||
if err := ns.SetNodeMtlsTrustCA("not a certificate"); err == nil {
|
||||
t.Fatal("invalid PEM must be rejected (fail closed)")
|
||||
}
|
||||
|
||||
if err := ns.SetNodeMtlsTrustCA(""); err != nil {
|
||||
t.Fatalf("clearing the trust CA must be allowed: %v", err)
|
||||
}
|
||||
pool, _ = settings.NodeMtlsClientCAPool()
|
||||
if pool != nil {
|
||||
t.Fatal("cleared trust CA must yield a nil pool (mTLS off)")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,31 +9,8 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestProbeParsesNetIO(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":5,"mem":{"current":1,"total":2},"netIO":{"up":1000,"down":2000},"panelGuid":"g","uptime":42}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse url: %v", err)
|
||||
}
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
n := &model.Node{Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/", ApiToken: "t", AllowPrivateAddress: true}
|
||||
|
||||
patch, err := (&NodeService{}).probe(context.Background(), n, "")
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
if patch.NetUp != 1000 || patch.NetDown != 2000 {
|
||||
t.Fatalf("net throughput not parsed from status: up=%d down=%d", patch.NetUp, patch.NetDown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHeartbeatStoresNetMetrics(t *testing.T) {
|
||||
_ = setupSettingMtlsDB(t)
|
||||
_ = initTrafficTestDB(t)
|
||||
s := &NodeService{}
|
||||
|
||||
n := &model.Node{Name: "netn", Address: "1.2.3.4", Port: 2053, Scheme: "https", ApiToken: "t"}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"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"
|
||||
)
|
||||
|
||||
func assertStringSet(t *testing.T, label string, got, want []string) {
|
||||
t.Helper()
|
||||
g := append([]string(nil), got...)
|
||||
w := append([]string(nil), want...)
|
||||
slices.Sort(g)
|
||||
slices.Sort(w)
|
||||
if !slices.Equal(g, w) {
|
||||
t.Fatalf("%s = %v, want %v", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// #4983: a synced inbound's OriginNodeGuid must point at the panel that
|
||||
// physically hosts it. A node's own local inbound (empty origin in its
|
||||
// snapshot) is attributed to the node's own GUID; an inbound the node forwards
|
||||
// from its own sub-node (non-empty origin) keeps that deeper GUID across the
|
||||
// hop — so a chained Node1->Node2->Node3 attributes Node3's inbounds to Node3.
|
||||
func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 1
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "node2",
|
||||
Address: "10.0.0.2",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "node2-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{ // node2's own local inbound — reports no origin
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
},
|
||||
{ // forwarded from node2's sub-node (node3) — carries node3's guid
|
||||
Tag: "in-8443-tcp",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
OriginNodeGuid: "node3-guid",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
origin := func(tag string) string {
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound %q: %v", tag, err)
|
||||
}
|
||||
return ib.OriginNodeGuid
|
||||
}
|
||||
|
||||
if og := origin("in-443-tcp"); og != "node2-guid" {
|
||||
t.Fatalf("local inbound origin = %q, want node2-guid (the node's own GUID)", og)
|
||||
}
|
||||
if og := origin("in-8443-tcp"); og != "node3-guid" {
|
||||
t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og)
|
||||
}
|
||||
}
|
||||
|
||||
// A cloned node reports its OWN inbound with its own (duplicated) panelGuid as
|
||||
// the origin. That must be remapped to the node-unique key, not stored verbatim
|
||||
// — otherwise origin_node_guid keeps the shared GUID while online is keyed by
|
||||
// the node-unique key, and the inbound page reads an empty bucket (shows
|
||||
// offline). A genuinely forwarded sub-node GUID is still kept across the hop.
|
||||
func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
// Two nodes share one panelGuid (cloned servers).
|
||||
for _, n := range []*model.Node{
|
||||
{Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"},
|
||||
{Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"},
|
||||
} {
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node %s: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{ // node 1's OWN inbound, reporting its own (shared) panelGuid as origin
|
||||
Tag: "own-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
OriginNodeGuid: "dup",
|
||||
},
|
||||
{ // forwarded from a sub-node with a distinct guid — kept across the hop
|
||||
Tag: "fwd-8443-tcp",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
OriginNodeGuid: "child-guid",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
origin := func(tag string) string {
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound %q: %v", tag, err)
|
||||
}
|
||||
return ib.OriginNodeGuid
|
||||
}
|
||||
|
||||
if og := origin("own-443-tcp"); og != "node:1" {
|
||||
t.Fatalf("cloned node's own inbound origin = %q, want node:1 (remapped from shared GUID)", og)
|
||||
}
|
||||
if og := origin("fwd-8443-tcp"); og != "child-guid" {
|
||||
t.Fatalf("forwarded inbound origin = %q, want child-guid (kept across the hop)", og)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// A node mid-restart can return an empty inbound list with success=true. The
|
||||
// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes
|
||||
// the node's central inbounds and every client on them (what happened to the
|
||||
// Germany node: 0 clients but still online).
|
||||
func TestSetRemoteTraffic_EmptySnapshotKeepsCentralInbounds(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 1
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID, Name: "n", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "g",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
nidPtr := nodeID
|
||||
if err := db.Create(&model.Inbound{
|
||||
UserId: 1, NodeID: &nidPtr, Tag: "remote-in", Enable: true,
|
||||
Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create central inbound: %v", err)
|
||||
}
|
||||
|
||||
// Empty snapshot — the node reported no inbounds this cycle.
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, &runtime.TrafficSnapshot{}, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.Inbound{}).Where("tag = ?", "remote-in").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count inbounds: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("empty snapshot must not delete the central inbound; got count = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 1
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "node2",
|
||||
Address: "10.0.0.2",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "node2-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
nodeIDPtr := nodeID
|
||||
if err := db.Create(&model.Inbound{
|
||||
UserId: 1,
|
||||
NodeID: &nodeIDPtr,
|
||||
Tag: "remote-in",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: "edge.example.com",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create central inbound: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "remote-in",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if ib.ShareAddrStrategy != "custom" || ib.ShareAddr != "edge.example.com" {
|
||||
t.Fatalf("share address fields were overwritten: strategy=%q addr=%q", ib.ShareAddrStrategy, ib.ShareAddr)
|
||||
}
|
||||
if ib.Port != 8443 {
|
||||
t.Fatalf("sync should still update regular remote fields; port = %d, want 8443", ib.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRemoteTraffic_DefaultsShareAddressFieldsForNewCentralInbound(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 1
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "node2",
|
||||
Address: "10.0.0.2",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "node2-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "remote-in",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: "remote.example.com",
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if ib.ShareAddrStrategy != "node" || ib.ShareAddr != "" {
|
||||
t.Fatalf("new central inbound share fields = (%q, %q), want (node, empty)", ib.ShareAddrStrategy, ib.ShareAddr)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func TestNodeProbeOnlyUpdateAndView(t *testing.T) {
|
||||
lies := false
|
||||
base := func() *NodeMutationRequest {
|
||||
return &NodeMutationRequest{
|
||||
Name: "probe-svc", NodeType: "agent", Enable: true,
|
||||
Name: "probe-svc", Enable: true,
|
||||
Scheme: "https", BasePath: "/",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -86,77 +85,3 @@ func TestEffectiveNodeKey_FallsBackOnCollision(t *testing.T) {
|
||||
t.Errorf("master collision: got %q, want node:4", got)
|
||||
}
|
||||
}
|
||||
|
||||
// recountByGuid must split per-node counts even when two direct nodes share a
|
||||
// GUID and their inbounds still carry that shared GUID as origin (pre-backfill).
|
||||
func TestRecountByGuid_SplitsClonedNodesWithSharedGuid(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
svc := NodeService{}
|
||||
selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
||||
|
||||
n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
|
||||
n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
|
||||
n3 := &model.Node{Id: 3, Name: "C", Address: "10.0.0.3", Port: 2053, ApiToken: "t", Guid: "uniq", Status: "online"}
|
||||
for _, n := range []*model.Node{n1, n2, n3} {
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node %s: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
id1, id2, id3 := 1, 2, 3
|
||||
inbounds := []*model.Inbound{
|
||||
{Tag: "a", Port: 1001, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id1, OriginNodeGuid: "dup"},
|
||||
{Tag: "b", Port: 1002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id2, OriginNodeGuid: "dup"},
|
||||
{Tag: "c", Port: 1003, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id3, OriginNodeGuid: "uniq"},
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []*model.Node{n1, n2, n3}
|
||||
svc.recountByGuid(nodes, selfGuid)
|
||||
|
||||
if n1.InboundCount != 1 || n2.InboundCount != 1 {
|
||||
t.Errorf("cloned nodes must not share inbound counts: n1=%d n2=%d, want 1,1", n1.InboundCount, n2.InboundCount)
|
||||
}
|
||||
if n3.InboundCount != 1 {
|
||||
t.Errorf("unique node InboundCount = %d, want 1", n3.InboundCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A cloned node's IP-attribution subtree must be stored under its node-unique
|
||||
// key, so a second clone sharing the GUID can't overwrite it in node_client_ips.
|
||||
func TestMergeClientIpsByGuid_RemapsClonedNodeSubtree(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
db := database.GetDB()
|
||||
svc := &InboundService{}
|
||||
now := time.Now().Unix()
|
||||
|
||||
n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
|
||||
n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
|
||||
for _, n := range []*model.Node{n1, n2} {
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.MergeClientIpsByGuid(n1, map[string]map[string][]model.ClientIpEntry{
|
||||
"dup": {"u@x": {{IP: "1.1.1.1", Timestamp: now}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("merge n1: %v", err)
|
||||
}
|
||||
|
||||
var rows []model.NodeClientIp
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("load rows: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 attribution row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].NodeGuid != "node:1" {
|
||||
t.Errorf("cloned node IPs must be stored under node-unique key, got %q", rows[0].NodeGuid)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user