27 Commits
Author SHA1 Message Date
Muse 7244f3c9da install-agent: RELEASE_BASE -> V2.4.0 (sh + ps1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 19:40:01 +00:00
Muse f9183e9dbb merge: feat/b6-traditional-removal (B6 phase 2: traditional node removal) 2026-10-23 19:39:51 +00:00
Muse f99c43dd2a tests: restore legacy ApiToken trim in normalize; update probe public contract for stack markers (rider 6); drop legacy panel-probe job test 2026-10-23 19:14:01 +00:00
Muse ef89259a49 B6 frontend: agent-only node form/list, drop mTLS modal and homepage config button (rider 5); regenerate contracts 2026-10-23 19:08:26 +00:00
Muse 4c04cdb1bc rider: anonymous probe view emits ipv4/ipv6 stack markers, never addresses 2026-10-23 19:08:25 +00:00
Muse bdfcce0e11 B6 backend: remove traditional node path, node mTLS and remote merge engine; drop restartXrayOnClientDisable (rider 1) and ApiScopeNodeSync (rider 3) 2026-10-23 19:08:24 +00:00
Muse 7ba1841df2 riders: drop sidebar donate/docs links; rename zh-CN TG bot tab label 2026-10-23 19:08:08 +00:00
Muse 3a3c7c0dc9 install-agent: RELEASE_BASE -> V2.3.0 (sh + ps1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 17:26:25 +00:00
Muse 388e26c292 geoip: pin default weekly-update URL to Gitea geoip-country asset (V2.3.0) 2026-10-23 17:26:15 +00:00
Muse 1d1258a10d merge fix/no-probeonly-flag into main (V2.3.0) 2026-10-23 17:25:59 +00:00
Muse 81299db6ff merge feat/geoip-flag into main (V2.3.0) 2026-10-23 17:25:55 +00:00
Muse 89b65f1606 merge feat/agent-split into main (V2.3.0) 2026-10-23 17:25:50 +00:00
Muse d16b888146 geoip: tests — known-address calibration, source order, pin precedence, updater download/validate/replace, contract frame 2026-10-23 16:42:28 +00:00
Muse c94136c8a1 geoip: resolve node countries locally for the public views (Q7)
country/country_auto in buildProbeNodeView now come from a local
MMDB lookup of the node's public address (held v4, held v6, seen
v4, seen v6) instead of the pin alone / a hardcoded empty string.
Layered data: local file in the data folder wins, embedded factory
snapshot is the fallback, no database means the pre-flag empty
shape. A weekly job conditionally downloads a fresher copy,
validates it, atomically replaces the local file and hot-swaps the
reader; no URL configured (geoipCountryDefaultURL / the
geoip_country_url setting) means the updater stays silent. No
address ever leaves the master.
2026-10-23 16:42:28 +00:00
Muse e7f4bfed24 geoip: embed DB-IP Lite country database snapshot (CC BY 4.0, provenance in NOTICE) 2026-10-23 16:42:28 +00:00
Muse 6b9832cdcc install-agent: download the standalone agent artifact
XUI_URL default switches from x-ui-linux-<arch> (panel binary) to
x-ui-agent-linux-<arch>; RELEASE_BASE mechanism unchanged. The new
asset exists from the first split release on — bumping RELEASE_BASE
stays a release-step action.
2026-10-23 16:40:35 +00:00
Muse e78cb8ccc5 feat(agent): standalone Linux agent binary + agent assets in update chain
New entry cmd/xui-agent links only the agent dependency closure
(internal/agent + config/logger/selfupdate/xray/util leaves), a
drop-in replacement for the dual-mode binary's agent subcommand:
same invocation (xui-agent agent -config ...), same agent.json and
state files, same wire protocol.

selfupdate gains AgentAssetName (x-ui-agent-linux-<arch>); the
agent's one-click update now downloads the agent asset instead of
the panel binary. Pre-split agents keep fetching the panel asset —
the two forms are interchangeable at the install path.
2026-10-23 16:40:35 +00:00
Muse ef662c9fdb panel: install commands no longer append --probe-only
Probe-only is governed solely by the master-side flag so unchecking it
in the panel takes effect on its own; an agent.json preset would pin
it via the dual-source OR gate. The install-agent.sh/ps1 --probe-only
flag stays for manual installs. tsc --noEmit clean.
2026-10-24 00:22:24 +08:00
Muse a81eff8c0d install-agent: RELEASE_BASE -> V2.2.1 (sh + ps1)
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
2026-10-23 15:25:09 +00:00
Muse 1d7eb16784 merge feat/v221: windows probe degradation + agent heartbeat probe + update-check fallback + procd/OpenRC script fixes + probe traffic GB unification + public remark name (V2.2.1) 2026-10-23 15:24:55 +00:00
Muse a13179ed9b v221 ⑦ public views: node name prefers the public remark
buildProbeNodeView now sources its 'name' from probeDisplayName:
the node's public remark (probe_meta.public_remark) when non-empty
after trimming, else the node name as before. Contract field name
unchanged, theme packages untouched, both audiences (anonymous and
admin frames share the builder); the panel's internal node list is
a different surface and keeps the plain node name.

Tests: display-name unit cases (remark / empty / whitespace) and
view-level assertions for both audiences; the public contract
test's seeded expectation updated to the remark (its fixture sets
public_remark='shown publicly' on node 'PubNode').
2026-10-23 15:02:12 +00:00
Muse 93c1620c15 v221 ⑥ probe traffic: binary GB convention + one-shot calibration
Unit unification: GB entries converted at 1e9 while every byte
formatter is binary (1024^3), so a 3000 GB limit rendered 2.73 TB
instead of 2.93 TB. calBytes and the form's limit conversion now
use 1024^3. Stored traffic_limits (bytes, typed x1e9) are rescaled
x1.073741824 exactly once at store open, guarded by the
probe_setting mark gb_1024_migrated_v1; calibration values are
stored in GB (no rescale — only their conversion changed) and
price/sort/reset-day are unitless.

One-shot calibration: the form's cal fields are deltas to apply
now. probe.MergeCalDelta folds a non-zero submitted value into the
stored standing correction and keeps the standing value on a
zero/absent submission; putMeta merges against the stored row
(SaveMeta/UpsertMeta keep plain replace — the notify sweep relies
on it). GET no longer echoes the standing correction into the form,
and the frontend keeps the cal inputs as local state that starts
empty and clears on successful save, so a later unrelated save
resubmits nothing.

Tests: calBytes(3000)=3000x2^30; migration 3.0e12 -> 3000x2^30 with
no second rescale on reopen; one-shot flow (save delta, save
unrelated with zero cal -> calibrated traffic unmoved, second delta
accumulates once). Existing TestTrafficCalibrationAppliedAndFloored
expectation updated to the binary figure. No vitest covers
ProbeMetaSection (disclosed in the report).
2026-10-23 15:02:11 +00:00
Muse ba57fb3c00 v221 ⑤ install-agent: OpenRC respawn_period=0 rejected by supervise-daemon
The item-23 template wrote respawn_period=0 assuming it meant 'no
window' like respawn_max=0; supervise-daemon validates the value at
service start and refuses: Invalid respawn-period value '0'
(Alpine real-machine report). Template now writes 1800, the value
the machine's other supervise-daemon services use; with
respawn_max=0 (unlimited) the period is only the counting window.

Why existing Alpine nodes (Leg-US) never hit it: the respawn lines
exist only since 27a5097 (2026-10-22, item 23) — before that the
template used command_background with no respawn parameters at all,
and panel one-click agent updates swap only the binary, never
regenerate /etc/init.d/x-ui-agent. Only fresh installs (or script
reruns) on OpenRC with the post-item-23 template were exposed.
2026-10-23 14:27:23 +00:00
Muse 151b07b73f v221 ④ install-agent: OpenWrt/procd support + honest nohup fallback
detect_init_system gains a procd tier ([ -f /etc/rc.common ] &&
command -v procd, after systemd/OpenRC; XUI_INIT_SYSTEM=procd also
accepted). start_via_procd writes /etc/init.d/xui-agent byte-equal
to the unit hand-verified on AX6600 (paths via AGENT_BIN/CONF_DIR),
stops any previous instance before overwriting, enables + starts,
and verifies the process is alive (pgrep, ps fallback). The nohup
fallback now errors out immediately and clearly when nohup itself
is missing (BusyBox-trimmed systems) instead of pretending to start
and failing at the end.
2026-10-23 14:26:31 +00:00
Muse cec5905483 v221 ③ update check: fall back to raw version when display is unparseable
displayUpdateFields returned avail=false whenever the running
build's display version could not be compared (the VV2.1.0
double-letter builds), so the panel swallowed a real update prompt
and the native updater refused to run. displayUpdateFieldsFor now
takes the current display/raw versions as inputs and, when the
display comparison cannot judge, compares the latest tag against
the raw base version with the same tolerant display parser —
unparseable raw base yields false. (Not isNewerVersion: on this
fork's uppercase-V tags it degrades to string inequality, which
would phantom-report an update on form difference alone.)
Parseable display versions keep the segment rule untouched.
Regression tests cover the VV scenario in both raw forms.
2026-10-23 14:26:31 +00:00
Muse 1df78dd874 v221 ② node probe: agent nodes answer from heartbeat, not a dead dial
NodeService.Probe now checks for a bound agent first: agent-managed
nodes synthesize the result from agent_nodes.last_seen (MAX over
bound agents, ms) — fresh within the same 180s window the heartbeat
job expires by = online with the heartbeat age as latency; stale or
never-seen = offline with LatencyMs -1. No dial, and LastError is
never written for agent nodes (the stored dial error from the
placeholder address https://agent:2053 is also healed on next ⚡,
since the synthesized patch carries an empty error through
UpdateHeartbeat). Traditional-node dial path unchanged; verdict is a
pure function with unit tests.

Frontend: the ⚡ tooltip/menu used t('pages.nodes.probe'), which
since the probe-metadata work resolves to the status-page SECTION
object, so i18next fell back to rendering the raw key. The existing
pages.nodes.testConnection key (present in all 13 locales) is the
right label — point the three usages at it. No new i18n keys.
2026-10-23 14:26:31 +00:00
Muse 92dfdc542c v221 ① windows probe: gate optional procs via Find + per-source recover guard
LazyProc.Call must-finds and panics on a missing export; the trimmed
Tianyi cloud-PC image has no FreeMibIfTable2 in iphlpapi.dll, which
killed the agent on its first collection after a successful register.
Optional procs (GetSystemTimes/GlobalMemoryStatusEx/GetIfTable2Ex+
FreeMibIfTable2 as one gated pair) now resolve through sourceGate
(Find once, missing = source disabled with one warning); the iface
pair is gated together so a missing free proc never leaks the
system table. collectMetrics additionally runs every source through
guardedCall so any residual panic zeroes that source alone.
Pure gate/guard logic lives in probe_guard.go (no build tag) with
Linux-runnable regression tests.
2026-10-23 14:26:22 +00:00
130 changed files with 2539 additions and 13679 deletions
+113
View File
@@ -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 -24
View File
@@ -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": "",
+21 -102
View File
@@ -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 -19
View File
@@ -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;
+6 -21
View File
@@ -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(),
-38
View File
@@ -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}
-1
View File
@@ -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'));
+3 -71
View File
@@ -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 />,
+48 -431
View File
@@ -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>
+186 -353
View File
@@ -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,
}}
/>
)}
+8 -166
View File
@@ -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>
);
+30 -8
View File
@@ -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')}
+1 -1
View File
@@ -19,7 +19,7 @@ interface ApiTokenRow {
name: string;
enabled: boolean;
createdAt: number;
scope: 'admin' | 'monitor' | 'node-sync';
scope: 'admin' | 'monitor';
expiresAt: number;
}
+19 -74
View File
@@ -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>;
-1
View File
@@ -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'],
+4 -5
View File
@@ -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}
+1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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()
}
+63
View File
@@ -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")
}
}
+82 -15
View File
@@ -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)))
+6 -1
View File
@@ -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
+3 -1
View File
@@ -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)
}
-30
View File
@@ -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`
}
+3 -4
View File
@@ -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 {
+25 -3
View File
@@ -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"
+12
View File
@@ -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")
+1 -46
View File
@@ -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
+1 -94
View File
@@ -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)
-6
View File
@@ -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 {
-20
View File
@@ -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
View File
@@ -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)
}
}
+19 -4
View File
@@ -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
}
+71 -2
View File
@@ -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"])
}
}
-57
View File
@@ -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)
}
+1 -2
View File
@@ -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")
}
}
-1
View File
@@ -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}),
+23
View File
@@ -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)
}
}
+7 -35
View File
@@ -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)
}
}
}
}
+10 -280
View File
@@ -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)
}
})
}
}
-27
View File
@@ -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
View File
@@ -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)
}
}
-319
View File
@@ -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)
}
}
-950
View File
@@ -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)
}
}
-71
View File
@@ -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")
}
})
}
-469
View File
@@ -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)
}
})
}
}
-282
View File
@@ -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)
}
})
}
-537
View File
@@ -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)
}
}
-2
View File
@@ -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)
}
}
+376
View File
@@ -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)
}
+259
View File
@@ -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")
}
+26
View File
@@ -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.
-18
View File
@@ -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 {
-100
View File
@@ -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(&currentIps).Error; err != nil {
return err
}
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
for i := range currentIps {
currentMap[currentIps[i].ClientEmail] = &currentIps[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
-61
View File
@@ -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.
+37 -9
View File
@@ -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)
}
+22 -144
View File
@@ -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(&central).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)
}
+1 -5
View File
@@ -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
View File
@@ -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)
}
}
+34 -106
View File
@@ -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
}
-95
View File
@@ -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(&central).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)
}
}
-53
View File
@@ -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)
}
-138
View File
@@ -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 -29
View File
@@ -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)
}
}
+1 -1
View File
@@ -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