45 Commits
Author SHA1 Message Date
Muse 6842da3c7e release: panel V2.5.0 / agent line A2.4.0 (agent code changed in P3); install-agent RELEASE_BASE -> V2.5.0 (ps1 frozen at V2.4.0)
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
Docs CI / build (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
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
2026-10-24 02:16:33 +00:00
Muse 6f4a4379d9 merge: feat/agent-version-decouple (agent version line decoupling + P3 memory tuning) 2026-10-24 01:59:19 +00:00
Muse 11afed3bac P3 A1: move memory tuning into stdlib-only util/memtuning (keeps agent closure lean) 2026-10-23 22:10:51 +00:00
Muse 1ee07ba73d P3 M1 test fix: assert go-i18n fallback contract (default text + MessageNotFoundErr) 2026-10-23 22:08:05 +00:00
Muse d70fd38978 P3 M4: column-level raw updates on agent ingest hot paths 2026-10-23 21:59:05 +00:00
Muse 5a7f89ee21 P3 M1: lazy per-language i18n parsing (fallback + bot language at startup) 2026-10-23 21:59:05 +00:00
Muse 6dd31b3619 P3 rider: stop sing-box core as well as xray before update re-exec 2026-10-23 21:59:05 +00:00
Muse c59882b846 P3 A1+M5: agent memory tuning profile and XUI_PPROF gate on both agent entries 2026-10-23 21:59:05 +00:00
Muse e5d42224aa decouple agent version line from panel version
Agent display version now comes from the agent line's own source
(config.agentDisplayVersion injection + defaultAgentDisplayVersion
constant, currently A2.3.0) instead of being derived from the panel's
displayVersion. Masters resolve the agent line's latest from each
release's agent-version.txt marker (selfupdate.ResolveLatestAgentTarget,
legacy tag derivation as fallback for pre-marker releases): update
intents target the marker under the panel tag, panel-only releases
produce no intent, and the nodes page badges agent rows against
their own line via PanelUpdateInfo.displayLatestAgentVersion.

Windows agent frozen at A2.2.1: release discipline in docs/release.md
+ scripts/release-build.sh (agent assets reused verbatim when the
agent code is unchanged, marker always emitted, no Windows exe),
install-agent.ps1 pinned at V2.4.0, and the A2.2.1 protocol shapes
locked by TestAgentLegacyWindows221Contract.

Agent update tests now derive their targets from the live agent
line instead of a hardcoded A2.0.x the line has passed.
2026-10-23 21:47:10 +00:00
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
Muse a25c51003e install-agent: RELEASE_BASE -> V2.2.0 (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 13:30:56 +00:00
Muse b27bcfcb5b merge feat/v210-probeonly-win: windows probe agent + version display + reality sni fix (V2.2.0) 2026-10-23 13:30:40 +00:00
Muse 75f10b27fa windows probe-only agent + agent version display unification + reality sni from sing-box fragment
Scope B (Windows agent):
- probe.go split into shared core + probe_linux.go (verbatim Linux
  sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes
  CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2
  counted set, MachineGuid boot identity, registry facts; load and
  TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper)
- update intents are acknowledged and skipped on Windows (the release
  assets are Linux binaries; Windows nodes update via install-agent.ps1)
- cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/
  uninstall/start/stop) mirroring main.go's agent lifecycle wiring
- install-agent.ps1: downloads the exe, writes probeOnly agent.json,
  registers and starts the service, rerun-safe

Scope C (version display):
- config.GetAgentDisplayVersion (and the master getter) compose from
  the bare number so a V-form injection no longer doubles the letter
  (AV2.1.0 -> A2.1.0)
- node list view repairs stored AV-prefixed reports at assembly so
  deployed agents read uniformly without an update

Scope D (production fix):
- share-link reality sni: when the inbound has a sing-box fragment,
  sni is the fragment's tls.server_name - the only name the sing-box
  REALITY server serves. The previous rule picked a uniformly random
  serverNames entry per generation, which xray tolerated but sing-box
  refuses as an invalid connection. sid keeps its random pick: the
  converter carries the whole shortIds list into the fragment, so
  every listed sid is served.
2026-10-23 13:22:51 +00:00
Muse 7df3a338ef install-agent: RELEASE_BASE -> V2.1.0
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 19:57:56 +08:00
Rogue 5ca22ec340 probe-only node mode: master flag + agent gate + panel switch (V2.1.0 A)
agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
2026-10-23 11:54:35 +00:00
Muse 2d37a6de18 install-agent: RELEASE_BASE -> V2.0.3
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
2026-10-23 18:45:22 +08:00
Muse db6f666ff7 probe: fast report stamps last-seen; snapshot TTL 900ms (V2.0.3)
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 18:38:56 +08:00
Muse d4d259fa58 probe ws: frame cadence follows probe_fast_interval (V2.0.2)
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 18:15:05 +08:00
Muse 9caa4e0cf9 install-agent: RELEASE_BASE -> V2.0.1
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
2026-10-23 16:13:25 +08:00
177 changed files with 6988 additions and 14612 deletions
+270
View File
@@ -0,0 +1,270 @@
//go:build windows
// Command xui-agent-windows is the standalone Windows build of the
// 3x-ui node agent. Windows nodes are probe-only by design: the agent
// reports host facts/metrics and runs latency probes, but no xray or
// sing-box core is ever fetched or started here (the agent core
// machinery stays the master's Linux fleet concern; the local
// agent.json carries "probeOnly": true, written by
// install-agent.ps1).
//
// Usage:
//
// xui-agent-windows.exe [-config C:\x-ui\agent.json] run (service or console)
// xui-agent-windows.exe install [-config <path>] register the service
// xui-agent-windows.exe uninstall remove the service
// xui-agent-windows.exe start | stop control the service
//
// The agent lifecycle wiring (config load, baseline derivation,
// logger init, graceful stop) mirrors main.go's agent branch — the
// same internal/agent package does the work either way.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/agent"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/op/go-logging"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const (
serviceName = "x-ui-agent"
defaultCfgPath = `C:\x-ui\agent.json`
)
func main() {
log.SetPrefix("[x-ui-agent] ")
args := os.Args[1:]
// Management subcommands come first; anything else is a run.
if len(args) > 0 {
switch args[0] {
case "install", "uninstall", "start", "stop":
cfgPath := parseConfigFlag("xui-agent-windows "+args[0], args[1:])
if err := runControl(args[0], cfgPath); err != nil {
log.Fatalf("%s: %v", args[0], err)
}
return
}
}
cfgPath := parseConfigFlag("xui-agent-windows", args)
isService, err := svc.IsWindowsService()
if err != nil {
log.Fatalf("detect service environment: %v", err)
}
if isService {
if err := svc.Run(serviceName, &agentHandler{cfgPath: cfgPath}); err != nil {
log.Fatalf("service run: %v", err)
}
return
}
// Interactive run: console foreground, Ctrl+C stops gracefully.
running, err := startAgent(cfgPath)
if err != nil {
log.Fatalf("start agent: %v", err)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
select {
case <-sigCh:
log.Println("Shutting down agent...")
running.Stop()
<-running.done
case err := <-running.done:
if err != nil {
log.Fatalf("Agent error: %v", err)
}
}
}
func parseConfigFlag(name string, args []string) string {
fs := flag.NewFlagSet(name, flag.ExitOnError)
cfg := fs.String("config", defaultCfgPath, "Path to agent config file")
_ = fs.Parse(args)
return *cfg
}
// runningAgent is one started agent plus its completion signal.
type runningAgent struct {
ag *agent.Agent
done chan error
}
func (r *runningAgent) Stop() { r.ag.Stop() }
// startAgent performs the same setup as main.go's agent branch and
// launches the agent loop in the background.
func startAgent(cfgPath string) (*runningAgent, error) {
// Agent mode uses the panel's directory layout for core binaries
// and configs; on a probe-only Windows node nothing consumes it,
// but keep the wiring identical to the Linux entry so behaviour
// cannot drift between the two.
if os.Getenv("XUI_BIN_FOLDER") == "" {
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
}
cfg, err := agent.LoadConfig(cfgPath)
if err != nil {
return nil, fmt.Errorf("load agent config from %s: %w", cfgPath, err)
}
// The acknowledged traffic baseline persists next to the agent
// config (its state home), unless the config names a path.
if cfg.BaselinePath == "" {
cfg.BaselinePath = filepath.Join(filepath.Dir(cfgPath), "agent-traffic-baseline.json")
}
logger.InitLogger(logging.INFO)
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
ag := agent.New(cfg)
r := &runningAgent{ag: ag, done: make(chan error, 1)}
go func() { r.done <- ag.Run() }()
return r, nil
}
// agentHandler adapts the agent loop to the SCM protocol.
type agentHandler struct {
cfgPath string
}
func (h *agentHandler) Execute(_ []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
const accepted = svc.AcceptStop | svc.AcceptShutdown
status <- svc.Status{State: svc.StartPending}
running, err := startAgent(h.cfgPath)
if err != nil {
log.Printf("start agent: %v", err)
return false, 1
}
status <- svc.Status{State: svc.Running, Accepts: accepted}
for {
select {
case err := <-running.done:
// The agent loop ended on its own (e.g. fatal run error).
if err != nil {
log.Printf("agent exited: %v", err)
return false, 1
}
return false, 0
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
status <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
status <- svc.Status{State: svc.StopPending}
running.Stop()
<-running.done
return false, 0
default:
log.Printf("unexpected service command: %v", c.Cmd)
}
}
}
}
// runControl implements the install/uninstall/start/stop subcommands
// through the service control manager.
func runControl(cmd, cfgPath string) error {
m, err := mgr.ConnectRemote("")
if err != nil {
return fmt.Errorf("connect to service manager: %w", err)
}
defer m.Disconnect()
switch cmd {
case "install":
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("locate own binary: %w", err)
}
absCfg, err := filepath.Abs(cfgPath)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
if s, err := m.OpenService(serviceName); err == nil {
s.Close()
return fmt.Errorf("service %s already exists (uninstall first)", serviceName)
}
s, err := m.CreateService(serviceName, exePath, mgr.Config{
DisplayName: "3x-ui Agent",
Description: "3x-ui probe-only node agent (facts, metrics and latency probes; runs no proxy core).",
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "-config", absCfg)
if err != nil {
return fmt.Errorf("create service: %w", err)
}
defer s.Close()
log.Printf("service %s installed (config: %s)", serviceName, absCfg)
return nil
case "uninstall":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
// Stop first so the binary is not locked when the operator
// deletes or replaces it; an already-stopped service is fine.
_, _ = s.Control(svc.Stop)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
st, err := s.Query()
if err != nil || st.State == svc.Stopped {
break
}
time.Sleep(300 * time.Millisecond)
}
if err := s.Delete(); err != nil {
return fmt.Errorf("delete service: %w", err)
}
log.Printf("service %s removed", serviceName)
return nil
case "start":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
if err := s.Start(); err != nil {
return fmt.Errorf("start service: %w", err)
}
log.Printf("service %s started", serviceName)
return nil
case "stop":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
if _, err := s.Control(svc.Stop); err != nil {
return fmt.Errorf("stop service: %w", err)
}
log.Printf("service %s stop requested", serviceName)
return nil
}
return fmt.Errorf("unknown subcommand %q", cmd)
}
func openService(m *mgr.Mgr) (*mgr.Service, error) {
s, err := m.OpenService(serviceName)
if err != nil {
return nil, fmt.Errorf("open service %s: %w", serviceName, err)
}
return s, nil
}
+133
View File
@@ -0,0 +1,133 @@
// 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 a few 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"
"net/http"
_ "net/http/pprof"
"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/mhsanaei/3x-ui/v3/internal/util/memtuning"
"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)
// Agent memory profile (ITEM-P3 A1/M5): the same runtime tuning
// family as the panel, with the agent's tighter budget, plus the
// periodic release the panel runs through its job scheduler (the
// agent has no scheduler), plus the panel's XUI_PPROF gate.
for _, line := range memtuning.ApplyAgentMemoryTuning() {
log.Println(line)
}
memtuning.StartPeriodicMemoryRelease()
if os.Getenv("XUI_PPROF") == "true" {
go func() {
log.Println("pprof profiling server listening on 127.0.0.1:6060")
if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
log.Printf("pprof server stopped: %v", err)
}
}()
}
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)
}
}
+94
View File
@@ -0,0 +1,94 @@
# Release process (L-UI fork)
Releases are published on the self-hosted Gitea (`rogue/3x-ui-agent`).
Build with `scripts/release-build.sh`; this document is the rules that
script encodes. Read it before cutting a release — several rules exist
because violating them already caused a production incident once.
## Two independent version lines
The panel and the agent are maintained as **separate lines** (policy
set 2026-10-24). They never borrow each other's numbers.
| line | form | source of truth | injection |
| --- | --- | --- | --- |
| panel (master) | `V2.4.0` | release tag | `-X .../internal/config.displayVersion` |
| agent | `A2.3.0` | the release's `agent-version.txt` marker | `-X .../internal/config.agentDisplayVersion` (agents only) |
- The agent line moves **only when agent code changes** (anything
under `cmd/xui-agent*`, `internal/agent/`, or code reachable from
the agent binaries). A panel-only change ships a new panel release
whose agent assets are the previous ones, untouched.
- An agent built without injection reports the source default
(`defaultAgentDisplayVersion` in `internal/config`), which must
always equal the current agent line. When the agent line bumps,
bump that constant in the same commit.
- Masters resolve "latest agent version" from the newest release's
`agent-version.txt` marker (`selfupdate.ResolveLatestAgentTarget`),
never by rewriting the panel tag. Releases older than the marker
convention (≤ V2.4.0) transparently fall back to the legacy
tag-derived value, which is exact for them (their agent assets were
still stamped from the tag).
## Asset set
Every release ships exactly:
- `x-ui-linux-amd64`, `x-ui-linux-arm64` (+ `.sha256` sidecars) — panel
- `x-ui-agent-linux-amd64`, `x-ui-agent-linux-arm64` (+ `.sha256`) — agent
- `agent-version.txt` — one line, the agent line's version (`A2.3.0`)
- `geoip-country.mmdb` lives in its own standing release, not here.
**No Windows executable.** See the freeze below.
## The agent reuse rule
When agent code is unchanged since the previous release:
1. Download the previous release's agent binaries **and** their
`.sha256` sidecars.
2. Verify each binary against its sidecar.
3. Upload them to the new release **byte-for-byte** — do not rebuild,
do not re-stamp. Rebuilding the same source with a new injection
would mint a binary that claims a version its code does not earn
and would churn every node's update state for nothing.
4. `agent-version.txt` still names the same agent version.
When agent code changed: build fresh agents, bump **only** the agent
line (and `defaultAgentDisplayVersion`), generate fresh sidecars.
`scripts/release-build.sh` implements both paths (`AGENT_CHANGED=0/1`).
## Windows freeze
The Windows agent is frozen at **A2.2.1**; **V2.4.0 was the last
release containing `x-ui-agent-windows-amd64.exe`**. One machine
(TYYUN, probe-only) runs it and it will not be updated.
Consequences that are part of the release contract:
- `install-agent.ps1` stays pinned to the V2.4.0 release base. Do not
bump it: newer releases contain no exe for it to download.
- The frozen agent cannot adapt to master changes, so the master side
of its protocol is contract-locked by
`TestAgentLegacyWindows221Contract` (register / heartbeat /
probe-report request shapes; config payload keys; response
envelopes). If that test goes red, the change breaks a node that
can never be upgraded — stop and reconsider.
## Pre-publish checks
- [ ] `agent-version.txt` content equals the agent assets' reported
version (for reused assets: the version they already report).
- [ ] Sizes and sha256 of every asset read back through the API after
upload match the local files.
- [ ] Binaries are static (`readelf -d` shows no NEEDED) and carry no
CGO stub strings.
- [ ] Panel self-reports the new V version; agent self-reports the
intended A version (unchanged line ⇒ unchanged report).
- [ ] Pilot: update ONE node first; after it converges, `stat` the
on-disk agent binary and compare size + sha256 with the release
asset. (V2.3.0 lesson: the version display can look right while
the fleet still runs the previous asset generation — the update
asset an agent fetches is decided by the agent's own build.)
- [ ] `install-agent.sh`'s `RELEASE_BASE` points at the new tag.
@@ -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}
+22
View File
@@ -29,6 +29,28 @@ export function formatPanelVersion(version: string | undefined | null): string {
return /^\d/.test(normalized) ? `v${normalized}` : v;
}
// Agent-line counterpart of isPanelUpdateAvailable for the nodes page
// badge. Since the agent/panel version decoupling, agent rows must be
// judged against the AGENT line's latest (the release's
// agent-version.txt marker, surfaced as displayLatestAgentVersion),
// never against the panel's version. The current side must be a clean
// A-form version — anything else (legacy "agent 3.7.0" reports,
// traditional panel versions) simply gets no badge instead of a
// guessed one, and unparseable input never reports an update (the
// phantom-update lesson from the panel side applies here too).
export function isAgentUpdateAvailable(latest: string, current: string): boolean {
if (!latest || !current) return false;
if (!/^[Aa]\d+\.\d+\.\d+$/.test(current.trim())) return false;
const a = parseVersionParts(latest.trim().replace(/^[Aa]/, ''));
const b = parseVersionParts(current.trim().replace(/^[Aa]/, ''));
if (!a || !b) return false;
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return false;
}
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
if (!latest || !current) return false;
const a = parseVersionParts(latest);
-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 />,
@@ -22,6 +22,10 @@ export interface PanelUpdateInfo {
displayCurrentVersion?: string;
displayLatestVersion?: string;
displayUpdateAvailable?: boolean;
// A-series agent-line latest (version decoupling): resolved by the
// server from the newest release's agent-version.txt marker. The
// nodes page badges agent rows against THIS, never the panel line.
displayLatestAgentVersion?: string;
}
interface BusyEvent {
+48 -409
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,20 +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: '',
};
}
@@ -64,9 +40,6 @@ export default function NodeFormModal({
open,
mode,
node,
testConnection,
fetchFingerprint,
fetchInbounds,
save,
onOpenChange,
}: NodeFormModalProps) {
@@ -76,11 +49,6 @@ 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('');
const [agentMasterUrl, setAgentMasterUrl] = useState('');
@@ -95,34 +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 { 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);
@@ -138,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;
@@ -178,9 +111,15 @@ export default function NodeFormModal({
const base =
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
` --master ${editMasterUrl}`;
return shownInstall.hasToken
const cmd = shownInstall.hasToken
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
: `${base} --name ${shownInstall.name}`;
// 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(
@@ -188,81 +127,22 @@ export default function NodeFormModal({
[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,
};
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) {
@@ -274,26 +154,13 @@ 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());
// Default the master URL to the address this panel is being
@@ -337,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
@@ -375,70 +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}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<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>
@@ -475,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')}
@@ -486,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>
+209 -346
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,26 +28,27 @@ import {
PlusOutlined,
RadarChartOutlined,
RightOutlined,
SafetyCertificateOutlined,
TeamOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import NodeHistoryPanel from './NodeHistoryPanel';
import { isAgentUpdateAvailable } from '@/lib/panel-version';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import './NodeList.css';
interface NodeListProps {
nodes: NodeRecord[];
// Agent line's latest version (A-form), resolved by the server from
// the newest release's agent-version marker; rows behind it get the
// update Tag in the panel-version column. Empty = no badges.
agentLatestVersion?: string;
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 +102,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':
@@ -213,13 +219,12 @@ function useRelativeTime() {
export default function NodeList({
nodes,
agentLatestVersion = '',
loading = false,
isMobile = false,
latestVersion = '',
selectedIds,
onSelectionChange,
onAdd,
onMtls,
onProbeTasks,
onEdit,
onDelete,
@@ -235,49 +240,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 +257,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 +356,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,50 +409,42 @@ 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.
// 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.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. The update Tag next to it is
// judged against the AGENT line's latest (version decoupling)
// — a panel-only release must never light it. The one-click
// update action itself 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 || '-'}
{isAgentUpdateAvailable(agentLatestVersion, record.panelVersion || '') && (
<Tooltip title={t('pages.nodes.updateAgent')}>
<Tag color="orange" style={{ marginInlineStart: 6, marginInlineEnd: 0 }}>
{agentLatestVersion}
</Tag>
</Tooltip>
)}
</span>
),
},
{
title: t('pages.nodes.uptime'),
@@ -603,18 +511,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 (
@@ -623,9 +520,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>
@@ -645,133 +539,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>
@@ -793,14 +663,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
@@ -846,17 +711,16 @@ 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">
{t('pages.nodes.panelVersion') || 'Panel version'}
</span>
<Tag>{statsNode.panelVersion || '-'}</Tag>
{isAgentUpdateAvailable(agentLatestVersion, statsNode.panelVersion || '') && (
<Tag color="orange">{agentLatestVersion}</Tag>
)}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.uptime')}</span>
@@ -920,7 +784,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
@@ -935,7 +799,6 @@ export default function NodeList({
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
)}
+23 -164
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 {
@@ -34,39 +29,9 @@ import { useNodeMutations } from '@/api/queries/useNodeMutations';
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>
);
}
import { setMessageInstance } from '@/utils/messageBus';
export default function NodesPage() {
const { t } = useTranslation();
@@ -80,70 +45,26 @@ 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 { create, update, remove, setEnable, 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,
});
// Agent-line latest for the node list's update badge (version
// decoupling): read from the same update-info endpoint the index
// page uses; the server resolves it from the newest release's
// agent-version.txt marker. Best-effort — an empty value just
// leaves every badge dark.
const [agentLatestVersion, setAgentLatestVersion] = useState('');
useEffect(() => {
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj?.displayLatestAgentVersion) {
setAgentLatestVersion(msg.obj.displayLatestAgentVersion);
}
});
}, []);
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 +129,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 +151,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 +174,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]);
@@ -362,13 +257,12 @@ export default function NodesPage() {
<Col span={24}>
<NodeList
nodes={nodes}
agentLatestVersion={agentLatestVersion}
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 +282,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;
}
+22 -72
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,11 @@ 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(),
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
// Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(),
@@ -81,63 +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),
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}
+37 -1
View File
@@ -1,6 +1,42 @@
import { describe, it, expect } from 'vitest';
import { formatPanelVersion, isPanelUpdateAvailable } from '@/lib/panel-version';
import { formatPanelVersion, isAgentUpdateAvailable, isPanelUpdateAvailable } from '@/lib/panel-version';
// The agent badge judges an agent row against the AGENT line's latest
// only (version decoupling): a newer PANEL version must never light
// it, and the comparison is numeric per segment.
describe('isAgentUpdateAvailable', () => {
it('flags a strictly newer agent-line latest', () => {
expect(isAgentUpdateAvailable('A2.4.0', 'A2.3.0')).toBe(true);
expect(isAgentUpdateAvailable('A2.10.0', 'A2.9.9')).toBe(true);
expect(isAgentUpdateAvailable('A3.0.0', 'A2.9.9')).toBe(true);
});
it('stays dark when the agent is current or ahead of its line', () => {
expect(isAgentUpdateAvailable('A2.3.0', 'A2.3.0')).toBe(false);
expect(isAgentUpdateAvailable('A2.3.0', 'A2.4.0')).toBe(false);
});
it('accepts a bare latest and a lowercase a on the current side', () => {
expect(isAgentUpdateAvailable('2.4.0', 'A2.3.0')).toBe(true);
expect(isAgentUpdateAvailable('A2.4.0', 'a2.3.0')).toBe(true);
});
it('never badges non-agent version cells', () => {
// Traditional panel rows and legacy pre-display agent reports
// carry no A-form version: no badge, whatever the agent line says.
expect(isAgentUpdateAvailable('A2.4.0', '3.7.0')).toBe(false);
expect(isAgentUpdateAvailable('A2.4.0', '2.0.0')).toBe(false);
expect(isAgentUpdateAvailable('A2.4.0', 'agent 3.7.0-agent.1')).toBe(false);
expect(isAgentUpdateAvailable('A2.4.0', '')).toBe(false);
expect(isAgentUpdateAvailable('', 'A2.3.0')).toBe(false);
});
it('never flags on unparseable input', () => {
expect(isAgentUpdateAvailable('dev-latest', 'A2.3.0')).toBe(false);
expect(isAgentUpdateAvailable('A2.4.0', 'A2.3')).toBe(false);
});
});
// Parity with web/service/panel.go isNewerVersion.
describe('isPanelUpdateAvailable', () => {
+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=
+104
View File
@@ -0,0 +1,104 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Installs the 3x-ui probe-only agent on Windows (64-bit).
.DESCRIPTION
Downloads x-ui-agent-windows-amd64.exe into C:\x-ui, writes
agent.json with "probeOnly": true (Windows nodes run no proxy
core: facts, metrics and latency probes only), registers the
x-ui-agent Windows service (auto-start) and starts it.
Re-running is safe: an existing service is stopped and removed
first, and any of -Master/-Token/-Name left empty is inherited
from the agent.json already on disk.
.EXAMPLE
.\install-agent.ps1 -Master "https://panel.example.com/rogue" -Token "<agent token>" -Name "win-node-1"
#>
[CmdletBinding()]
param(
[string]$Master,
[string]$Token,
[string]$Name,
[string]$InstallDir = "C:\x-ui",
[string]$AgentUrl = $env:XUI_URL
)
$ErrorActionPreference = "Stop"
# Release download base. FROZEN at V2.4.0 — do not bump: the Windows
# agent line ended at A2.2.1 and V2.4.0 is the last release that
# contains the Windows exe (later releases ship no exe; see
# docs/release.md, "Windows freeze").
$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"
}
$ServiceName = "x-ui-agent"
$ExeName = "x-ui-agent-windows-amd64.exe"
$ExePath = Join-Path $InstallDir $ExeName
$ConfigPath = Join-Path $InstallDir "agent.json"
# Inherit missing parameters from an existing agent.json (reruns).
if (Test-Path $ConfigPath) {
$existing = Get-Content $ConfigPath -Raw | ConvertFrom-Json
if (-not $Master) { $Master = $existing.master_url }
if (-not $Token) { $Token = $existing.token }
if (-not $Name) { $Name = $existing.node_name }
}
if (-not $Master) { throw "missing -Master (and no existing agent.json to inherit from)" }
if (-not $Token) { throw "missing -Token (and no existing agent.json to inherit from)" }
if (-not $Name) { throw "missing -Name (and no existing agent.json to inherit from)" }
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# Stop and remove any previous install so the exe file is not locked
# by a running service when it is replaced below. Prefer the old
# binary's own subcommands; fall back to sc.exe when it is gone.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc) {
if (Test-Path $ExePath) {
& $ExePath stop | Out-Null
& $ExePath uninstall | Out-Null
}
else {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
& sc.exe delete $ServiceName | Out-Null
}
Start-Sleep -Seconds 1
}
# Download the agent binary (via a temp file, then swap into place).
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$tmpExe = "$ExePath.new"
Write-Host "Downloading $AgentUrl"
Invoke-WebRequest -Uri $AgentUrl -OutFile $tmpExe -UseBasicParsing
if (-not (Test-Path $tmpExe) -or (Get-Item $tmpExe).Length -eq 0) {
throw "agent download produced an empty file: $AgentUrl"
}
Move-Item -Force $tmpExe $ExePath
# Write agent.json. Windows agents are probe-only: probeOnly is
# always set (the design contract, not an option).
$config = [ordered]@{
master_url = $Master
token = $Token
node_name = $Name
probeOnly = $true
}
$json = $config | ConvertTo-Json
# Go's encoding/json rejects a UTF-8 BOM, which Set-Content -Encoding
# UTF8 would add on Windows PowerShell 5.1: write BOM-less UTF-8.
[System.IO.File]::WriteAllText($ConfigPath, $json, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Wrote $ConfigPath (probeOnly)"
# Register and start the service.
& $ExePath install -config $ConfigPath
if ($LASTEXITCODE -ne 0) { throw "service install failed (exit $LASTEXITCODE)" }
& $ExePath start
if ($LASTEXITCODE -ne 0) { throw "service start failed (exit $LASTEXITCODE)" }
Write-Host "3x-ui agent installed and running as service '$ServiceName'."
Write-Host "Update later by re-running this script; the master's one-click agent update is intentionally not consumed on Windows."
+97 -15
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# 3x-ui-agent 节点一键安装脚本
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] [--probe-only]
# --probe-only(V2.1.0):纯探针安装——跳过 sing-box 核心下载与自检,
# agent.json 写入 probeOnly=true;agent 只跑探针通道、不运行任何核心。
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
#
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
@@ -9,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,不再与主控共用
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行"
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
MASTER=""; TOKEN=""; NAME=""
MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
while [[ $# -gt 0 ]]; do
case $1 in
--master) MASTER="$2"; shift 2;;
--token) TOKEN="$2"; shift 2;;
--name) NAME="$2"; shift 2;;
--probe-only) PROBE_ONLY=1; shift;;
*) error "未知参数: $1";;
esac
done
@@ -53,6 +56,8 @@ if [[ -f "$AGENT_JSON_PATH" ]]; then
if [[ -z "$MASTER" && -n "$f_master" ]]; then MASTER="$f_master"; INHERITED+=("主控地址"); fi
if [[ -z "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi
if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); fi
# probeOnly 是布尔字段,json_get 只取字符串值,这里单独按 true 沿用。
if [[ -z "$PROBE_ONLY" ]] && grep -q '"probeOnly"[[:space:]]*:[[:space:]]*true' "$AGENT_JSON_PATH"; then PROBE_ONLY=1; INHERITED+=("纯探针"); fi
if [[ ${#INHERITED[@]} -gt 0 ]]; then
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
@@ -76,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.0.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.5.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-agent-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}"
XUI_HOME="${ROOT}/usr/local/x-ui"
@@ -107,18 +117,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
SINGBOX_VER="1.13.21"
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}"
SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
info "纯探针模式:跳过 sing-box 核心下载"
else
info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
fi
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。
self_check() {
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点没有核心可检(V2.1.0),只验 agent 本体。
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
info "纯探针模式:跳过 sing-box 自检"
return 0
fi
local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out
@@ -137,11 +159,14 @@ self_check() {
self_check
info "写入 agent 配置..."
PROBE_ONLY_JSON=""
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
"probeOnly": true'; fi
cat > "${CONF_DIR}/agent.json" <<EOF
{
"master_url": "$MASTER",
"token": "$TOKEN",
"node_name": "$NAME"
"node_name": "$NAME"${PROBE_ONLY_JSON}
}
EOF
chmod 600 "${CONF_DIR}/agent.json"
@@ -200,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
@@ -208,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
}
@@ -266,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"
@@ -302,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
@@ -328,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
+67
View File
@@ -55,6 +55,11 @@ type Config struct {
// Empty: main derives it next to the agent config file; the agent
// falls back to the bin folder.
BaselinePath string `json:"baseline_path,omitempty"`
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
// fetched, started or stopped for the node's inbounds — only the
// probe channels run. The master's per-node flag (delivered on the
// config poll) enables the same mode; either source turns it on.
ProbeOnly bool `json:"probeOnly,omitempty"`
}
// Agent represents the node agent
@@ -124,6 +129,11 @@ type Agent struct {
fastIntervalSec atomic.Int64
fastUnsupported atomic.Bool
// Probe-only mode (V2.1.0): the master's per-node flag rides the
// config poll; the local config preset is the other source. While
// either is on, the poll skips both core branches entirely.
masterProbeOnly atomic.Bool
// Latency probe runner (item 7 B4, see ping.go): task set pushed
// on the config poll, samples returned on the heartbeat.
probes *probeRunner
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
// report, automatic interface selection).
ProbeFastInterval *int64 `json:"probe_fast_interval"`
ProbeStatIface *string `json:"probe_stat_iface"`
// ProbeOnly is a pointer on purpose (V2.1.0), same as the other
// probe keys: a master too old to know the mode omits the field
// and the agent keeps its current state.
ProbeOnly *bool `json:"probe_only"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
if result.ProbeStatIface != nil {
SetStatIfacePin(*result.ProbeStatIface)
}
if result.ProbeOnly != nil {
a.masterProbeOnly.Store(*result.ProbeOnly)
}
// One-click update intents ride this poll (item 20); handle them even
// when the config itself is unchanged.
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
a.probes.applyTasks(*result.ProbeTasks)
}
// Probe-only mode (V2.1.0): no core config is applied at all. The
// master already withholds the payload for flagged nodes, but the
// local preset must gate even against a master that still serves
// one. A core left running from before the mode was turned on is
// retired by the convergence step.
if a.probeOnlyActive() {
a.retireCoresForProbeOnly()
return nil
}
// Switchover (ITEM6 B5): a master that serves a sing-box config
// has made sing-box the node's formal runtime. Apply the config
// through the switch sequence (which retires the legacy xray
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
return nil
}
// probeOnlyActive reports whether probe-only mode is in force from
// either source: the local agent.json preset or the master's
// per-node flag on the config poll.
func (a *Agent) probeOnlyActive() bool {
return a.config.ProbeOnly || a.masterProbeOnly.Load()
}
// retireCoresForProbeOnly converges a node that entered probe-only
// mode while a core was still running: the core's tail traffic is
// flushed best-effort (an explicit operator mode change outranks the
// unreported tail, unlike the switchover retire path, which keeps
// the core alive on a failed flush) and the core is stopped. Later
// polls find nothing running and skip, so the retirement happens
// exactly once per entry. The switchover bookkeeping (sbSwitched,
// lastSingboxHash) is left untouched on purpose: leaving probe-only
// re-serves the same config hash, and the existing "hash current but
// core not running" poll path reactivates the core on its own.
func (a *Agent) retireCoresForProbeOnly() {
if a.xrayProc != nil {
if err := a.collectAndReportXrayTraffic(); err != nil {
logger.Warningf("probe-only: flush xray traffic before retiring its core failed: %v", err)
}
a.stopXray()
logger.Info("probe-only mode: xray core retired")
}
if a.singboxRunning() {
if err := a.collectAndReportSingboxTraffic(); err != nil {
logger.Warningf("probe-only: flush sing-box traffic before retiring its core failed: %v", err)
}
a.stopSingbox()
logger.Info("probe-only mode: sing-box core retired")
}
}
// singboxRunning reports whether the managed sing-box process is
// currently alive.
func (a *Agent) singboxRunning() bool {
@@ -914,6 +975,12 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
// (xray and, since ITEM6 B3, sing-box). The two cores keep separate
// clients and baselines; a failure in one never blocks the other.
func (a *Agent) collectAndReportTraffic() error {
// Probe-only nodes run no core (V2.1.0): the cycle is an explicit
// no-op, so a core retired by the mode can never be mistaken for
// a live one by a later tick.
if a.probeOnlyActive() {
return nil
}
var firstErr error
if err := a.collectAndReportXrayTraffic(); err != nil {
firstErr = err
+41 -786
View File
@@ -1,34 +1,34 @@
package agent
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go:
// host Facts (once per process) and per-heartbeat Metrics read directly from
// /proc and /sys with the standard library only. The port keeps the source's
// probe.go is the platform-neutral core of the probe collector, ported
// from monitor-probe's agent collector (collect.rs, MIT): host Facts
// (once per process) and per-heartbeat Metrics with the source's
// accounting discipline:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
// - network totals sum only "counted" interfaces — loopback, container,
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer
// filter so no byte is counted twice;
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set,
// so a set change re-baselines exactly like a reboot;
// - every source degrades independently: an unreadable file yields a zero
// field, never a failed collection round.
// - memory "used" is free(1)'s column: total - available;
// - disk "used" is df(1)'s: real storage only, total - free blocks
// (not the unprivileged-available figure);
// - network totals sum only "counted" interfaces — loopback,
// container, tunnel and stacked devices are excluded by a filter so
// no byte is counted twice;
// - boot epoch = a stable boot identity + FNV-1a fingerprint of the
// counted set, so a set change re-baselines exactly like a reboot;
// - every source degrades independently: an unreadable source yields
// a zero field, never a failed collection round.
//
// The samplers themselves are per-OS: probe_linux.go reads /proc and
// /sys, probe_windows.go reads the Win32 performance/registry surface.
// Both feed the shared rate/epoch machinery below and must keep the
// same accounting semantics; where a source has no equivalent on the
// platform, the field stays zero (documented at the sampler).
import (
"hash/fnv"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
@@ -72,7 +72,7 @@ type ProbeMetrics struct {
}
// probeCollector holds the cross-sample state the rates need (previous CPU
// jiffies and per-interface counters). Kept in memory only, like the source.
// counters and per-interface counters). Kept in memory only, like the source.
type probeCollector struct {
mu sync.Mutex
prevCPU *cpuSample
@@ -80,6 +80,9 @@ type probeCollector struct {
prevNetT time.Time
}
// cpuSample is one cumulative CPU reading in whatever tick unit the
// platform reports: total covers every state, idle only the idle state.
// The differencing in cpuPercent is unit-agnostic.
type cpuSample struct{ total, idle uint64 }
var (
@@ -94,37 +97,15 @@ func collectFacts() *ProbeFacts {
return factsCache
}
func sampleFacts() *ProbeFacts {
mem := parseMeminfo(readProcFile("meminfo"))
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
v4, v6 := probeAddresses()
return &ProbeFacts{
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
OS: osPrettyName(),
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
Arch: runtime.GOARCH,
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
CPUName: cpuName,
CPUCores: cpuCores,
MemTotal: mem["MemTotal"],
SwapTotal: mem["SwapTotal"],
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// statIfacePin (V2.0.1) names the single interface to count when
// the operator pinned one on the master (its probe_meta.stat_iface,
// delivered on the config poll). "" keeps the automatic counted-set
// selection below. The pin bypasses the three-layer filter on
// purpose: the operator named this exact device, including when it
// is one the filter would skip (a tunnel the node actually bills
// on). A pinned name absent from /proc/net/dev counts nothing —
// the boot epoch changes with the counted set, so the master
// re-baselines instead of booking a phantom delta.
// selection. The pin bypasses the filter on purpose: the operator
// named this exact device, including when it is one the filter would
// skip (a tunnel the node actually bills on). A pinned name absent
// from the interface table counts nothing — the boot epoch changes
// with the counted set, so the master re-baselines instead of booking
// a phantom delta.
var statIfacePin atomic.Value // string
// SetStatIfacePin records the master's counted-interface pin.
@@ -137,6 +118,12 @@ func statIfacePinValue() string {
return ""
}
// netCounters is one interface's cumulative byte totals.
type netCounters struct {
name string
rx, tx uint64
}
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all {
if c.name == pin {
@@ -146,151 +133,9 @@ func pinnedInterfaces(all []netCounters, pin string) []netCounters {
return nil
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
mem := parseMeminfo(readProcFile("meminfo"))
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
rxRate, txRate := probeColl.netRate(counted, time.Now())
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
return &ProbeMetrics{
BootID: bootID,
Uptime: parseUptime(readProcFile("uptime")),
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
Load: parseLoadavg(readProcFile("loadavg")),
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: tcp,
UDP: udp,
Procs: procCount(),
}
}
// --- file helpers -----------------------------------------------------------
const sysNetRoot = "/sys/class/net"
func readProcFile(name string) string {
b, err := os.ReadFile(filepath.Join("/proc", name))
if err != nil {
return ""
}
return string(b)
}
func readTrimFile(path, fallback string) string {
b, err := os.ReadFile(path)
if err != nil {
return fallback
}
if s := strings.TrimSpace(string(b)); s != "" {
return s
}
return fallback
}
// --- memory (free口径) -------------------------------------------------------
func parseMeminfo(text string) map[string]uint64 {
out := make(map[string]uint64)
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
kb, err := strconv.ParseUint(f[0], 10, 64)
if err != nil {
continue
}
out[key] = kb * 1024
}
return out
}
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
// available above the total, as LXCFS can report, -> total - MemFree).
func memUsedFree(m map[string]uint64) (total, used uint64) {
total = m["MemTotal"]
if total == 0 {
return 0, 0
}
avail, ok := m["MemAvailable"]
if !ok {
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
if avail > total {
avail = m["MemFree"]
}
if avail > total {
avail = total
}
return total, total - avail
}
func swapUsedFree(m map[string]uint64) (total, used uint64) {
total = m["SwapTotal"]
free := m["SwapFree"]
if free > total {
free = total
}
return total, total - free
}
// --- cpu --------------------------------------------------------------------
func parseCPUJiffies(stat string) *cpuSample {
for _, line := range strings.Split(stat, "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line[4:])
if len(f) < 5 {
return nil
}
var v [8]uint64
var total uint64
for i := 0; i < len(f) && i < 8; i++ {
n, err := strconv.ParseUint(f[i], 10, 64)
if err != nil {
return nil
}
v[i] = n
total += n
}
return &cpuSample{total: total, idle: v[3] + v[4]}
}
return nil
}
// cpuPercent diffs two cumulative samples into a busy percentage. A
// missing or non-advancing previous sample reports 0 (the round only
// re-baselines) — the source's discipline on both platforms.
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
if now == nil {
return 0
@@ -315,344 +160,14 @@ func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
return pct
}
func parseLoadavg(text string) [3]float32 {
var out [3]float32
f := strings.Fields(text)
for i := 0; i < 3 && i < len(f); i++ {
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
out[i] = float32(v)
}
}
return out
}
func parseUptime(text string) uint64 {
f := strings.Fields(text)
if len(f) == 0 {
return 0
}
v, err := strconv.ParseFloat(f[0], 64)
if err != nil || v < 0 {
return 0
}
return uint64(v)
}
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
func cpuInfo(text string) (string, uint32) {
name := ""
cores := uint32(0)
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "processor":
cores++
case "model name", "Model", "cpu model":
if name == "" {
name = strings.TrimSpace(val)
}
}
}
if cores == 0 {
cores = uint32(runtime.NumCPU())
if cores == 0 {
cores = 1
}
}
if name == "" {
name = armCPUName(text)
}
if name == "" {
name = "unknown"
}
return name, cores
}
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
func armCPUName(text string) string {
var vendor *armVendor
var names []string
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "CPU implementer":
vendor = nil
if id, ok := parseHexID(val); ok {
for i := range armVendors {
if armVendors[i].id == id {
vendor = &armVendors[i]
}
}
}
case "CPU part":
if vendor == nil {
continue
}
name := vendor.name
if id, ok := parseHexID(val); ok {
for _, p := range vendor.parts {
if p.id == id {
name = p.name
}
}
}
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return strings.Join(names, "/")
}
func parseHexID(s string) (uint32, bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return uint32(v), true
}
type armPart struct {
id uint32
name string
}
type armVendor struct {
id uint32
name string
parts []armPart
}
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
var armVendors = []armVendor{
{0x41, "ARM", []armPart{
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
}},
{0x48, "HiSilicon", []armPart{
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
}},
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
}
// --- os / virt ---------------------------------------------------------------
func osPrettyName() string {
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, "\"'")
}
}
}
return "Linux"
}
// detectVirt ports the source's chain: containers first, then hypervisor.
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
// reads as "none", same caveat the source documents for cleared CPUID bits.
func detectVirt(mounts, cpuinfo string) string {
if _, err := os.Stat("/proc/vz"); err == nil {
if _, err := os.Stat("/proc/bc"); err != nil {
return "openvz"
}
}
if s := readTrimFile("/run/systemd/container", ""); s != "" {
return s
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return "podman"
}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
return "lxc"
}
}
if _, err := os.Stat("/proc/xen"); err == nil {
return "xen"
}
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
return "xen"
}
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
for _, m := range []struct{ word, name string }{
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
{"bhyve", "bhyve"}, {"xen", "xen"},
} {
if strings.Contains(dmi, m.word) {
return m.name
}
}
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
return "kvm"
}
if strings.Contains(cpuinfo, "hypervisor") {
return "vm"
}
return "none"
}
// --- network: counted set, epoch, rates -------------------------------------
type netCounters struct {
name string
rx, tx uint64
}
func parseNetDev(text string) []netCounters {
var out []netCounters
lines := strings.Split(text, "\n")
for _, line := range lines {
name, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) < 16 {
continue
}
rx, err1 := strconv.ParseUint(f[0], 10, 64)
tx, err2 := strconv.ParseUint(f[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
}
return out
}
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
var skipIfacePrefixes = []string{
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
}
func skipIfaceName(name string) bool {
for _, p := range skipIfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
// the kernel books their bytes again on the device beneath.
func isStackedName(name string) bool {
if strings.Contains(name, ".") {
return true
}
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var tunnelLinkTypes = map[string]bool{
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
}
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
// are already counted on another device. Hardware (a `device` link) exempts
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
// type with WireGuard but carries real traffic.
func countedElsewhere(sysRoot, name string) bool {
dev := filepath.Join(sysRoot, name)
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
devtypeIs := func(set ...string) bool {
for _, line := range strings.Split(uevent, "\n") {
for _, want := range set {
if line == want {
return true
}
}
}
return false
}
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
return true
}
if entries, err := os.ReadDir(dev); err == nil {
for _, e := range entries {
if strings.HasPrefix(e.Name(), "lower_") {
return true
}
}
}
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
return true
}
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
return true
}
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
}
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
func ifaceCounts(sysRoot, name string) bool {
if skipIfaceName(name) || isStackedName(name) {
return false
}
return !countedElsewhere(sysRoot, name)
}
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
out := make([]netCounters, 0, len(all))
for _, c := range all {
if ifaceCounts(sysRoot, c.name) {
out = append(out, c)
}
}
return out
}
// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}".
// bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}".
// The master only tests it for equality: any change re-baselines.
func bootEpoch(kernelBootID string, names []string) string {
func bootEpoch(bootID string, names []string) string {
sorted := append([]string(nil), names...)
sort.Strings(sorted)
h := fnv.New64a()
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
return kernelBootID + "/" + hex16(h.Sum64())
return bootID + "/" + hex16(h.Sum64())
}
func hex16(v uint64) string {
@@ -697,136 +212,6 @@ func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64,
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
}
// --- addresses (scope §1.5) ---------------------------------------------------
// probeAddresses picks one address per family: candidates come from
// non-skip interfaces that are up, are not link-local, prefer public over
// private, and for v6 avoid temporary/deprecated/tentative addresses.
func probeAddresses() (string, string) {
transient := transientV6(readProcFile("net/if_inet6"))
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
type cand struct {
ip net.IP
transient bool
}
var held []cand
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 {
continue
}
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if !lendsAddress(ifc.Name, ip) {
continue
}
c := cand{ip: ip}
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
for _, t := range transient {
if t.Equal(ip6) {
c.transient = true
}
}
}
held = append(held, c)
}
}
pick := func(v6 bool) string {
var best *cand
for i := range held {
c := &held[i]
if (c.ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = c
continue
}
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
if cp != bp {
if cp {
best = c
}
continue
}
if v6 && best.transient && !c.transient {
best = c
}
}
if best == nil {
return ""
}
return best.ip.String()
}
return pick(false), pick(true)
}
// lendsAddress mirrors the source: a skipped interface lends no address,
// except a WireGuard interface lending a public v6 that is not in
// Cloudflare WARP's shared 2606:4700::/32.
func lendsAddress(name string, ip net.IP) bool {
if !skipIfaceName(name) {
return true
}
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
ip16 := ip.To16()
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
}
return false
}
// transientV6 parses /proc/net/if_inet6 for addresses carrying
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
func transientV6(text string) []net.IP {
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
var out []net.IP
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
flags, err := strconv.ParseUint(f[4], 16, 32)
if err != nil || flags&transientFlags == 0 {
continue
}
if len(f[0]) != 32 {
continue
}
ip := make(net.IP, 16)
ok := true
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
if err != nil {
ok = false
break
}
ip[i] = byte(b)
}
if ok {
out = append(out, ip)
}
}
return out
}
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
// v6 is only 2000::/3.
@@ -853,133 +238,3 @@ func isPublicIP(ip net.IP) bool {
}
return ip16[0]&0xe0 == 0x20
}
// --- sockets / processes ------------------------------------------------------
func connCounts(v4, v6 string) (tcp, udp uint32) {
stat := func(text, prefix, key string) uint32 {
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
f := strings.Fields(line[len(prefix):])
for i := 0; i+1 < len(f); i++ {
if f[i] == key {
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
return uint32(n)
}
return 0
}
}
}
return 0
}
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
return tcp, udp
}
func procCount() uint32 {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
var n uint32
for _, e := range entries {
name := e.Name()
digits := name != ""
for i := 0; i < len(name); i++ {
if name[i] < '0' || name[i] > '9' {
digits = false
break
}
}
if digits {
n++
}
}
return n
}
// --- disk (df口径) ---------------------------------------------------------------
var skipFSTypes = map[string]bool{
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
"fuse": true, "rpc_pipefs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
"glusterfs": true, "9p": true,
}
func skipFSType(fstype string) bool {
if skipFSTypes[fstype] {
return true
}
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
return true
}
return false
}
// realMountPoints ports parse_mounts: only the topmost mount per path,
// deduped by source device (zfs by pool), real storage only.
func realMountPoints(text string) []string {
type row struct{ dev, mount, fstype string }
var rows []row
top := map[string]int{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
rows = append(rows, row{f[0], f[1], f[2]})
top[f[1]] = len(rows) - 1
}
seen := map[string]bool{}
var out []string
for i, r := range rows {
if top[r.mount] != i || skipFSType(r.fstype) {
continue
}
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
continue
}
key := r.dev
if r.fstype == "zfs" {
if j := strings.IndexByte(key, '/'); j >= 0 {
key = key[:j]
}
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
}
return out
}
// diskUsage sums statfs over the mounts: total = f_blocks, used =
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
func diskUsage(mounts []string) (total, used uint64) {
for _, m := range mounts {
var st syscall.Statfs_t
if err := syscall.Statfs(m, &st); err != nil {
continue
}
bs := uint64(st.Frsize)
if bs == 0 {
bs = uint64(st.Bsize)
}
total += st.Blocks * bs
if st.Blocks > st.Bfree {
used += (st.Blocks - st.Bfree) * bs
}
}
return total, used
}
+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")
}
}
+778
View File
@@ -0,0 +1,778 @@
//go:build linux
package agent
// probe_linux.go is the Linux sampler for the probe collector: host
// Facts and per-heartbeat Metrics read directly from /proc and /sys
// with the standard library only. The accounting discipline (see
// probe.go) is the source's:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
// - network totals sum only "counted" interfaces, selected by the
// three-layer filter below so no byte is counted twice;
// - boot epoch = kernel boot_id + fingerprint of the counted set.
import (
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
func sampleFacts() *ProbeFacts {
mem := parseMeminfo(readProcFile("meminfo"))
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
v4, v6 := probeAddresses()
return &ProbeFacts{
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
OS: osPrettyName(),
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
Arch: runtime.GOARCH,
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
CPUName: cpuName,
CPUCores: cpuCores,
MemTotal: mem["MemTotal"],
SwapTotal: mem["SwapTotal"],
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
mem := parseMeminfo(readProcFile("meminfo"))
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
rxRate, txRate := probeColl.netRate(counted, time.Now())
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
return &ProbeMetrics{
BootID: bootID,
Uptime: parseUptime(readProcFile("uptime")),
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
Load: parseLoadavg(readProcFile("loadavg")),
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: tcp,
UDP: udp,
Procs: procCount(),
}
}
// --- file helpers -----------------------------------------------------------
const sysNetRoot = "/sys/class/net"
func readProcFile(name string) string {
b, err := os.ReadFile(filepath.Join("/proc", name))
if err != nil {
return ""
}
return string(b)
}
func readTrimFile(path, fallback string) string {
b, err := os.ReadFile(path)
if err != nil {
return fallback
}
if s := strings.TrimSpace(string(b)); s != "" {
return s
}
return fallback
}
// --- memory (free口径) -------------------------------------------------------
func parseMeminfo(text string) map[string]uint64 {
out := make(map[string]uint64)
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
kb, err := strconv.ParseUint(f[0], 10, 64)
if err != nil {
continue
}
out[key] = kb * 1024
}
return out
}
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
// available above the total, as LXCFS can report, -> total - MemFree).
func memUsedFree(m map[string]uint64) (total, used uint64) {
total = m["MemTotal"]
if total == 0 {
return 0, 0
}
avail, ok := m["MemAvailable"]
if !ok {
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
if avail > total {
avail = m["MemFree"]
}
if avail > total {
avail = total
}
return total, total - avail
}
func swapUsedFree(m map[string]uint64) (total, used uint64) {
total = m["SwapTotal"]
free := m["SwapFree"]
if free > total {
free = total
}
return total, total - free
}
// --- cpu --------------------------------------------------------------------
func parseCPUJiffies(stat string) *cpuSample {
for _, line := range strings.Split(stat, "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line[4:])
if len(f) < 5 {
return nil
}
var v [8]uint64
var total uint64
for i := 0; i < len(f) && i < 8; i++ {
n, err := strconv.ParseUint(f[i], 10, 64)
if err != nil {
return nil
}
v[i] = n
total += n
}
return &cpuSample{total: total, idle: v[3] + v[4]}
}
return nil
}
func parseLoadavg(text string) [3]float32 {
var out [3]float32
f := strings.Fields(text)
for i := 0; i < 3 && i < len(f); i++ {
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
out[i] = float32(v)
}
}
return out
}
func parseUptime(text string) uint64 {
f := strings.Fields(text)
if len(f) == 0 {
return 0
}
v, err := strconv.ParseFloat(f[0], 64)
if err != nil || v < 0 {
return 0
}
return uint64(v)
}
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
func cpuInfo(text string) (string, uint32) {
name := ""
cores := uint32(0)
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "processor":
cores++
case "model name", "Model", "cpu model":
if name == "" {
name = strings.TrimSpace(val)
}
}
}
if cores == 0 {
cores = uint32(runtime.NumCPU())
if cores == 0 {
cores = 1
}
}
if name == "" {
name = armCPUName(text)
}
if name == "" {
name = "unknown"
}
return name, cores
}
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
func armCPUName(text string) string {
var vendor *armVendor
var names []string
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "CPU implementer":
vendor = nil
if id, ok := parseHexID(val); ok {
for i := range armVendors {
if armVendors[i].id == id {
vendor = &armVendors[i]
}
}
}
case "CPU part":
if vendor == nil {
continue
}
name := vendor.name
if id, ok := parseHexID(val); ok {
for _, p := range vendor.parts {
if p.id == id {
name = p.name
}
}
}
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return strings.Join(names, "/")
}
func parseHexID(s string) (uint32, bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return uint32(v), true
}
type armPart struct {
id uint32
name string
}
type armVendor struct {
id uint32
name string
parts []armPart
}
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
var armVendors = []armVendor{
{0x41, "ARM", []armPart{
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
}},
{0x48, "HiSilicon", []armPart{
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
}},
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
}
// --- os / virt ---------------------------------------------------------------
func osPrettyName() string {
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, "\"'")
}
}
}
return "Linux"
}
// detectVirt ports the source's chain: containers first, then hypervisor.
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
// reads as "none", same caveat the source documents for cleared CPUID bits.
func detectVirt(mounts, cpuinfo string) string {
if _, err := os.Stat("/proc/vz"); err == nil {
if _, err := os.Stat("/proc/bc"); err != nil {
return "openvz"
}
}
if s := readTrimFile("/run/systemd/container", ""); s != "" {
return s
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return "podman"
}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
return "lxc"
}
}
if _, err := os.Stat("/proc/xen"); err == nil {
return "xen"
}
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
return "xen"
}
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
for _, m := range []struct{ word, name string }{
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
{"bhyve", "bhyve"}, {"xen", "xen"},
} {
if strings.Contains(dmi, m.word) {
return m.name
}
}
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
return "kvm"
}
if strings.Contains(cpuinfo, "hypervisor") {
return "vm"
}
return "none"
}
// --- network: counted set, epoch, rates -------------------------------------
func parseNetDev(text string) []netCounters {
var out []netCounters
lines := strings.Split(text, "\n")
for _, line := range lines {
name, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) < 16 {
continue
}
rx, err1 := strconv.ParseUint(f[0], 10, 64)
tx, err2 := strconv.ParseUint(f[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
}
return out
}
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
var skipIfacePrefixes = []string{
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
}
func skipIfaceName(name string) bool {
for _, p := range skipIfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
// the kernel books their bytes again on the device beneath.
func isStackedName(name string) bool {
if strings.Contains(name, ".") {
return true
}
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var tunnelLinkTypes = map[string]bool{
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
}
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
// are already counted on another device. Hardware (a `device` link) exempts
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
// type with WireGuard but carries real traffic.
func countedElsewhere(sysRoot, name string) bool {
dev := filepath.Join(sysRoot, name)
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
devtypeIs := func(set ...string) bool {
for _, line := range strings.Split(uevent, "\n") {
for _, want := range set {
if line == want {
return true
}
}
}
return false
}
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
return true
}
if entries, err := os.ReadDir(dev); err == nil {
for _, e := range entries {
if strings.HasPrefix(e.Name(), "lower_") {
return true
}
}
}
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
return true
}
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
return true
}
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
}
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
func ifaceCounts(sysRoot, name string) bool {
if skipIfaceName(name) || isStackedName(name) {
return false
}
return !countedElsewhere(sysRoot, name)
}
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
out := make([]netCounters, 0, len(all))
for _, c := range all {
if ifaceCounts(sysRoot, c.name) {
out = append(out, c)
}
}
return out
}
// --- addresses (scope §1.5) ---------------------------------------------------
// probeAddresses picks one address per family: candidates come from
// non-skip interfaces that are up, are not link-local, prefer public over
// private, and for v6 avoid temporary/deprecated/tentative addresses.
func probeAddresses() (string, string) {
transient := transientV6(readProcFile("net/if_inet6"))
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
type cand struct {
ip net.IP
transient bool
}
var held []cand
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 {
continue
}
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if !lendsAddress(ifc.Name, ip) {
continue
}
c := cand{ip: ip}
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
for _, t := range transient {
if t.Equal(ip6) {
c.transient = true
}
}
}
held = append(held, c)
}
}
pick := func(v6 bool) string {
var best *cand
for i := range held {
c := &held[i]
if (c.ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = c
continue
}
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
if cp != bp {
if cp {
best = c
}
continue
}
if v6 && best.transient && !c.transient {
best = c
}
}
if best == nil {
return ""
}
return best.ip.String()
}
return pick(false), pick(true)
}
// lendsAddress mirrors the source: a skipped interface lends no address,
// except a WireGuard interface lending a public v6 that is not in
// Cloudflare WARP's shared 2606:4700::/32.
func lendsAddress(name string, ip net.IP) bool {
if !skipIfaceName(name) {
return true
}
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
ip16 := ip.To16()
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
}
return false
}
// transientV6 parses /proc/net/if_inet6 for addresses carrying
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
func transientV6(text string) []net.IP {
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
var out []net.IP
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
flags, err := strconv.ParseUint(f[4], 16, 32)
if err != nil || flags&transientFlags == 0 {
continue
}
if len(f[0]) != 32 {
continue
}
ip := make(net.IP, 16)
ok := true
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
if err != nil {
ok = false
break
}
ip[i] = byte(b)
}
if ok {
out = append(out, ip)
}
}
return out
}
// --- sockets / processes ------------------------------------------------------
func connCounts(v4, v6 string) (tcp, udp uint32) {
stat := func(text, prefix, key string) uint32 {
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
f := strings.Fields(line[len(prefix):])
for i := 0; i+1 < len(f); i++ {
if f[i] == key {
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
return uint32(n)
}
return 0
}
}
}
return 0
}
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
return tcp, udp
}
func procCount() uint32 {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
var n uint32
for _, e := range entries {
name := e.Name()
digits := name != ""
for i := 0; i < len(name); i++ {
if name[i] < '0' || name[i] > '9' {
digits = false
break
}
}
if digits {
n++
}
}
return n
}
// --- disk (df口径) ---------------------------------------------------------------
var skipFSTypes = map[string]bool{
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
"fuse": true, "rpc_pipefs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
"glusterfs": true, "9p": true,
}
func skipFSType(fstype string) bool {
if skipFSTypes[fstype] {
return true
}
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
return true
}
return false
}
// realMountPoints ports parse_mounts: only the topmost mount per path,
// deduped by source device (zfs by pool), real storage only.
func realMountPoints(text string) []string {
type row struct{ dev, mount, fstype string }
var rows []row
top := map[string]int{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
rows = append(rows, row{f[0], f[1], f[2]})
top[f[1]] = len(rows) - 1
}
seen := map[string]bool{}
var out []string
for i, r := range rows {
if top[r.mount] != i || skipFSType(r.fstype) {
continue
}
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
continue
}
key := r.dev
if r.fstype == "zfs" {
if j := strings.IndexByte(key, '/'); j >= 0 {
key = key[:j]
}
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
}
return out
}
// diskUsage sums statfs over the mounts: total = f_blocks, used =
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
func diskUsage(mounts []string) (total, used uint64) {
for _, m := range mounts {
var st syscall.Statfs_t
if err := syscall.Statfs(m, &st); err != nil {
continue
}
bs := uint64(st.Frsize)
if bs == 0 {
bs = uint64(st.Bsize)
}
total += st.Blocks * bs
if st.Blocks > st.Bfree {
used += (st.Blocks - st.Bfree) * bs
}
}
return total, used
}
+209
View File
@@ -0,0 +1,209 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
)
// V2.1.0 probe-only tests: the mode gates both core branches of the
// config poll (no fetch, no start, no switch latch), retires a core
// that was running when the mode turns on, and self-heals back to
// the core when the mode turns off again.
// probeMaster is a fake master whose config response is assembled
// from a switchable probe_only flag plus the usual sing-box payload.
type probeMaster struct {
probeOnly atomic.Bool
trafficHit atomic.Int32
srv *httptest.Server
}
func newProbeMaster(t *testing.T) *probeMaster {
t.Helper()
m := &probeMaster{}
sum := sha256.Sum256([]byte(sbCfgA))
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
"probe_tasks": []any{},
// The real master always sends the flag (only a pre-V2.1.0
// master omits it); the fixture mirrors that.
"probe_only": m.probeOnly.Load(),
}
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
m.trafficHit.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
m.srv = httptest.NewServer(mux)
t.Cleanup(m.srv.Close)
return m
}
func (m *probeMaster) agent(cfg *Config) *Agent {
if cfg == nil {
cfg = &Config{}
}
cfg.MasterURL = m.srv.URL
cfg.Token = "tok"
cfg.NodeName = "n"
return New(cfg)
}
// The master's flag alone (no local preset) must gate the poll: the
// served sing-box payload is ignored entirely.
func TestProbeOnlyMasterFlagGatesApply(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if !a.probeOnlyActive() {
t.Fatal("probe-only must be active from the master flag")
}
if a.sbSwitched {
t.Fatal("switchover latched under probe-only")
}
if a.singboxRunning() {
t.Fatal("sing-box started under probe-only")
}
if a.lastSingboxHash != "" {
t.Fatalf("lastSingboxHash = %q, want untouched (empty)", a.lastSingboxHash)
}
if _, err := os.Stat(singboxConfigPath()); !os.IsNotExist(err) {
t.Fatalf("applied config file exists under probe-only (stat err = %v)", err)
}
}
// The local agent.json preset gates even against a master that
// still serves a payload (and predates the flag): with no core
// binary on disk, not even a core fetch may be attempted.
func TestProbeOnlyLocalPresetSkipsCoreFetch(t *testing.T) {
binDir := t.TempDir() // deliberately no fake sing-box binary
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
coreSrv := fakeCoreServer(t, &hits, http.StatusOK)
defer coreSrv.Close()
useCoreBase(t, coreSrv.URL)
m := newProbeMaster(t) // probe_only absent from its responses
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if hits.Load() != 0 {
t.Fatalf("core fetch attempted %d times under local probe-only preset", hits.Load())
}
if a.sbSwitched || a.singboxRunning() {
t.Fatal("core engaged under local probe-only preset")
}
}
// Turning the flag on while sing-box is running retires the core
// exactly once; turning it off again reactivates the core from the
// unchanged hash (the poll's not-running path), without the
// bookkeeping having been dirtied in between.
func TestProbeOnlyRetiresRunningSingboxAndHealsBack(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("initial fetch: %v", err)
}
if !a.singboxRunning() || !a.sbSwitched {
t.Fatal("precondition: sing-box must be running after the normal poll")
}
hashBefore := a.lastSingboxHash
m.probeOnly.Store(true)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("probe-only fetch: %v", err)
}
if a.singboxRunning() {
t.Fatal("sing-box must be retired when probe-only turns on")
}
if !a.sbSwitched {
t.Fatal("sbSwitched must stay latched (bookkeeping untouched by probe-only)")
}
if a.lastSingboxHash != hashBefore {
t.Fatalf("lastSingboxHash dirtied: %q -> %q", hashBefore, a.lastSingboxHash)
}
// A second probe-only poll is a no-op, not an error.
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("second probe-only fetch: %v", err)
}
m.probeOnly.Store(false)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("heal-back fetch: %v", err)
}
if !a.singboxRunning() {
t.Fatal("sing-box must restart once probe-only turns off (hash unchanged)")
}
}
// Same retirement for a legacy xray core that predates the
// switchover: probe-only stops it and does not start sing-box.
func TestProbeOnlyRetiresLegacyXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired under probe-only")
}
if a.singboxRunning() || a.sbSwitched {
t.Fatal("sing-box must not engage under probe-only")
}
}
// The traffic cycle is an explicit no-op under probe-only: even
// with a core process somehow alive, nothing is collected or posted.
func TestProbeOnlyTrafficCycleIsNoop(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
// Put a core under the agent directly (bypassing the poll gate)
// to prove the traffic cycle itself refuses to touch it.
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
t.Fatalf("applySingboxConfig: %v", err)
}
if !a.singboxRunning() {
t.Fatal("precondition: fake sing-box must be running")
}
if err := a.collectAndReportTraffic(); err != nil {
t.Fatalf("collectAndReportTraffic under probe-only: %v", err)
}
if n := m.trafficHit.Load(); n != 0 {
t.Fatalf("traffic posted %d times under probe-only", n)
}
}
+6
View File
@@ -1,3 +1,9 @@
//go:build linux
// This file tests the Linux sampler's /proc and /sys parsers
// (probe_linux.go); the shared machinery has its own coverage and the
// Windows sampler's pure helpers live in probe_windows_test.go.
package agent
import (
+530
View File
@@ -0,0 +1,530 @@
//go:build windows
package agent
// probe_windows.go is the Windows sampler for the probe collector,
// feeding the same Facts/Metrics contract and accounting discipline
// as the Linux sampler (see probe.go) from the Win32 surface:
//
// - CPU%: GetSystemTimes differenced through the shared cpuPercent
// (kernel time includes idle, exactly like /proc/stat jiffies);
// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys
// (the free(1) "available" column's spirit); swap is the pagefile
// pair (TotalPageFile/AvailPageFile);
// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed
// like df: used = total - free bytes (GetDiskFreeSpaceEx's
// TotalFree, NOT the caller-available figure, which discounts
// quotas the way f_bavail discounts the ext reserve);
// - network: GetIfTable2 rows filtered to OperStatus Up, ifType
// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates
// the Linux three-layer filter: Windows exposes no stacking
// topology in the MIB, but tunnel/virtual adapters report their
// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of
// physical media lands on the same counted set in practice. The
// cost of the difference: a physical-medium bond/virtual switch
// vNIC presenting as Ethernet IS counted here even where Linux
// layer 3 would have skipped it;
// - boot identity: the registry MachineGuid stands in for the
// kernel boot_id; the counted-set FNV fingerprint is shared, so a
// NIC change re-baselines exactly like on Linux;
// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper);
// - load averages, TCP/UDP connection counts have no exported
// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw
// counters exist for CPU/memory, but the package exports no
// GetTcpTable2/GetUdpTable2 wrapper, so per the independent-
// 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"
"os"
"runtime"
"strings"
"time"
"unsafe"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// 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()
v4, v6 := winAddresses()
return &ProbeFacts{
Hostname: winHostname(),
OS: winOSName(),
Kernel: winKernelBuild(),
Arch: runtime.GOARCH,
Virt: winVirt(),
CPUName: winCPUName(),
CPUCores: uint32(runtime.NumCPU()),
MemTotal: memTotal,
SwapTotal: swapTotal,
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// 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()
// 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 {
c := netCounters{name: r.name, rx: r.rx, tx: r.tx}
all = append(all, c)
if winIfaceCounts(r) {
counted = append(counted, c)
}
}
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(all, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
rxRate, txRate := probeColl.netRate(counted, time.Now())
return &ProbeMetrics{
BootID: bootEpoch(winMachineGUID(), names),
Uptime: guardedCall("uptime", func() uint64 {
return uint64(windows.DurationSinceBoot().Seconds())
}),
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
Load: [3]float32{},
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: guardedCall("processes", winProcCount),
}
}
// --- registry helpers --------------------------------------------------------
func regGetString(root registry.Key, path, name string) string {
k, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
v, _, err := k.GetStringValue(name)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
func winMachineGUID() string {
return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid")
}
func winOSName() string {
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
name := regGetString(registry.LOCAL_MACHINE, key, "ProductName")
if name == "" {
return "Windows"
}
// DisplayVersion ("23H2") disambiguates: ProductName still says
// "Windows 10" on most Windows 11 installs.
if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" {
name += " " + dv
}
return name
}
func winKernelBuild() string {
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" {
return b
}
return "unknown"
}
func winCPUName() string {
if n := regGetString(registry.LOCAL_MACHINE,
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" {
return n
}
return "unknown"
}
// winVirt is best-effort: the BIOS strings Windows itself surfaces.
// No match reads "unknown" (Windows guests of a hypervisor that hides
// them are indistinguishable from bare metal this way).
func winVirt() string {
const bios = `HARDWARE\DESCRIPTION\System\BIOS`
hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") +
" " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName"))
for _, m := range []struct{ word, name string }{
{"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"},
{"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"},
{"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"},
} {
if strings.Contains(hay, m.word) {
return m.name
}
}
return "unknown"
}
func winHostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "unknown"
}
// --- cpu / memory --------------------------------------------------------------
// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ.
func filetime100ns(ft windows.Filetime) uint64 {
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
}
// 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)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)))
if r == 0 {
return nil
}
return &cpuSample{
total: filetime100ns(kernel) + filetime100ns(user),
idle: filetime100ns(idle),
}
}
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
// usedFromTotalAvail is the used-column discipline as a pure
// function: used = total - avail, saturating at 0.
func usedFromTotalAvail(total, avail uint64) uint64 {
if avail > total {
return 0
}
return total - avail
}
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)))
if r == 0 {
return 0, 0, 0, 0
}
return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys),
ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile)
}
// --- disk (df口径) -----------------------------------------------------------
// driveSpace is one fixed drive's GetDiskFreeSpaceEx result.
type driveSpace struct{ total, free uint64 }
// sumDriveSpace aggregates fixed drives the df way: used = total -
// free (the caller-available figure is deliberately not consulted).
func sumDriveSpace(ds []driveSpace) (total, used uint64) {
for _, d := range ds {
total += d.total
used += usedFromTotalAvail(d.total, d.free)
}
return total, used
}
// splitDriveStrings decodes the GetLogicalDriveStrings multi-string
// buffer (NUL-separated roots, double-NUL terminated). Pure, so the
// layout is unit-testable off-device.
func splitDriveStrings(buf []uint16) []string {
var out []string
start := 0
for i, c := range buf {
if c != 0 {
continue
}
if i == start {
break // double NUL: end of the list
}
out = append(out, windows.UTF16ToString(buf[start:i]))
start = i + 1
}
return out
}
// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives.
func fixedDriveRoots() []string {
n, err := windows.GetLogicalDriveStrings(0, nil)
if err != nil || n == 0 {
return nil
}
buf := make([]uint16, n)
n, err = windows.GetLogicalDriveStrings(n, &buf[0])
if err != nil {
return nil
}
var roots []string
for _, s := range splitDriveStrings(buf[:n]) {
p, err := windows.UTF16PtrFromString(s)
if err != nil {
continue
}
if windows.GetDriveType(p) == windows.DRIVE_FIXED {
roots = append(roots, s)
}
}
return roots
}
func winDiskUsage() (total, used uint64) {
var spaces []driveSpace
for _, root := range fixedDriveRoots() {
p, err := windows.UTF16PtrFromString(root)
if err != nil {
continue
}
var freeToCaller, totalBytes, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
continue
}
spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree})
}
return sumDriveSpace(spaces)
}
// --- network: counted set ------------------------------------------------------
// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2.
type winIfaceRow struct {
name string
ifType uint32
operUp bool
rx, tx uint64
}
// winIfaceCounts applies the counted-set rule to one row (see the
// file header for how it approximates the Linux three-layer filter).
func winIfaceCounts(r winIfaceRow) bool {
if !r.operUp {
return false
}
if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK {
return false
}
return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD ||
r.ifType == windows.IF_TYPE_IEEE80211
}
// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics
// 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
r, _, _ := procGetIfTable2Ex.Call(
uintptr(windows.MibIfTableNormal),
uintptr(unsafe.Pointer(&table)))
if r != 0 || table == nil {
return nil
}
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
n := int(table.NumEntries)
if n == 0 {
return nil
}
rows := unsafe.Slice(&table.Table[0], n)
out := make([]winIfaceRow, 0, n)
for i := range rows {
r := &rows[i]
out = append(out, winIfaceRow{
name: windows.UTF16ToString(r.Alias[:]),
ifType: r.Type,
operUp: r.OperStatus == windows.IfOperStatusUp,
rx: r.InOctets,
tx: r.OutOctets,
})
}
return out
}
// --- processes -----------------------------------------------------------------
func winProcCount() uint32 {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0
}
defer windows.CloseHandle(snap)
var pe windows.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
if err := windows.Process32First(snap, &pe); err != nil {
return 0
}
var n uint32 = 1
for {
if err := windows.Process32Next(snap, &pe); err != nil {
break
}
n++
}
return n
}
// --- addresses -------------------------------------------------------------------
// winAddresses picks one address per family from up, non-loopback
// interfaces: public preferred over private, like the Linux picker —
// only without its /proc inputs (no operstate file, no temporary-
// address flags; net.Interfaces' own flags do the loopback cut).
func winAddresses() (string, string) {
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
var cands []net.IP
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsLoopback() {
continue
}
cands = append(cands, ip)
}
}
pick := func(v6 bool) string {
var best net.IP
for _, ip := range cands {
if (ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = ip
continue
}
if isPublicIP(ip) && !isPublicIP(best) {
best = ip
}
}
if best == nil {
return ""
}
return best.String()
}
return pick(false), pick(true)
}
+99
View File
@@ -0,0 +1,99 @@
//go:build windows
package agent
import (
"testing"
"golang.org/x/sys/windows"
)
// The Windows sampler's pure helpers: the Win32 calls themselves need
// a real machine, but the accounting math layered on their results is
// pinned here.
func TestFiletime100nsAndCPUSampleDiff(t *testing.T) {
lo := windows.Filetime{LowDateTime: 0x00000005, HighDateTime: 0x00000002}
if got := filetime100ns(lo); got != 0x200000005 {
t.Fatalf("filetime100ns = %#x, want 0x200000005", got)
}
// The shared differ over Windows-style counters (kernel includes
// idle): idle advanced by the full window -> 0% busy; then a
// window where only a quarter of the time is idle -> 75%.
c := &probeCollector{}
first := &cpuSample{total: 1000, idle: 400}
if got := c.cpuPercent(first); got != 0 {
t.Fatalf("first sample = %v, want 0 (baseline only)", got)
}
idleAll := &cpuSample{total: 2000, idle: 1400}
if got := c.cpuPercent(idleAll); got != 0 {
t.Fatalf("all-idle window = %v, want 0", got)
}
busy := &cpuSample{total: 3000, idle: 1650}
if got := c.cpuPercent(busy); got != 75 {
t.Fatalf("busy window = %v, want 75", got)
}
}
func TestUsedFromTotalAvail(t *testing.T) {
if got := usedFromTotalAvail(100, 30); got != 70 {
t.Fatalf("used = %d, want 70", got)
}
if got := usedFromTotalAvail(100, 100); got != 0 {
t.Fatalf("used = %d, want 0", got)
}
if got := usedFromTotalAvail(100, 130); got != 0 {
t.Fatalf("used saturates, got %d", got)
}
}
func TestSumDriveSpaceDfSemantics(t *testing.T) {
total, used := sumDriveSpace([]driveSpace{
{total: 1000, free: 250},
{total: 2000, free: 2000},
{total: 500, free: 900}, // inconsistent source clamps to full
})
if total != 3500 || used != 1250 {
t.Fatalf("sumDriveSpace = %d/%d, want 3500/1250", total, used)
}
}
func TestSplitDriveStrings(t *testing.T) {
mk := func(ss ...string) []uint16 {
var out []uint16
for _, s := range ss {
for _, r := range s {
out = append(out, uint16(r))
}
out = append(out, 0)
}
return append(out, 0)
}
got := splitDriveStrings(mk(`C:\`, `D:\`))
if len(got) != 2 || got[0] != `C:\` || got[1] != `D:\` {
t.Fatalf("splitDriveStrings = %q, want [C:\\ D:\\]", got)
}
if got := splitDriveStrings(mk()); len(got) != 0 {
t.Fatalf("empty buffer = %q, want none", got)
}
}
func TestWinIfaceCountsRule(t *testing.T) {
cases := []struct {
name string
row winIfaceRow
want bool
}{
{"up ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: true}, true},
{"up wifi", winIfaceRow{ifType: windows.IF_TYPE_IEEE80211, operUp: true}, true},
{"down ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: false}, false},
{"loopback", winIfaceRow{ifType: windows.IF_TYPE_SOFTWARE_LOOPBACK, operUp: true}, false},
{"tunnel", winIfaceRow{ifType: 131, operUp: true}, false},
{"ppp", winIfaceRow{ifType: 23, operUp: true}, false},
}
for _, tc := range cases {
if got := winIfaceCounts(tc.row); got != tc.want {
t.Errorf("%s: winIfaceCounts = %v, want %v", tc.name, got, tc.want)
}
}
}
+37 -1
View File
@@ -52,6 +52,15 @@ var execSelf = func(path string) error {
return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ())
}
// selfUpdateSupported reports whether this platform can consume a
// master update intent. Windows agents are the standalone
// xui-agent-windows binary (see cmd/xui-agent-windows), serviced by
// re-running install-agent.ps1: the release assets an intent points
// at are the panel's Linux binaries, so an intent delivered to a
// Windows node is acknowledged and skipped, never downloaded.
// A package variable so tests can exercise the skip on any OS.
var selfUpdateSupported = runtime.GOOS != "windows"
func (a *Agent) setUpdateReport(st agentUpdateStatus) {
a.updateMu.Lock()
cp := st
@@ -82,6 +91,19 @@ func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
a.updateMu.Unlock()
return
}
if !selfUpdateSupported {
// Windows (see selfUpdateSupported): log once per intent and
// mark it handled so the poll does not re-log it every cycle.
// No report is filed — the master-side intent simply ages out,
// and the node updates by re-running install-agent.ps1.
a.updateMu.Lock()
if intent.RequestedAt != a.updateHandled {
a.updateHandled = intent.RequestedAt
logger.Infof("agent update to %s offered but skipped on %s: Windows agents update by re-running install-agent.ps1", intent.Target, runtime.GOOS)
}
a.updateMu.Unlock()
return
}
a.updateMu.Lock()
if a.updateRunning || intent.RequestedAt == a.updateHandled ||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) {
@@ -129,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
@@ -167,7 +194,16 @@ func (a *Agent) runUpdate(intent updateIntent) {
// Best effort: flush the "swapped" report before the image is replaced
// (success is ultimately proven by the new version's telemetry).
_ = a.sendHeartbeat()
// Stop BOTH managed cores before replacing the image (ITEM-P3
// rider). Stopping only xray orphaned a running sing-box: the
// orphan kept the inbound ports bound and serving the old config
// while the new image's own core died on bind every poll cycle,
// and no later config change could reach it. On exec failure the
// rollback below restores the old binary in this same process;
// the cores stay down only until the next config poll, whose
// hash-current-but-core-down path re-applies and restarts them.
a.stopXray()
a.stopSingbox()
if err := execSelf(exePath); err != nil {
// The exec failed before replacing the image: roll the old binary
// back into place and keep running it.
+114 -6
View File
@@ -3,6 +3,7 @@ package agent
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -12,9 +13,25 @@ import (
"sync/atomic"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
)
// nextAgentTarget returns an intent target one patch above the test
// binary's own agent-line version. The update tests must ride the
// agent line wherever it currently stands (A2.3.0 since the version
// decoupling) instead of hardcoding a version the line has already
// passed — a hardcoded "newer" target silently degrades into an
// already-current skip when the default agent version moves.
func nextAgentTarget(t *testing.T) string {
t.Helper()
parts, ok := config.ParseDisplayVersion(config.GetAgentDisplayVersion())
if !ok {
t.Fatalf("agent display version %q does not parse", config.GetAgentDisplayVersion())
}
return fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]+1)
}
// Item 20: the agent consumes the master's update intent, downloads the
// release asset for its architecture, verifies the sha256 sidecar, swaps
// the binary keeping a .bak, and re-execs. Failures must leave the old
@@ -22,7 +39,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)
}
@@ -78,7 +97,7 @@ func TestAgentUpdateSwapsAndRestarts(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL)
execCalls := stubExec(t, nil)
a.runUpdate(updateIntent{RequestedAt: 42, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
a.runUpdate(updateIntent{RequestedAt: 42, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
got, _ := os.ReadFile(exe)
if string(got) != "new-binary" {
@@ -102,7 +121,7 @@ func TestAgentUpdateBadChecksumKeepsOldBinary(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL)
execCalls := stubExec(t, nil)
a.runUpdate(updateIntent{RequestedAt: 43, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
a.runUpdate(updateIntent{RequestedAt: 43, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
got, _ := os.ReadFile(exe)
if string(got) != "old-binary" {
@@ -127,8 +146,9 @@ func TestAgentUpdateAlreadyCurrentSkipsDownload(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL)
stubExec(t, nil)
// The test binary's display version defaults to A2.0.0.
a.runUpdate(updateIntent{RequestedAt: 44, Target: "A2.0.0", Tag: "V2.0.0", BaseURL: srv.URL + "/"})
// The intent target equals the test binary's own agent-line
// version: already current, whatever the line currently is.
a.runUpdate(updateIntent{RequestedAt: 44, Target: config.GetAgentDisplayVersion(), Tag: "V2.0.0", BaseURL: srv.URL + "/"})
if hits.Load() != 0 {
t.Fatalf("release server hit %d times for an already-current agent", hits.Load())
@@ -148,7 +168,7 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL)
stubExec(t, errExecBoom)
a.runUpdate(updateIntent{RequestedAt: 45, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
a.runUpdate(updateIntent{RequestedAt: 45, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
got, _ := os.ReadFile(exe)
if string(got) != "old-binary" {
@@ -159,6 +179,94 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
}
}
func TestAgentUpdateSkippedOnUnsupportedOS(t *testing.T) {
orig := selfUpdateSupported
selfUpdateSupported = false
t.Cleanup(func() { selfUpdateSupported = orig })
var hits atomic.Int32
srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits)
defer srv.Close()
a, exe := newUpdateTestAgent(t, srv.URL)
intent := &updateIntent{RequestedAt: 46, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"}
a.maybeHandleUpdate(intent)
// A repeated delivery of the same intent is also a no-op.
a.maybeHandleUpdate(intent)
if hits.Load() != 0 {
t.Fatalf("release server hit %d times on an unsupported OS, want 0", hits.Load())
}
got, _ := os.ReadFile(exe)
if string(got) != "old-binary" {
t.Fatalf("exe = %q, want untouched old-binary", got)
}
a.updateMu.Lock()
handled, running := a.updateHandled, a.updateRunning
a.updateMu.Unlock()
if handled != 46 || running {
t.Fatalf("handled=%d running=%v, want handled=46 running=false", handled, running)
}
if rep := a.currentUpdateReport(); rep != nil {
t.Fatalf("report = %+v, want none for a skipped intent", rep)
}
}
// ITEM-P3 rider: runUpdate must stop the sing-box core as well as
// xray before execSelf replaces the image. Pre-fix only xray was
// stopped, leaving the old sing-box orphaned on the inbound ports —
// the new image's core then died on bind every poll cycle and the
// orphan kept serving a stale config. The fake core is a real
// process so "stopped" is observable, and the exec stub records
// whether the core was already down at exec time (ordering matters:
// stopping after exec would be stopping in the wrong process).
func TestAgentUpdateStopsSingboxCoreBeforeExec(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake core is a shell script")
}
srv := fakeReleaseServer(t, []byte("new-binary"), true, nil)
defer srv.Close()
a, _ := newUpdateTestAgent(t, srv.URL)
dir := t.TempDir()
corePath := filepath.Join(dir, "sing-box")
if err := os.WriteFile(corePath, []byte("#!/bin/sh\nexec sleep 3600\n"), 0o755); err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(dir, "sb.json")
if err := os.WriteFile(cfgPath, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
a.sbProc = &singboxProcess{}
if err := a.sbProc.start(corePath, cfgPath, filepath.Join(dir, "sb.log")); err != nil {
t.Fatalf("start fake core: %v", err)
}
if !a.singboxRunning() {
t.Fatal("fake sing-box core is not running")
}
t.Cleanup(func() { a.stopSingbox() })
var sbRunningAtExec atomic.Bool
orig := execSelf
execSelf = func(string) error {
sbRunningAtExec.Store(a.singboxRunning())
return nil
}
t.Cleanup(func() { execSelf = orig })
a.runUpdate(updateIntent{RequestedAt: 47, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
if sbRunningAtExec.Load() {
t.Fatal("sing-box core was still running when execSelf fired")
}
if a.singboxRunning() {
t.Fatal("sing-box core is still running after the update")
}
if rep := a.currentUpdateReport(); rep == nil || rep.State != "swapped" {
t.Fatalf("report = %+v, want swapped", rep)
}
}
var errExecBoom = &execBoomError{}
type execBoomError struct{}
+53 -4
View File
@@ -42,6 +42,15 @@ var (
// telemetry) read the V/A display version, while internal comparisons
// and advertisements keep using GetBaseVersion/GetPanelVersion.
displayVersion string
// agentDisplayVersion is the AGENT line's own release number,
// injected the same way. Since the version-line decoupling it is
// deliberately NOT derived from displayVersion: the panel version
// (displayVersion) advances with every panel release, while the
// agent version advances only when agent code actually changes.
// Release builds inject both; a build that injects neither reports
// the panel default and the agent-line default below, which are
// intentionally different numbers.
agentDisplayVersion string
)
// LogLevel represents the logging level for the application.
@@ -125,16 +134,56 @@ func GetDisplayVersion() string {
return defaultDisplayVersion
}
// bareDisplayVersion is GetDisplayVersion without a single leading
// V/v. Release builds inject the number either bare ("2.1.0") or in
// the master's V-form ("V2.1.0"); the letter-prefixed getters below
// must compose from the bare form so the letter never doubles
// ("VV2.1.0" / "AV2.1.0" — the latter is what agent rows showed when
// the V-form was injected).
func bareDisplayVersion() string {
v := GetDisplayVersion()
if len(v) > 1 && (v[0] == 'V' || v[0] == 'v') {
return v[1:]
}
return v
}
// GetMasterDisplayVersion is the master's display version ("V1.0.0"),
// shown in the panel's version slot.
func GetMasterDisplayVersion() string {
return "V" + GetDisplayVersion()
return "V" + bareDisplayVersion()
}
// GetAgentDisplayVersion is the agent's display version ("A1.0.0"),
// reported in agent telemetry and shown on agent node rows.
// defaultAgentDisplayVersion is the agent line's version when a build
// does not inject one. It names the last release in which agent code
// actually changed (A2.4.0 — the P3 memory-tuning cycle touched agent
// code, shipped with panel V2.5.0; before that the line stood at
// A2.3.0 while the panel line advanced alone). Bump it ONLY together
// with a real agent-code change, in the same commit; panel releases
// must never move it.
const defaultAgentDisplayVersion = "2.4.0"
// GetAgentDisplayVersion is the agent's display version ("A2.4.0"),
// reported in agent telemetry and shown on agent node rows. It reads
// ONLY the agent line's own injected version (falling back to the
// agent-line default): changing the panel's displayVersion injection
// must never move it — that independence is the decoupling contract.
func GetAgentDisplayVersion() string {
return "A" + GetDisplayVersion()
return "A" + bareAgentDisplayVersion()
}
// bareAgentDisplayVersion is the agent line's bare numeric version,
// normalized like bareDisplayVersion (a single leading display letter
// is stripped, so builds may inject "2.3.0" or "A2.3.0").
func bareAgentDisplayVersion() string {
v := strings.TrimSpace(agentDisplayVersion)
if v == "" {
v = defaultAgentDisplayVersion
}
if len(v) > 1 && (v[0] == 'A' || v[0] == 'a' || v[0] == 'V' || v[0] == 'v') {
return v[1:]
}
return v
}
// ParseDisplayVersion parses a display version into its three numeric
@@ -0,0 +1,45 @@
package config
import "testing"
// Version-line decoupling: the agent's display version comes ONLY from
// the agent line's own injection (agentDisplayVersion), never from the
// panel's displayVersion. These tests lock that independence: moving
// either injection moves exactly one line's self-report.
func TestAgentDisplayVersionIndependentSource(t *testing.T) {
origPanel, origAgent := displayVersion, agentDisplayVersion
t.Cleanup(func() { displayVersion, agentDisplayVersion = origPanel, origAgent })
// Agent injection in bare and A-forms both normalize to the A-form.
for _, injected := range []string{"2.4.0", "A2.4.0", "a2.4.0"} {
agentDisplayVersion = injected
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
t.Fatalf("agentDisplayVersion=%q: GetAgentDisplayVersion = %q, want A2.4.0", injected, got)
}
}
// Sweeping the panel injection across values must leave the agent
// self-report bit-identical (the phantom-update regression shape:
// a panel-only release used to drag every agent's version along).
agentDisplayVersion = "2.3.0"
for _, panel := range []string{"", "2.0.0", "2.4.0", "V2.4.0", "9.9.9"} {
displayVersion = panel
if got := GetAgentDisplayVersion(); got != "A2.3.0" {
t.Fatalf("displayVersion=%q moved the agent version: got %q, want A2.3.0", panel, got)
}
if got := GetMasterDisplayVersion(); panel != "" && got == "V2.3.0" {
t.Fatalf("displayVersion=%q: master version collided with the agent line: %q", panel, got)
}
}
// And the reverse: moving the agent injection must not move the
// master's self-report.
displayVersion = "2.4.0"
agentDisplayVersion = "2.5.0"
if got := GetMasterDisplayVersion(); got != "V2.4.0" {
t.Fatalf("agent injection moved the master version: got %q, want V2.4.0", got)
}
if got := GetAgentDisplayVersion(); got != "A2.5.0" {
t.Fatalf("agent self-report = %q, want A2.5.0", got)
}
}
+21 -5
View File
@@ -55,25 +55,41 @@ func TestGetBaseVersionBuildVersionOverride(t *testing.T) {
func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
orig := displayVersion
t.Cleanup(func() { displayVersion = orig })
origAgent := agentDisplayVersion
t.Cleanup(func() { displayVersion = orig; agentDisplayVersion = origAgent })
displayVersion = ""
agentDisplayVersion = ""
if got := GetDisplayVersion(); got != "2.0.0" {
t.Fatalf("default display version = %q, want 2.0.0", got)
}
if got := GetMasterDisplayVersion(); got != "V2.0.0" {
t.Fatalf("default master display version = %q, want V2.0.0", got)
}
if got := GetAgentDisplayVersion(); got != "A2.0.0" {
t.Fatalf("default agent display version = %q, want A2.0.0", got)
// The two lines have independent defaults since the decoupling:
// the panel default (2.0.0) and the agent line's own version
// (2.4.0, its last real change) deliberately differ.
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
t.Fatalf("default agent display version = %q, want A2.4.0", got)
}
displayVersion = "1.0.1"
if got := GetMasterDisplayVersion(); got != "V1.0.1" {
t.Fatalf("injected master display version = %q, want V1.0.1", got)
}
if got := GetAgentDisplayVersion(); got != "A1.0.1" {
t.Fatalf("injected agent display version = %q, want A1.0.1", got)
// Panel injection must NOT move the agent line (decoupling lock).
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
t.Fatalf("agent display version after panel injection = %q, want A2.4.0 (unchanged)", got)
}
// A release build may inject the number in the master's V-form:
// the master getter must not double its letter ("VV1.0.2").
displayVersion = "V1.0.2"
if got := GetMasterDisplayVersion(); got != "V1.0.2" {
t.Fatalf("V-injected master display version = %q, want V1.0.2", got)
}
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
t.Fatalf("agent display version after V-form panel injection = %q, want A2.4.0 (unchanged)", got)
}
}
-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`
}
+7
View File
@@ -108,6 +108,13 @@ type AgentNode struct {
SeenIPv6 string `json:"seenIpv6"`
SeenIPAt int64 `json:"seenIpAt"`
// ProbeOnly marks the node as probe-only (V2.1.0): the master serves
// no core config for it and the agent runs no core at all — only the
// probe channels (heartbeat, fast report, latency tasks) stay live.
// Set from the panel's node form; the agent can also preset it in
// its local agent.json, either source turns the mode on.
ProbeOnly bool `json:"probeOnly"`
// One-click update (item 20): the master records an update intent here;
// the agent picks it up from its config poll, downloads + verifies +
// swaps its binary, restarts itself and reports the outcome back on
+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 {
+83
View File
@@ -0,0 +1,83 @@
package selfupdate
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// The agent-version.txt marker is the master's source of truth for the
// agent line since the version decoupling: a panel release whose agent
// assets are unchanged reuses carries a marker BELOW its own tag
// number, and that marker — never the tag — decides agent update
// availability.
func TestParseAgentVersionMarker(t *testing.T) {
cases := []struct {
body string
want string
ok bool
}{
{"A2.3.0\n", "A2.3.0", true},
{"2.4.0", "A2.4.0", true},
{" A2.10.3 \n", "A2.10.3", true},
{"\n\nA2.3.0\nextra lines ignored\n", "A2.3.0", true},
{"", "", false},
{"\n\n", "", false},
{"dev-latest", "", false},
{"A2.3", "", false},
{"A2.3.0.1", "", false},
}
for _, c := range cases {
got, ok := ParseAgentVersionMarker([]byte(c.body))
if ok != c.ok || got != c.want {
t.Fatalf("ParseAgentVersionMarker(%q) = %q, %v; want %q, %v", c.body, got, ok, c.want, c.ok)
}
}
}
// markerServer serves /<tag>/agent-version.txt from the given map;
// unknown tags 404 like a pre-decoupling release.
func markerServer(t *testing.T, markers map[string]string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for tag, body := range markers {
if r.URL.Path == "/download/"+tag+"/"+AgentVersionMarkerAsset {
_, _ = w.Write([]byte(body))
return
}
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
t.Setenv(BaseURLEnv, srv.URL+"/download/")
return srv
}
func TestResolveLatestAgentTargetPrefersMarker(t *testing.T) {
markerServer(t, map[string]string{"V2.5.0": "A2.3.0\n"})
got, err := ResolveLatestAgentTarget(context.Background(), nil, "V2.5.0")
if err != nil {
t.Fatalf("ResolveLatestAgentTarget: %v", err)
}
// The whole point of the decoupling: panel tag V2.5.0, agent line
// still A2.3.0 — the answer must be the marker, not A2.5.0.
if got != "A2.3.0" {
t.Fatalf("target = %q, want A2.3.0 from the marker", got)
}
}
func TestResolveLatestAgentTargetFallsBackToTag(t *testing.T) {
markerServer(t, map[string]string{}) // no markers anywhere: pre-decoupling world
got, err := ResolveLatestAgentTarget(context.Background(), nil, "V2.4.0")
if err != nil {
t.Fatalf("ResolveLatestAgentTarget: %v", err)
}
if got != "A2.4.0" {
t.Fatalf("target = %q, want A2.4.0 derived from the tag", got)
}
if _, err := ResolveLatestAgentTarget(context.Background(), nil, "dev-latest"); err == nil {
t.Fatal("unparseable tag without a marker must be an error")
}
}
+108 -3
View File
@@ -17,17 +17,34 @@ import (
"os"
"path/filepath"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
// 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
// fork's repository (the same source the panel update check reads).
const LatestReleaseAPI = "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/latest"
// AgentVersionMarkerAsset is the release asset that carries the AGENT
// line's current version ("A2.3.0") for a release. Since the agent /
// panel version-line decoupling, a panel release's tag no longer
// implies the agent version: the agent assets inside the release may
// be byte-identical reuses of an older agent build. The marker is a
// tiny text asset (single line, A-form) written by the release flow
// (see docs/release.md); masters read it to learn the agent line's
// latest version independently of the panel tag. Releases published
// before the decoupling (<= V2.4.0) carry no marker — readers fall
// back to deriving the A-form from the tag, which is exact for those
// releases because their agent builds were stamped from the tag.
const AgentVersionMarkerAsset = "agent-version.txt"
// BaseURLEnv overrides the release base (mirrors, tests). Read at call
// time so a process never caches a stale override.
const BaseURLEnv = "XUI_RELEASE_BASE_URL"
@@ -41,7 +58,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 +72,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"
@@ -95,6 +132,74 @@ func FetchLatestReleaseTag(ctx context.Context, client *http.Client) (string, er
return strings.TrimSpace(body.TagName), nil
}
// ParseAgentVersionMarker parses the agent-version.txt marker body:
// the first non-empty line must be a display version (bare or
// letter-prefixed); the result is normalized to the A-form.
func ParseAgentVersionMarker(data []byte) (string, bool) {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts, ok := config.ParseDisplayVersion(line)
if !ok {
return "", false
}
return fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]), true
}
return "", false
}
// FetchAgentVersionMarker downloads and parses the agent version
// marker of one release tag. Any failure (missing asset on a
// pre-decoupling release, network, malformed body) is an error so
// callers can fall back deliberately.
func FetchAgentVersionMarker(ctx context.Context, client *http.Client, tag string) (string, error) {
if client == nil {
client = http.DefaultClient
}
url := BaseURL() + strings.TrimSpace(tag) + "/" + AgentVersionMarkerAsset
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("agent version marker: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return "", err
}
v, ok := ParseAgentVersionMarker(data)
if !ok {
return "", fmt.Errorf("agent version marker: malformed body %q", strings.TrimSpace(string(data)))
}
return v, nil
}
// ResolveLatestAgentTarget returns the agent line's latest version
// (A-form) as published by the release at tag: the release's
// agent-version.txt marker when present, else the legacy derivation
// from the (panel) tag itself. The derivation is kept for
// pre-decoupling releases, whose agent builds were stamped from the
// tag, so it stays exact there; it must never be used to override a
// marker that IS present.
func ResolveLatestAgentTarget(ctx context.Context, client *http.Client, tag string) (string, error) {
if v, err := FetchAgentVersionMarker(ctx, client, tag); err == nil {
return v, nil
}
parts, ok := config.ParseDisplayVersion(tag)
if !ok {
return "", fmt.Errorf("cannot resolve agent target: no marker in release %q and tag is not a display version", tag)
}
return fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]), nil
}
// ParseSHA256Sidecar extracts the digest from a sha256sum-style sidecar
// file ("<hex> <name>" or a bare "<hex>", possibly with trailing lines).
func ParseSHA256Sidecar(data []byte) (string, bool) {
+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")
+35 -3
View File
@@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params)
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
default:
params["security"] = "none"
}
@@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params)
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow {
params["flow"] = client.Flow
}
@@ -1700,7 +1700,30 @@ func hysteriaPinHex(pin string) string {
return pin
}
func applyShareRealityParams(stream map[string]any, params map[string]string) {
// singboxFragmentServerName returns the tls.server_name of the
// inbound's stored sing-box fragment — the ONE name the sing-box
// REALITY server actually serves. The converter puts exactly one
// name there (realitySettings.serverNames[0], see internal/singbox)
// while the legacy settings may list dozens. "" when the inbound has
// no fragment, the fragment is unparseable, or it carries no server
// name; callers then keep the historical behaviour.
func singboxFragmentServerName(singboxConfig string) string {
if strings.TrimSpace(singboxConfig) == "" {
return ""
}
var fragment map[string]any
if err := json.Unmarshal([]byte(singboxConfig), &fragment); err != nil {
return ""
}
if tlsBlock, ok := fragment["tls"].(map[string]any); ok {
if name, ok := tlsBlock["server_name"].(string); ok {
return strings.TrimSpace(name)
}
}
return ""
}
func applyShareRealityParams(stream map[string]any, fragmentSNI string, params map[string]string) {
params["security"] = "reality"
realitySetting, _ := stream["realitySettings"].(map[string]any)
realitySettings, _ := searchKey(realitySetting, "settings")
@@ -1710,6 +1733,15 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
params["sni"], _ = sNames[random.Num(len(sNames))].(string)
}
}
// The fragment's server_name wins over the pick above: xray
// serves the whole serverNames list, so a random entry was
// safe there, but the sing-box core serves only the
// fragment's single name — any other entry is refused as an
// invalid REALITY connection (the PPF-L3 outage: 29 listed
// names, core serving only the first).
if fragmentSNI != "" {
params["sni"] = fragmentSNI
}
if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
params["pbk"], _ = pbkValue.(string)
}
+54
View File
@@ -89,6 +89,60 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
}
}
// Scope D: with several serverNames, the generated link's sni must be
// the sing-box fragment's tls.server_name — the only name the sing-box
// REALITY server serves (the converter stores serverNames[0] there).
// The historical behaviour picked a uniformly random list entry per
// generation, which xray tolerated (it serves the whole list) but
// sing-box refuses as an invalid connection. Without a fragment the
// sni still comes from the list.
func TestGenVlessLink_RealitySniPrefersSingboxFragment(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["amd.example.com","connect-sit.amd.example.com","www.amd.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`
s := &SubService{}
inbound := shareLinkInbound(stream)
inbound.SingboxConfig = `{"type":"vless","tag":"in","listen":"::","listen_port":443,` +
`"tls":{"enabled":true,"server_name":"amd.example.com",` +
`"reality":{"enabled":true,"short_id":["ab12cd"]}}}`
// The losing branch is a random pick: sample enough generations
// that a regression to it cannot survive by luck (2/3 wrong per
// draw; 30 draws make a false pass ~2e-6).
for i := 0; i < 30; i++ {
link := s.genVlessLink(inbound, "user")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
if got := u.Query().Get("sni"); got != "amd.example.com" {
t.Fatalf("draw %d: sni = %q, want the fragment's amd.example.com\nlink: %s", i, got, link)
}
}
// No fragment (or an unusable one): historical behaviour — the
// sni is some entry of the serverNames list.
for _, frag := range []string{"", "{not json", `{"type":"vless"}`} {
inbound.SingboxConfig = frag
link := s.genVlessLink(inbound, "user")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
switch got := u.Query().Get("sni"); got {
case "amd.example.com", "connect-sit.amd.example.com", "www.amd.example.com":
default:
t.Fatalf("fragment %q: sni = %q, want a serverNames entry", frag, got)
}
}
}
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
// with distinct subIds.
func realityTwoClientInbound() *model.Inbound {
@@ -1,4 +1,4 @@
package sys
package memtuning
import (
"fmt"
@@ -6,12 +6,24 @@ import (
"runtime/debug"
"strconv"
"strings"
"time"
)
const (
memLimitHeadroomPercent = 90
defaultGCPercent = 75
defaultReleaseMinutes = 10
// Agent tuning profile (ITEM-P3 A1). The baseline measurement put
// the agent's live heap at ~7-8 MiB of anonymous memory, yet
// production agents idled at 38-40 MB RSS: with Go defaults
// (GOGC=100, no soft limit, no periodic release) the runtime
// holds high-water arenas indefinitely. The agent profile pairs
// the panel's GOGC env value (50) with an explicit 24 MiB soft
// budget — roughly 3x the live heap, enough headroom for stats
// bursts, far below the untuned high-water mark.
agentGCPercent = 50
agentSoftLimitMB = 24
)
// ApplyMemoryTuning configures the Go runtime for a lower, steadier footprint and
@@ -32,11 +44,15 @@ func ApplyMemoryTuning() []string {
// applyGCPercent lowers GOGC so the heap high-water mark, and thus RSS, stays
// smaller. An explicit GOGC env (including GOGC=off) is left to the runtime.
func applyGCPercent() string {
return applyGCPercentWithDefault(defaultGCPercent)
}
func applyGCPercentWithDefault(def int) string {
if _, ok := os.LookupEnv("GOGC"); ok {
return "GC percent: GOGC env (handled by the Go runtime)"
}
pct := defaultGCPercent
pct := def
if v := strings.TrimSpace(os.Getenv("XUI_GOGC")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
pct = n
@@ -50,6 +66,65 @@ func applyGCPercent() string {
return fmt.Sprintf("GC percent set to %d", pct)
}
// ApplyAgentMemoryTuning is ApplyMemoryTuning with the node agent's
// budget profile (ITEM-P3 A1), applied by both agent entries (the
// standalone cmd/xui-agent and the dual-mode binary's agent branch).
// It differs from the panel profile in two ways: the default GC
// percent is 50 instead of 75, and the soft limit falls back to an
// explicit agent budget (agentSoftLimitMB) instead of "no budget".
// The cgroup fallback is deliberately not used for the agent: fleet
// agents run on whole VMs or containers whose cgroup limit describes
// the box, not an agent budget, and would defeat the tighter default.
// Explicit env budgets (GOMEMLIMIT, XUI_MEMORY_LIMIT) still win.
func ApplyAgentMemoryTuning() []string {
lines := []string{applyGCPercentWithDefault(agentGCPercent)}
if limit, source := applyAgentMemoryLimit(); limit > 0 {
lines = append(lines, fmt.Sprintf("Go memory soft limit set to %d MiB (%s)", limit>>20, source))
} else {
lines = append(lines, "Go memory soft limit not enforced: "+source)
}
return lines
}
func applyAgentMemoryLimit() (int64, string) {
if strings.TrimSpace(os.Getenv("GOMEMLIMIT")) != "" {
return 0, "GOMEMLIMIT env (handled by the Go runtime)"
}
if v := strings.TrimSpace(os.Getenv("XUI_MEMORY_LIMIT")); v != "" {
if mb, err := strconv.ParseInt(v, 10, 64); err == nil && mb > 0 {
limit := mb << 20
debug.SetMemoryLimit(limit)
return limit, "XUI_MEMORY_LIMIT=" + v + "MiB"
}
}
limit := int64(agentSoftLimitMB) << 20
debug.SetMemoryLimit(limit)
return limit, "agent default budget"
}
// StartPeriodicMemoryRelease launches the goroutine that returns
// freed heap pages to the OS on the shared release cadence
// (MemoryReleaseIntervalMinutes; 0 disables, and no goroutine is
// started). The panel runs the same cadence through its job
// scheduler; agent processes have no scheduler, so their entries
// start this loop directly. It never stops — the loop's lifetime is
// the process's.
func StartPeriodicMemoryRelease() {
mins := MemoryReleaseIntervalMinutes()
if mins <= 0 {
return
}
go func() {
ticker := time.NewTicker(time.Duration(mins) * time.Minute)
defer ticker.Stop()
for range ticker.C {
debug.FreeOSMemory()
}
}()
}
// applyMemoryLimit sets the soft limit only from an explicit budget: GOMEMLIMIT
// env (left to the runtime), XUI_MEMORY_LIMIT in MiB, or a real cgroup limit at
// 90% to leave headroom for non-heap and the xray child. No budget -> Go default.
@@ -0,0 +1,105 @@
package memtuning
import (
"os"
"runtime/debug"
"strings"
"testing"
)
// lookupGOGC reports whether a GOGC env is present at all: presence
// (even empty handling aside) makes the tuning defer to the runtime,
// which the default-profile tests cannot exercise.
func lookupGOGC() (string, bool) {
return os.LookupEnv("GOGC")
}
// The agent tuning profile (ITEM-P3 A1): GOGC default 50 and a
// 24 MiB default soft budget, with explicit env budgets still
// winning. The runtime knobs are process-global, so each test
// restores whatever GC percent / memory limit it found.
func preserveRuntimeKnobs(t *testing.T) {
t.Helper()
prevGC := debug.SetGCPercent(-1)
prevLimit := debug.SetMemoryLimit(-1)
t.Cleanup(func() {
debug.SetGCPercent(prevGC)
debug.SetMemoryLimit(prevLimit)
})
}
func unsetenv(t *testing.T, keys ...string) {
t.Helper()
for _, k := range keys {
t.Setenv(k, "")
// t.Setenv cannot unset; an empty value is what the tuning
// code treats as absent (it trims before checking), except
// for GOGC where LookupEnv counts presence — see the GOGC
// case below, which is exercised only via XUI_GOGC instead.
}
}
func TestApplyAgentMemoryTuningDefaults(t *testing.T) {
preserveRuntimeKnobs(t)
unsetenv(t, "GOMEMLIMIT", "XUI_MEMORY_LIMIT", "XUI_GOGC")
if _, present := lookupGOGC(); present {
t.Skip("GOGC is set in the test environment")
}
lines := ApplyAgentMemoryTuning()
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "GC percent set to 50") {
t.Fatalf("lines = %v, want GC percent 50", lines)
}
if !strings.Contains(joined, "Go memory soft limit set to 24 MiB (agent default budget)") {
t.Fatalf("lines = %v, want 24 MiB agent default budget", lines)
}
if got := debug.SetMemoryLimit(-1); got != int64(24)<<20 {
t.Fatalf("memory limit = %d, want %d", got, int64(24)<<20)
}
if got := debug.SetGCPercent(-1); got != 50 {
t.Fatalf("GC percent = %d, want 50", got)
}
}
func TestApplyAgentMemoryTuningExplicitLimitWins(t *testing.T) {
preserveRuntimeKnobs(t)
unsetenv(t, "GOMEMLIMIT")
t.Setenv("XUI_MEMORY_LIMIT", "40")
lines := ApplyAgentMemoryTuning()
if !strings.Contains(strings.Join(lines, "\n"), "Go memory soft limit set to 40 MiB (XUI_MEMORY_LIMIT=40MiB)") {
t.Fatalf("lines = %v, want XUI_MEMORY_LIMIT budget", lines)
}
if got := debug.SetMemoryLimit(-1); got != int64(40)<<20 {
t.Fatalf("memory limit = %d, want %d", got, int64(40)<<20)
}
}
func TestApplyAgentMemoryTuningGOMEMLIMITEnvWins(t *testing.T) {
preserveRuntimeKnobs(t)
before := debug.SetMemoryLimit(-1)
t.Setenv("GOMEMLIMIT", "32MiB")
lines := ApplyAgentMemoryTuning()
if !strings.Contains(strings.Join(lines, "\n"), "Go memory soft limit not enforced: GOMEMLIMIT env") {
t.Fatalf("lines = %v, want GOMEMLIMIT left to the runtime", lines)
}
if got := debug.SetMemoryLimit(-1); got != before {
t.Fatalf("memory limit changed to %d, want untouched %d", got, before)
}
}
func TestApplyAgentMemoryTuningXUIGOGCOverride(t *testing.T) {
preserveRuntimeKnobs(t)
if _, present := lookupGOGC(); present {
t.Skip("GOGC is set in the test environment")
}
t.Setenv("XUI_GOGC", "80")
lines := ApplyAgentMemoryTuning()
if !strings.Contains(strings.Join(lines, "\n"), "GC percent set to 80") {
t.Fatalf("lines = %v, want XUI_GOGC override", lines)
}
}
+198 -108
View File
@@ -3,6 +3,7 @@ package controller
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
@@ -146,46 +147,91 @@ func (a *AgentController) captureSeenIP(c *gin.Context, agent *model.AgentNode)
if ip == nil || agent == nil {
return
}
updates := map[string]any{"seen_ip_at": time.Now().UnixMilli()}
now := time.Now().UnixMilli()
cols := []agentColumn{{"seen_ip_at", now}, {"updated_at", now}}
if v4 := ip.To4(); v4 != nil {
if agent.SeenIPv4 == v4.String() {
return
}
updates["seen_ipv4"] = v4.String()
cols = append(cols, agentColumn{"seen_ipv4", v4.String()})
} else {
if agent.SeenIPv6 == ip.String() {
return
}
updates["seen_ipv6"] = ip.String()
cols = append(cols, agentColumn{"seen_ipv6", ip.String()})
}
if err := database.GetDB().Model(&model.AgentNode{}).
Where("id = ?", agent.Id).Updates(updates).Error; err != nil {
if err := execAgentColumns(uint(agent.Id), cols); err != nil {
return
}
if v, ok := updates["seen_ipv4"]; ok {
agent.SeenIPv4 = v.(string)
for _, c := range cols {
switch c.name {
case "seen_ipv4":
agent.SeenIPv4 = c.value.(string)
case "seen_ipv6":
agent.SeenIPv6 = c.value.(string)
}
}
if v, ok := updates["seen_ipv6"]; ok {
agent.SeenIPv6 = v.(string)
agent.SeenIPAt = now
}
// agentColumn is one column assignment for execAgentColumns.
type agentColumn struct {
name string
value any
}
// execAgentColumns writes exactly the given columns of one
// agent_nodes row with a single raw UPDATE (ITEM-P3 M4). The ingest
// hot paths — heartbeats, traffic reports, probe fast-reports, for
// every agent every few seconds — previously paid gorm's statement
// build/clone/scan machinery per call; the baseline measured that
// family at roughly a third of the master's allocation churn. The
// column sets and values written are unchanged from the gorm calls
// this replaces, including updated_at, which gorm's autoUpdateTime
// stamped on every one of them.
func execAgentColumns(id uint, cols []agentColumn) error {
if len(cols) == 0 {
return nil
}
agent.SeenIPAt = updates["seen_ip_at"].(int64)
sqlDB, err := database.GetDB().DB()
if err != nil {
return err
}
var sb strings.Builder
sb.WriteString("UPDATE agent_nodes SET ")
args := make([]any, 0, len(cols)+1)
for i, c := range cols {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(c.name)
sb.WriteString(" = ?")
args = append(args, c.value)
}
sb.WriteString(" WHERE id = ?")
args = append(args, id)
_, err = sqlDB.Exec(sb.String(), args...)
return err
}
func (a *AgentController) touchAgent(id uint, name string) {
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
now := time.Now().UnixMilli()
cols := []agentColumn{{"last_seen", now}, {"updated_at", now}}
if name != "" {
updates["name"] = name
cols = append(cols, agentColumn{"name", name})
}
db := database.GetDB()
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
_ = execAgentColumns(id, cols)
// If bound to a node, refresh the node's heartbeat so the legacy node
// list shows it online (the legacy probe can't reach agent-only nodes).
var nodeID *int
_ = db.Model(&model.AgentNode{}).Select("node_id").Where("id = ?", id).Scan(&nodeID).Error
if nodeID != nil {
now := time.Now().UnixMilli()
_ = db.Exec("UPDATE nodes SET last_heartbeat = ?, status = 'online' WHERE id = ?", now, *nodeID).Error
sqlDB, err := database.GetDB().DB()
if err != nil {
return
}
var nodeID sql.NullInt64
if err := sqlDB.QueryRow("SELECT node_id FROM agent_nodes WHERE id = ?", id).Scan(&nodeID); err != nil || !nodeID.Valid {
return
}
_ = database.GetDB().Exec("UPDATE nodes SET last_heartbeat = ?, status = 'online' WHERE id = ?", now, nodeID.Int64).Error
}
// register activates a pre-issued token. Tokens are issued bound to a node via
@@ -233,35 +279,36 @@ type heartbeatTelemetry struct {
// updateAgentTelemetry persists the reported telemetry fields onto the
// agent row. Only fields actually present in the report are written.
func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
updates := map[string]any{"telemetry_at": time.Now().UnixMilli()}
now := time.Now().UnixMilli()
cols := []agentColumn{{"telemetry_at", now}, {"updated_at", now}}
if t.AgentVersion != nil {
updates["agent_version"] = *t.AgentVersion
cols = append(cols, agentColumn{"agent_version", *t.AgentVersion})
}
if t.XrayVersion != nil {
updates["xray_version"] = *t.XrayVersion
cols = append(cols, agentColumn{"xray_version", *t.XrayVersion})
}
if t.SingboxVersion != nil {
updates["singbox_version"] = *t.SingboxVersion
cols = append(cols, agentColumn{"singbox_version", *t.SingboxVersion})
}
if t.UptimeSeconds != nil {
updates["uptime_secs"] = *t.UptimeSeconds
cols = append(cols, agentColumn{"uptime_secs", *t.UptimeSeconds})
}
if t.LatencyMs != nil {
updates["latency_ms"] = *t.LatencyMs
cols = append(cols, agentColumn{"latency_ms", *t.LatencyMs})
}
if t.CpuPercent != nil {
updates["cpu_pct"] = *t.CpuPercent
cols = append(cols, agentColumn{"cpu_pct", *t.CpuPercent})
}
if t.MemPercent != nil {
updates["mem_pct"] = *t.MemPercent
cols = append(cols, agentColumn{"mem_pct", *t.MemPercent})
}
if t.IPv4 != nil {
updates["ipv4"] = *t.IPv4
cols = append(cols, agentColumn{"ipv4", *t.IPv4})
}
if t.IPv6 != nil {
updates["ipv6"] = *t.IPv6
cols = append(cols, agentColumn{"ipv6", *t.IPv6})
}
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
_ = execAgentColumns(id, cols)
}
// heartbeatProbeFacts / heartbeatProbeMetrics are the monitor-probe-style
@@ -309,99 +356,101 @@ type heartbeatProbeMetrics struct {
// metrics share one update so a heartbeat carries a single write; register
// calls it with metrics == nil.
func (a *AgentController) updateAgentProbe(id uint, f *heartbeatProbeFacts, m *heartbeatProbeMetrics) {
updates := map[string]any{}
var cols []agentColumn
if f != nil {
if f.Hostname != nil {
updates["probe_hostname"] = *f.Hostname
cols = append(cols, agentColumn{"probe_hostname", *f.Hostname})
}
if f.OS != nil {
updates["probe_os"] = *f.OS
cols = append(cols, agentColumn{"probe_os", *f.OS})
}
if f.Kernel != nil {
updates["probe_kernel"] = *f.Kernel
cols = append(cols, agentColumn{"probe_kernel", *f.Kernel})
}
if f.Arch != nil {
updates["probe_arch"] = *f.Arch
cols = append(cols, agentColumn{"probe_arch", *f.Arch})
}
if f.Virt != nil {
updates["probe_virt"] = *f.Virt
cols = append(cols, agentColumn{"probe_virt", *f.Virt})
}
if f.CPUName != nil {
updates["probe_cpu_name"] = *f.CPUName
cols = append(cols, agentColumn{"probe_cpu_name", *f.CPUName})
}
if f.CPUCores != nil {
updates["probe_cpu_cores"] = *f.CPUCores
cols = append(cols, agentColumn{"probe_cpu_cores", *f.CPUCores})
}
if f.MemTotal != nil {
updates["probe_mem_total"] = *f.MemTotal
cols = append(cols, agentColumn{"probe_mem_total", *f.MemTotal})
}
if f.SwapTotal != nil {
updates["probe_swap_total"] = *f.SwapTotal
cols = append(cols, agentColumn{"probe_swap_total", *f.SwapTotal})
}
if f.DiskTotal != nil {
updates["probe_disk_total"] = *f.DiskTotal
cols = append(cols, agentColumn{"probe_disk_total", *f.DiskTotal})
}
}
if m != nil {
updates["probe_at"] = time.Now().UnixMilli()
cols = append(cols, agentColumn{"probe_at", time.Now().UnixMilli()})
if m.BootID != nil {
updates["probe_boot_id"] = *m.BootID
cols = append(cols, agentColumn{"probe_boot_id", *m.BootID})
}
if m.Iface != nil {
updates["probe_iface"] = *m.Iface
cols = append(cols, agentColumn{"probe_iface", *m.Iface})
}
if m.Load != nil {
updates["probe_load1"] = (*m.Load)[0]
updates["probe_load5"] = (*m.Load)[1]
updates["probe_load15"] = (*m.Load)[2]
cols = append(cols,
agentColumn{"probe_load1", (*m.Load)[0]},
agentColumn{"probe_load5", (*m.Load)[1]},
agentColumn{"probe_load15", (*m.Load)[2]})
}
if m.MemTotal != nil {
updates["probe_mem_total"] = *m.MemTotal
cols = append(cols, agentColumn{"probe_mem_total", *m.MemTotal})
}
if m.MemUsed != nil {
updates["probe_mem_used"] = *m.MemUsed
cols = append(cols, agentColumn{"probe_mem_used", *m.MemUsed})
}
if m.SwapTotal != nil {
updates["probe_swap_total"] = *m.SwapTotal
cols = append(cols, agentColumn{"probe_swap_total", *m.SwapTotal})
}
if m.SwapUsed != nil {
updates["probe_swap_used"] = *m.SwapUsed
cols = append(cols, agentColumn{"probe_swap_used", *m.SwapUsed})
}
if m.DiskTotal != nil {
updates["probe_disk_total"] = *m.DiskTotal
cols = append(cols, agentColumn{"probe_disk_total", *m.DiskTotal})
}
if m.DiskUsed != nil {
updates["probe_disk_used"] = *m.DiskUsed
cols = append(cols, agentColumn{"probe_disk_used", *m.DiskUsed})
}
if m.NetRxTotal != nil {
updates["probe_net_rx_tot"] = *m.NetRxTotal
cols = append(cols, agentColumn{"probe_net_rx_tot", *m.NetRxTotal})
}
if m.NetTxTotal != nil {
updates["probe_net_tx_tot"] = *m.NetTxTotal
cols = append(cols, agentColumn{"probe_net_tx_tot", *m.NetTxTotal})
}
if m.NetRx != nil {
updates["probe_net_rx"] = *m.NetRx
cols = append(cols, agentColumn{"probe_net_rx", *m.NetRx})
}
if m.NetTx != nil {
updates["probe_net_tx"] = *m.NetTx
cols = append(cols, agentColumn{"probe_net_tx", *m.NetTx})
}
if m.TCP != nil {
updates["probe_tcp"] = *m.TCP
cols = append(cols, agentColumn{"probe_tcp", *m.TCP})
}
if m.UDP != nil {
updates["probe_udp"] = *m.UDP
cols = append(cols, agentColumn{"probe_udp", *m.UDP})
}
if m.Procs != nil {
updates["probe_procs"] = *m.Procs
cols = append(cols, agentColumn{"probe_procs", *m.Procs})
}
if m.CPU != nil {
updates["probe_cpu"] = *m.CPU
cols = append(cols, agentColumn{"probe_cpu", *m.CPU})
}
}
if len(updates) == 0 {
if len(cols) == 0 {
return
}
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
cols = append(cols, agentColumn{"updated_at", time.Now().UnixMilli()})
_ = execAgentColumns(id, cols)
}
// probeSample flattens a heartbeat metrics block into the probe
@@ -467,31 +516,40 @@ type heartbeatUpdateStatus struct {
// clear the intent so the config poll stops re-offering it; the recorded
// state stays visible on the node row.
func (a *AgentController) applyAgentUpdateStatus(id uint, st *heartbeatUpdateStatus) {
db := database.GetDB()
var row model.AgentNode
if err := db.First(&row, id).Error; err != nil {
// Only the intent stamp is needed to gate the report (ITEM-P3
// M4: a two-column read instead of a whole-row First).
sqlDB, err := database.GetDB().DB()
if err != nil {
return
}
if row.UpdateRequestedAt == 0 || st.RequestedAt != row.UpdateRequestedAt {
var requestedAt sql.NullInt64
if err := sqlDB.QueryRow("SELECT update_requested_at FROM agent_nodes WHERE id = ?", id).Scan(&requestedAt); err != nil {
return
}
updates := map[string]any{"update_state_at": time.Now().UnixMilli()}
if !requestedAt.Valid || requestedAt.Int64 == 0 || st.RequestedAt != requestedAt.Int64 {
return
}
now := time.Now().UnixMilli()
cols := []agentColumn{{"update_state_at", now}, {"updated_at", now}}
switch st.State {
case "downloading", "swapped":
updates["update_state"] = st.State
updates["update_error"] = ""
cols = append(cols,
agentColumn{"update_state", st.State},
agentColumn{"update_error", ""})
case "failed":
updates["update_state"] = "failed"
updates["update_error"] = st.Error
updates["update_requested_at"] = int64(0)
cols = append(cols,
agentColumn{"update_state", "failed"},
agentColumn{"update_error", st.Error},
agentColumn{"update_requested_at", int64(0)})
case "current":
updates["update_state"] = "current"
updates["update_error"] = ""
updates["update_requested_at"] = int64(0)
cols = append(cols,
agentColumn{"update_state", "current"},
agentColumn{"update_error", ""},
agentColumn{"update_requested_at", int64(0)})
default:
return
}
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
_ = execAgentColumns(id, cols)
}
// confirmAgentUpdate marks a pending intent successful once the agent
@@ -501,23 +559,31 @@ func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) {
if reportedVersion == "" {
return
}
db := database.GetDB()
var row model.AgentNode
if err := db.First(&row, id).Error; err != nil {
// Only the pending intent's stamp and target gate the confirm
// (ITEM-P3 M4: a two-column read instead of a whole-row First).
sqlDB, err := database.GetDB().DB()
if err != nil {
return
}
if row.UpdateRequestedAt == 0 || row.UpdateTarget == "" {
var requestedAt sql.NullInt64
var target sql.NullString
if err := sqlDB.QueryRow("SELECT update_requested_at, update_target FROM agent_nodes WHERE id = ?", id).Scan(&requestedAt, &target); err != nil {
return
}
if cmp, ok := config.CompareDisplayVersion(reportedVersion, row.UpdateTarget); !ok || cmp < 0 {
if !requestedAt.Valid || requestedAt.Int64 == 0 || !target.Valid || target.String == "" {
return
}
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(map[string]any{
"update_state": "success",
"update_error": "",
"update_requested_at": int64(0),
"update_state_at": time.Now().UnixMilli(),
}).Error
if cmp, ok := config.CompareDisplayVersion(reportedVersion, target.String); !ok || cmp < 0 {
return
}
now := time.Now().UnixMilli()
_ = execAgentColumns(id, []agentColumn{
{"update_state", "success"},
{"update_error", ""},
{"update_requested_at", int64(0)},
{"update_state_at", now},
{"updated_at", now},
})
}
// heartbeatProbeResult is one latency sample an agent piggybacks on
@@ -626,11 +692,16 @@ func probeStatIface(agentID int64) string {
}
// probeReport ingests one fast report (V2.0.1): a probe metrics
// block and nothing else. It deliberately does NOT touch the
// agent's last-seen stamp — liveness stays the 60 s heartbeat's
// signal, and the traffic ledger's unseen-boot rule keeps measuring
// reboots against heartbeat sightings exactly as before. Agents on
// older masters never see this endpoint (404) and simply keep
// block and nothing else. Since V2.0.3 it also touches the agent's
// last-seen stamp, like every other report channel: the hub's
// themes gate each batch on the node's last_seen being newer than
// the last one they applied, so a metrics-only channel that never
// advanced the stamp froze the public cards between the 10 s
// traffic reports. Liveness now means "any report from the agent"
// — all channels come from the same process — and the traffic
// ledger's unseen-boot rule is unaffected: Record still receives
// the pre-touch stamp, so gaps measure exactly as before. Agents
// on older masters never see this endpoint (404) and simply keep
// reporting metrics on the heartbeat; masters accept metrics on
// either channel.
func (a *AgentController) probeReport(c *gin.Context) {
@@ -644,9 +715,13 @@ func (a *AgentController) probeReport(c *gin.Context) {
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
}
_ = c.ShouldBindJSON(&req)
// The last-seen stamp as it stood before this report (see the
// heartbeat handler): captured before touchAgent overwrites it.
prevLastSeen := agent.LastSeen
a.touchAgent(uint(agent.Id), "")
if req.ProbeMetrics != nil {
a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics)
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), agent.LastSeen)
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
}
jsonObj(c, gin.H{"message": "ok"}, nil)
}
@@ -662,22 +737,37 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return
}
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if sbErr != nil {
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
return
// Probe-only nodes (V2.1.0) get no core payload at all: the build
// is skipped entirely, so a broken data model on such a node can
// never 500 its poll, and the probe keys + update intent below
// ride along exactly as for normal nodes. singbox_config stays
// null and the hash empty, which agents read as "no payload".
var sbRaw json.RawMessage
sbHash := ""
if !agent.ProbeOnly {
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if sbErr != nil {
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
return
}
raw, mErr := json.Marshal(sbCfg)
if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return
}
sbRaw = raw
sbSum := sha256.Sum256(sbRaw)
sbHash = hex.EncodeToString(sbSum[:])
}
sbRaw, mErr := json.Marshal(sbCfg)
if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return
}
sbSum := sha256.Sum256(sbRaw)
payload := gin.H{
"singbox_config": json.RawMessage(sbRaw),
"singbox_config_hash": hex.EncodeToString(sbSum[:]),
"singbox_config": sbRaw,
"singbox_config_hash": sbHash,
// Probe-only (V2.1.0): the per-node flag rides every poll so
// toggling it in the panel takes effect without touching the
// node. Always present from this master version.
"probe_only": agent.ProbeOnly,
// Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while
@@ -18,9 +18,10 @@ import (
)
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
// metrics without touching liveness (last-seen stays the
// heartbeat's), and the config poll carries the cadence + the
// node's counted-interface pin.
// metrics, and the config poll carries the cadence + the node's
// counted-interface pin. Since V2.0.3 the fast report also stamps
// last-seen (the hub's themes gate batches on its freshness); it
// originally did not — see the probeReport handler comment.
func TestAgentFastReportAndConfigKeys(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
@@ -71,6 +72,12 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
// Age the stamp first so the fast report's touch is observable
// even within the same millisecond as the heartbeat's.
stale := seenAfterHeartbeat - 120_000
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
t.Fatalf("age last-seen: %v", err)
}
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
t.Fatalf("probe-report: %d (%s)", code, body)
}
@@ -78,8 +85,8 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
t.Errorf("fast report metrics not stored: %+v", row)
}
if row.LastSeen != seenAfterHeartbeat {
t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen)
if row.LastSeen <= stale {
t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen)
}
// Unauthorized callers are refused.
@@ -0,0 +1,165 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Windows agent freeze (version decoupling): the Windows agent line is
// frozen at A2.2.1 — exactly one machine (TYYUN, probe-only) runs it
// and no new Windows builds will ship. The frozen agent can therefore
// never be updated to match a future master, so the MASTER side of
// its protocol is under contract instead: the request shapes the
// A2.2.1 Windows agent sends (register / heartbeat / fast report) and
// the response shapes it consumes (envelopes, config payload keys)
// must keep working verbatim. Any change that breaks this test is a
// breaking change for a node that cannot be upgraded — see
// docs/release.md ("Windows freeze").
//
// The payloads below mirror what cmd/xui-agent-windows at A2.2.1
// actually emits, including its documented degradations (V2.2.1):
// no xray/singbox version in telemetry, load and TCP/UDP counts
// pinned to zero, no updateStatus ever.
func TestAgentLegacyWindows221Contract(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
node := &model.Node{Name: "tyyun-frozen", Address: "10.0.22.1", Enable: true}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
const token = "legacy-windows-token"
agent := &model.AgentNode{
TokenHash: hashAgentToken(token), TokenPrefix: "legacy-",
Name: "tyyun-frozen", NodeID: &node.Id, ProbeOnly: true,
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
call := func(method, path, body string) (int, []byte) {
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec.Code, rec.Body.Bytes()
}
load := func() model.AgentNode {
var row model.AgentNode
if err := db.First(&row, agent.Id).Error; err != nil {
t.Fatalf("reload agent: %v", err)
}
return row
}
// --- register, with the probe facts a Windows agent reports -----
regBody := `{"token":"` + token + `","name":"tyyun-frozen","probeFacts":{` +
`"hostname":"TYYUN-PC","os":"Windows 10","kernel":"10.0.19045","arch":"amd64",` +
`"virt":"kvm","cpuName":"Intel Xeon","cpuCores":4,` +
`"memTotal":4294967296,"swapTotal":0,"diskTotal":64424509440,` +
`"agentVersion":"A2.2.1"}}`
code, body := call(http.MethodPost, "/panel/api/agent/register", regBody)
if code != http.StatusOK {
t.Fatalf("register: %d (%s)", code, body)
}
var regEnv struct {
Success bool `json:"success"`
Obj struct {
Message string `json:"message"`
} `json:"obj"`
}
if err := json.Unmarshal(body, &regEnv); err != nil || !regEnv.Success || regEnv.Obj.Message != "registered" {
t.Fatalf("register envelope changed shape: %s", body)
}
if row := load(); row.ProbeHostname != "TYYUN-PC" || row.LastSeen == 0 {
t.Fatalf("register facts/last-seen not stored: %+v", row)
}
// --- heartbeat: telemetry (no core versions), facts + metrics --
hbBody := `{"token":"` + token + `","timestamp":1750000000,` +
`"telemetry":{"agentVersion":"A2.2.1","uptimeSeconds":90061,` +
`"cpuPercent":3.5,"memPercent":41.2,"ipv4":"203.0.113.9"},` +
`"probeFacts":{"hostname":"TYYUN-PC","os":"Windows 10","kernel":"10.0.19045",` +
`"arch":"amd64","virt":"kvm","cpuName":"Intel Xeon","cpuCores":4,` +
`"memTotal":4294967296,"swapTotal":0,"diskTotal":64424509440},` +
`"probeMetrics":{"bootId":"win-boot-1","iface":"Ethernet","uptime":90061,` +
`"load":[0,0,0],"memTotal":4294967296,"memUsed":1769803776,` +
`"swapTotal":0,"swapUsed":0,"diskTotal":64424509440,"diskUsed":21474836480,` +
`"netRxTotal":1073741824,"netTxTotal":536870912,"netRx":1024,"netTx":2048,` +
`"tcp":0,"udp":0,"procs":88,"cpu":3.5}}`
code, body = call(http.MethodPost, "/panel/api/agent/heartbeat", hbBody)
if code != http.StatusOK {
t.Fatalf("heartbeat: %d (%s)", code, body)
}
row := load()
if row.AgentVersion != "A2.2.1" {
t.Fatalf("agent version not stored from legacy heartbeat: %q", row.AgentVersion)
}
if row.TelemetryAt == 0 || row.ProbeAt == 0 {
t.Fatalf("legacy heartbeat did not stamp telemetry/probe times: %+v", row)
}
// --- fast report (probe-report): metrics only --------------------
frBody := `{"probeMetrics":{"bootId":"win-boot-1","iface":"Ethernet","uptime":90121,` +
`"load":[0,0,0],"memTotal":4294967296,"memUsed":1782579200,` +
`"swapTotal":0,"swapUsed":0,"diskTotal":64424509440,"diskUsed":21474836480,` +
`"netRxTotal":1074790400,"netTxTotal":537395200,"netRx":2048,"netTx":1024,` +
`"tcp":0,"udp":0,"procs":89,"cpu":4.0}}`
code, body = call(http.MethodPost, "/panel/api/agent/probe-report", frBody)
if code != http.StatusOK {
t.Fatalf("probe-report: %d (%s)", code, body)
}
if row := load(); row.ProbeMemUsed != 1782579200 {
t.Fatalf("fast report metrics not stored: %+v", row)
}
// --- config poll: the frozen agent consumes these keys ----------
code, body = call(http.MethodGet, "/panel/api/agent/config", "")
if code != http.StatusOK {
t.Fatalf("config: %d (%s)", code, body)
}
var cfg map[string]any
if err := json.Unmarshal(body, &cfg); err != nil {
t.Fatalf("config payload not a JSON object: %v", err)
}
// Probe-only: no core payload, but the probe keys must be present
// with their established types — the frozen agent keys its cadence
// and interface pin off them.
if cfg["probe_only"] != true {
t.Fatalf("probe_only = %v, want true", cfg["probe_only"])
}
if v, present := cfg["singbox_config"]; !present || v != nil {
t.Fatalf("singbox_config = %v (present %v), want present null for probe-only", v, present)
}
if _, present := cfg["probe_tasks"]; !present {
t.Fatal("probe_tasks key missing from config payload")
}
if _, ok := cfg["probe_fast_interval"].(float64); !ok {
t.Fatalf("probe_fast_interval = %v, want a number", cfg["probe_fast_interval"])
}
if _, ok := cfg["probe_stat_iface"].(string); !ok {
t.Fatalf("probe_stat_iface = %v, want a string", cfg["probe_stat_iface"])
}
if _, present := cfg["agent_update"]; present {
t.Fatal("no intent was recorded, so agent_update must be absent")
}
}
@@ -0,0 +1,125 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// V2.1.0: a probe-only agent's config poll carries the flag and NO
// core payload (singbox_config null, hash empty), while the probe
// keys and a pending update intent still ride along — a probe-only
// node must stay reachable for probes and one-click agent updates.
// The control agent on a normal node keeps receiving its payload.
func TestConfigProbeOnlyWithholdsCorePayload(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
seed := func(name, token string, probeOnly bool) {
node := &model.Node{Name: name, Address: "10.0.9.4"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: token[:12],
Name: name,
NodeID: &node.Id,
AgentVersion: "A2.0.3",
ProbeOnly: probeOnly,
}
if probeOnly {
agent.UpdateRequestedAt = time.Now().Unix()
agent.UpdateState = "pending"
agent.UpdateTarget = "A2.1.0"
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
inbound := &model.Inbound{
Enable: true,
Port: 443,
Protocol: model.VLESS,
Tag: "inbound-" + name,
NodeID: &node.Id,
Settings: `{"clients":[],"decryption":"none","encryption":"none","fallbacks":[]}`,
StreamSettings: `{"network":"tcp","security":"reality","realitySettings":` +
`{"serverNames":["www.amd.com"],"shortIds":["ab12cd34"],"privateKey":"FAKEPRIV",` +
`"settings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`,
SingboxConfig: `{"type":"vless","tag":"inbound-` + name + `","listen_port":443,` +
`"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,` +
`"handshake":{"server":"www.amd.com","server_port":443},"private_key":"FAKEPRIV",` +
`"short_id":["ab12cd34"]}},"users":[]}`,
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
}
const probeToken = "probe-only-token-0000000000000000000001"
const normalToken = "probe-normal-token-00000000000000000001"
seed("probe-node", probeToken, true)
seed("normal-node", normalToken, false)
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
get := func(token string) map[string]any {
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("config response not JSON: %v", err)
}
return resp
}
resp := get(probeToken)
if resp["probe_only"] != true {
t.Fatalf("probe_only = %v, want true", resp["probe_only"])
}
if cfg, present := resp["singbox_config"]; !present || cfg != nil {
t.Fatalf("singbox_config = %v (present=%v), want JSON null", cfg, present)
}
if h := resp["singbox_config_hash"]; h != "" {
t.Fatalf("singbox_config_hash = %v, want empty", h)
}
if _, present := resp["probe_tasks"]; !present {
t.Fatal("probe_tasks missing: probe-only nodes still run latency probes")
}
if _, present := resp["probe_fast_interval"]; !present {
t.Fatal("probe_fast_interval missing on probe-only config")
}
upd, _ := resp["agent_update"].(map[string]any)
if upd == nil || upd["target"] != "A2.1.0" {
t.Fatalf("agent_update = %v, want the pending intent (probe-only nodes still update)", resp["agent_update"])
}
resp = get(normalToken)
if resp["probe_only"] != false {
t.Fatalf("probe_only = %v, want false for a normal node", resp["probe_only"])
}
if cfg, _ := resp["singbox_config"].(map[string]any); cfg == nil {
t.Fatalf("normal node lost its singbox payload: %v", resp["singbox_config"])
}
if h, _ := resp["singbox_config_hash"].(string); h == "" {
t.Fatal("normal node singbox_config_hash empty")
}
}
+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{}
+29 -193
View File
@@ -2,12 +2,14 @@ package controller
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"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/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -28,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) {
@@ -109,77 +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, _, 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"), 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) {
@@ -192,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)
}
@@ -245,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
}
+12 -9
View File
@@ -211,10 +211,12 @@ func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
// text by default, binary gzip frames for the anonymous audience when
// opened with ?gzip. Audience eligibility is re-checked every tick —
// a socket outlives both the session and the public-page switch.
// liveWS is the hub's browser stream: a snapshot frame on the
// operator's fast-report cadence (V2.0.2; the hub's fixed 2 s tick
// made a 1 s agent cadence invisible up front), text by default,
// binary gzip frames for the anonymous audience when opened with
// ?gzip. Audience eligibility is re-checked every tick — a socket
// outlives both the session and the public-page switch.
func (pc *PublicProbeController) liveWS(c *gin.Context) {
authed := probeAuthed(c) && !fromOtherOrigin(c)
if !authed && !service.ProbePublicPageEnabled() {
@@ -280,18 +282,19 @@ func (pc *PublicProbeController) liveWS(c *gin.Context) {
return conn.WriteMessage(frameType, frame) == nil
}
// The first frame goes out immediately (hub's interval ticks at
// once), then one per 2 s tick.
// The first frame goes out immediately; later frames follow the
// fast-report cadence, re-read after every frame so a settings
// change lands on a live socket without a reconnect.
if !send() {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
timer := time.NewTimer(time.Duration(service.ProbeFastIntervalSec()) * time.Second)
select {
case <-done:
timer.Stop()
return
case <-ticker.C:
case <-timer.C:
if !send() {
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"])
}
}
@@ -0,0 +1,63 @@
package controller
import (
"net/http/httptest"
"strings"
"testing"
"time"
ws "github.com/gorilla/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// V2.0.2: /api/ws frame cadence follows the operator's fast-report
// interval (probe_fast_interval) instead of the hub's fixed 2 s tick.
// A mid-stream settings change re-paces a live socket: the timer
// already in flight still fires on the old cadence, the next one is
// armed from the re-read setting.
func TestProbeWebSocketCadenceFollowsFastInterval(t *testing.T) {
rig := newProbeTestRig(t)
_, _ = rig.seedAgent(t, "CadenceNode", true)
rig.setSetting(t, "public_page", "on")
rig.setSetting(t, "probe_fast_interval", "1")
service.InvalidateProbeSnapshot()
srv := httptest.NewServer(rig.engine)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
readAt := func() time.Time {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("ws read: %v", err)
}
return time.Now()
}
_ = readAt() // first frame: immediate
prev := readAt()
for i := 0; i < 2; i++ {
now := readAt()
if gap := now.Sub(prev); gap < 500*time.Millisecond || gap > 2500*time.Millisecond {
t.Fatalf("frame gap at 1 s cadence: %v", gap)
}
prev = now
}
// Change to 3 s mid-stream: one in-flight 1 s frame, then the
// stream settles onto the new cadence without a reconnect.
rig.setSetting(t, "probe_fast_interval", "3")
prev = readAt()
now := readAt()
if gap := now.Sub(prev); gap < 2200*time.Millisecond || gap > 6*time.Second {
t.Fatalf("frame gap after change to 3 s: %v", gap)
}
}
-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)
}
}
+216 -38
View File
@@ -7,7 +7,9 @@ import (
"encoding/json"
"io/fs"
"os"
"path"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -30,19 +32,57 @@ const (
Web I18nType = "web" // Web interface type
)
// defaultLanguageTag is the bundle's fallback language — the one
// language parsed eagerly at startup.
const defaultLanguageTag = "en-US"
// i18nSource is one translation file held as raw bytes. Parsing is
// what costs memory (ITEM-P3 baseline: all 13 language files parsed
// ≈ 10.2 MB of live heap, ~2/3 of the master's total), while the raw
// JSON is an order of magnitude smaller — so sources stay resident
// unparsed and each language is parsed on first use and cached
// (ITEM-P3 M1).
type i18nSource struct {
tag string // language tag from the file name, e.g. "zh-CN"
path string // path handed to ParseMessageFileBytes, as before
data []byte
}
var (
// i18nMu serializes lazy parses against localizations: parsing
// takes the write lock, Localizer creation and Localize calls
// take the read lock, so a first-use parse can never race a
// lookup in another goroutine.
i18nMu sync.RWMutex
i18nSources []i18nSource
i18nParsed map[string]bool
)
// SettingService interface defines methods for accessing locale settings.
type SettingService interface {
GetTgLang() (string, error)
}
// InitLocalizer initializes the internationalization system with embedded translation files.
// Only the fallback language (and, via initTGBotLocalizer, the bot's
// configured language) is parsed here; every other language is
// indexed as raw bytes and parsed lazily on first use.
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
i18nMu.Lock()
// set default bundle to English
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
i18nBundle = i18n.NewBundle(language.MustParse(defaultLanguageTag))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
i18nSources = nil
i18nParsed = map[string]bool{}
// parse files
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
// index the translation files, then parse the fallback language
if err := indexTranslationFiles(i18nFS); err != nil {
i18nMu.Unlock()
return err
}
err := ensureLanguageLocked(defaultLanguageTag)
i18nMu.Unlock()
if err != nil {
return err
}
@@ -93,10 +133,12 @@ func I18n(i18nType I18nType, key string, params ...string) string {
return key
}
i18nMu.RLock()
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: templateData,
})
i18nMu.RUnlock()
if err != nil {
logger.Errorf("Failed to localize message: %v", err)
return ""
@@ -112,6 +154,16 @@ func initTGBotLocalizer(settingService SettingService) error {
return err
}
// The bot localizer is created once, here, so its language is a
// startup language: parse it now (its first use). A parse failure
// fails startup, exactly as the old parse-everything init did for
// every language.
if err := ensureLanguage(botLang); err != nil {
return err
}
i18nMu.RLock()
defer i18nMu.RUnlock()
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
return nil
}
@@ -123,14 +175,8 @@ func initTGBotLocalizer(settingService SettingService) error {
func LocalizerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Ensure bundle is initialized so creating a Localizer won't panic
if i18nBundle == nil {
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Try lazy-load from disk when running sub server without InitLocalizer
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
}
ensureBundle()
var lang string
if cookie, err := c.Request.Cookie("lang"); err == nil {
@@ -139,7 +185,16 @@ func LocalizerMiddleware() gin.HandlerFunc {
lang = c.GetHeader("Accept-Language")
}
// First use of a language parses it (cached afterwards). A
// parse failure only degrades this request to the fallback
// language; it must not fail the request.
if err := ensureLanguage(lang); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
i18nMu.RLock()
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
i18nMu.RUnlock()
c.Set("localizer", LocalizerWeb)
c.Set("I18n", I18n)
@@ -147,28 +202,127 @@ func LocalizerMiddleware() gin.HandlerFunc {
}
}
// loadTranslationsFromDisk attempts to load translation files from "internal/web/translation" using the local filesystem.
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
root := os.DirFS("internal/web")
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(root, path)
if err != nil {
return err
}
_, err = bundle.ParseMessageFileBytes(data, path)
return err
})
// ensureBundle covers processes that serve web requests without
// InitLocalizer having run (the sub server's historical lazy path):
// it creates the bundle and indexes the translation files from disk.
func ensureBundle() {
i18nMu.RLock()
ready := i18nBundle != nil
i18nMu.RUnlock()
if ready {
return
}
i18nMu.Lock()
defer i18nMu.Unlock()
if i18nBundle != nil {
return
}
i18nBundle = i18n.NewBundle(language.MustParse(defaultLanguageTag))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
i18nParsed = map[string]bool{}
if err := loadTranslationsFromDisk(); err != nil {
logger.Warning("i18n lazy load failed:", err)
return
}
if err := ensureLanguageLocked(defaultLanguageTag); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
}
// parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
err := fs.WalkDir(i18nFS, "translation",
// ensureLanguage parses the translation file best matching the
// preference (a bare tag like "zh-CN", or an Accept-Language header
// value) unless it is already parsed, and always makes sure the
// fallback language is present. Concurrent first uses are safe.
func ensureLanguage(pref string) error {
i18nMu.Lock()
defer i18nMu.Unlock()
return ensureLanguageLocked(pref)
}
// ensureLanguageLocked is ensureLanguage with i18nMu write-held.
func ensureLanguageLocked(pref string) error {
if err := parseLanguageLocked(defaultLanguageTag); err != nil {
return err
}
tag := matchLanguageTag(pref)
if tag == "" || tag == defaultLanguageTag {
return nil
}
return parseLanguageLocked(tag)
}
// matchLanguageTag resolves a preference to one of the indexed
// source tags, or "" when nothing plausible matches — an unsupported
// language must not trigger a pointless parse, it just falls back.
func matchLanguageTag(pref string) string {
// An Accept-Language header is a preference list with optional
// quality values; the first entry is the most preferred.
if i := strings.IndexAny(pref, ",;"); i >= 0 {
pref = pref[:i]
}
pref = strings.TrimSpace(pref)
if pref == "" {
return ""
}
for _, src := range i18nSources {
if strings.EqualFold(src.tag, pref) {
return src.tag
}
}
want, err := language.Parse(pref)
if err != nil {
return ""
}
tags := make([]language.Tag, 0, len(i18nSources))
raws := make([]string, 0, len(i18nSources))
for _, src := range i18nSources {
if t, err := language.Parse(src.tag); err == nil {
tags = append(tags, t)
raws = append(raws, src.tag)
}
}
if len(tags) == 0 {
return ""
}
_, idx, confidence := language.NewMatcher(tags).Match(want)
if confidence == language.No {
return ""
}
return raws[idx]
}
// parseLanguageLocked parses one indexed language into the bundle,
// once. Unknown tags are a no-op (lookups fall back), as before.
// Caller holds i18nMu (write).
func parseLanguageLocked(tag string) error {
if i18nParsed[tag] {
return nil
}
for _, src := range i18nSources {
if src.tag == tag {
if _, err := i18nBundle.ParseMessageFileBytes(src.data, src.path); err != nil {
return err
}
i18nParsed[tag] = true
return nil
}
}
return nil
}
// languageTagFromPath extracts the language tag from a translation
// file path ("translation/zh-CN.json" -> "zh-CN"), the same name
// ParseMessageFileBytes derives the language from.
func languageTagFromPath(p string) string {
base := path.Base(p)
return strings.TrimSuffix(base, path.Ext(base))
}
// indexTranslationFiles indexes the embedded translation files as
// raw bytes without parsing them (ITEM-P3 M1). Caller holds i18nMu.
func indexTranslationFiles(i18nFS embed.FS) error {
return fs.WalkDir(i18nFS, "translation",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
@@ -183,12 +337,36 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
return err
}
_, err = i18nBundle.ParseMessageFileBytes(data, path)
return err
i18nSources = append(i18nSources, i18nSource{
tag: languageTagFromPath(path),
path: path,
data: data,
})
return nil
})
if err != nil {
return err
}
return nil
}
// loadTranslationsFromDisk indexes translation files from
// "internal/web/translation" using the local filesystem, as raw
// bytes (parsing stays lazy). Caller holds i18nMu.
func loadTranslationsFromDisk() error {
root := os.DirFS("internal/web")
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(root, path)
if err != nil {
return err
}
i18nSources = append(i18nSources, i18nSource{
tag: languageTagFromPath(path),
path: path,
data: data,
})
return nil
})
}
+188
View File
@@ -0,0 +1,188 @@
package locale
import (
"embed"
"errors"
"sync"
"testing"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
// Test fixtures mirror the production layout (an embed FS whose
// root holds "translation/<tag>.json") with three small languages.
// They live under this package's own translation/ directory and are
// used only by these tests — the panel embeds internal/web's files.
//go:embed translation
var testI18nFS embed.FS
type fakeSettingService struct{ lang string }
func (f fakeSettingService) GetTgLang() (string, error) { return f.lang, nil }
func parsedTags() map[string]bool {
i18nMu.RLock()
defer i18nMu.RUnlock()
out := make(map[string]bool, len(i18nParsed))
for k, v := range i18nParsed {
out[k] = v
}
return out
}
// localize mirrors how production localizers are built (language
// ensured first, then NewLocalizer) and returns the raw library
// result: go-i18n answers a key missing in the requested language
// with the default language's text AND a MessageNotFoundErr, so
// callers here assert both halves of that contract.
func localize(t *testing.T, lang, key string, templateData map[string]any) (string, error) {
t.Helper()
if err := ensureLanguage(lang); err != nil {
t.Fatalf("ensureLanguage(%q): %v", lang, err)
}
i18nMu.RLock()
loc := i18n.NewLocalizer(i18nBundle, lang)
i18nMu.RUnlock()
return loc.Localize(&i18n.LocalizeConfig{MessageID: key, TemplateData: templateData})
}
func localizeOK(t *testing.T, lang, key string, templateData map[string]any) string {
t.Helper()
msg, err := localize(t, lang, key, templateData)
if err != nil {
t.Fatalf("Localize(%q, %q): %v", lang, key, err)
}
return msg
}
// ITEM-P3 M1: startup parses only the fallback (+ the bot's
// language); other languages parse on first use, then serve from
// cache; unsupported languages parse nothing and fall back.
func TestLazyLanguageParsing(t *testing.T) {
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "en-US"}); err != nil {
t.Fatalf("InitLocalizer: %v", err)
}
parsed := parsedTags()
if !parsed["en-US"] {
t.Fatalf("fallback language not parsed at init: %v", parsed)
}
if parsed["zh-CN"] || parsed["fa-IR"] {
t.Fatalf("non-startup languages parsed at init: %v", parsed)
}
if got := localizeOK(t, "zh-CN", "hello", nil); got != "你好" {
t.Fatalf("zh-CN hello = %q, want 你好", got)
}
if got := localizeOK(t, "zh-CN", "greet", map[string]any{"Name": "World"}); got != "你好 World" {
t.Fatalf("zh-CN greet = %q", got)
}
parsed = parsedTags()
if !parsed["zh-CN"] || parsed["fa-IR"] {
t.Fatalf("after zh-CN use, parsed = %v", parsed)
}
// A key missing in the requested language follows go-i18n's
// fallback contract: default-language text plus a
// MessageNotFoundErr — and the production I18n() wrapper maps
// that error to "", exactly as it did when every language was
// parsed eagerly.
msg, err := localize(t, "zh-CN", "only_en", nil)
if msg != "Only in English" {
t.Fatalf("fallback text = %q, want English text", msg)
}
var notFound *i18n.MessageNotFoundErr
if !errors.As(err, &notFound) {
t.Fatalf("fallback err = %v, want MessageNotFoundErr", err)
}
i18nMu.RLock()
LocalizerWeb = i18n.NewLocalizer(i18nBundle, "zh-CN")
i18nMu.RUnlock()
if got := I18n(Web, "only_en"); got != "" {
t.Fatalf("I18n fallback = %q, want \"\" (wrapper error contract)", got)
}
if got := I18n(Web, "hello"); got != "你好" {
t.Fatalf("I18n zh-CN hello = %q, want 你好", got)
}
// A bare language preference resolves to the regional file.
if got := localizeOK(t, "fa", "hello", nil); got != "سلام" {
t.Fatalf("fa hello = %q, want سلام", got)
}
if !parsedTags()["fa-IR"] {
t.Fatal("fa-IR not parsed after fa preference")
}
// An Accept-Language header value resolves to its first entry.
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "en-US"}); err != nil {
t.Fatalf("re-init: %v", err)
}
if err := ensureLanguage("zh-CN,zh;q=0.9,en;q=0.8"); err != nil {
t.Fatalf("ensureLanguage(header): %v", err)
}
if !parsedTags()["zh-CN"] {
t.Fatal("zh-CN not parsed from Accept-Language header")
}
// An unsupported language parses nothing and still localizes
// (in the fallback language, matched directly, so no error).
before := parsedTags()
if got := localizeOK(t, "de-DE", "hello", nil); got != "Hello" {
t.Fatalf("de-DE hello = %q, want fallback Hello", got)
}
after := parsedTags()
if len(after) != len(before) {
t.Fatalf("unsupported language triggered a parse: before %v after %v", before, after)
}
}
// The bot's configured language is a startup language: its
// localizer must work immediately after init.
func TestBotLanguageParsedAtInit(t *testing.T) {
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "fa-IR"}); err != nil {
t.Fatalf("InitLocalizer: %v", err)
}
if !parsedTags()["fa-IR"] {
t.Fatal("bot language not parsed at init")
}
if got := I18n(Bot, "hello"); got != "سلام" {
t.Fatalf("bot hello = %q, want سلام", got)
}
}
// Concurrent first use of several languages: parses and lookups
// must not race (run with -race) and every lookup must succeed.
func TestConcurrentFirstUse(t *testing.T) {
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "en-US"}); err != nil {
t.Fatalf("InitLocalizer: %v", err)
}
langs := []string{"zh-CN", "fa-IR", "en-US", "zh", "fa", "de-DE"}
var wg sync.WaitGroup
errs := make(chan error, 256)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, lang := range langs {
if err := ensureLanguage(lang); err != nil {
errs <- err
}
i18nMu.RLock()
loc := i18n.NewLocalizer(i18nBundle, lang)
i18nMu.RUnlock()
if _, err := loc.Localize(&i18n.LocalizeConfig{MessageID: "hello"}); err != nil {
errs <- err
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("concurrent first use: %v", err)
}
parsed := parsedTags()
if !parsed["zh-CN"] || !parsed["fa-IR"] || !parsed["en-US"] {
t.Fatalf("parsed after concurrent use = %v", parsed)
}
}
@@ -0,0 +1 @@
{"greet":"Hello {{.Name}}","hello":"Hello","only_en":"Only in English"}
@@ -0,0 +1 @@
{"greet":"سلام {{.Name}}","hello":"سلام"}
@@ -0,0 +1 @@
{"greet":"你好 {{.Name}}","hello":"你好"}
+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)
}

Some files were not shown because too many files have changed in this diff Show More