Compare commits

..

15 Commits

Author SHA1 Message Date
MHSanaei 49fbcdc09c fix(frontend): wrap overview modal action buttons on long labels
The Geodata Auto-Update actions row is a non-wrapping flex row with no
gap. Its three buttons fit in English, but the longer Russian, Ukrainian
and Turkish labels make the row wider than the default-width modal, so
the buttons spill out of it. The row now wraps with an 8px gap.

.actions-row is a global class defined identically in VersionModal.css
and PanelUpdateModal.css, so both copies change to keep the cascade
order irrelevant. Verified in Chromium with a temporary Storybook story
rendering VersionModal in ru-RU: the first button sat 88px outside the
modal before the change and inside it after.

Closes #6737
2026-10-05 20:20:52 +02:00
Egor aacfaebab8 fix(tuic): client speed display and certificate button layout (#6723)
* fix(tuic): restore client speed and certificate layout

* docs(tuic): clarify native runtime and protocol behavior

* fix(websocket): preserve traffic updates from independent sources

* fix(docs): sync websocket traffic schema and drop restating TUIC tests

docs/public/openapi.json still described the old traffic event, without
clientTrafficSource/clientTrafficIntervalMs or the TUIC oneOf branch, so
the docs site showed a payload the panel no longer sends. No check
covers that copy.

The TUIC certificate layout test and the TUIC speed payload test only
read back the literals the code writes, so neither could fail on a real
regression. Both are removed, along with the className that existed only
for the layout test.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-05 19:46:46 +02:00
冰 e897b0957a fix(sub): append host serverDescription to hysteria links (#6740)
* fix(sub): append host serverDescription to hysteria links

A host's Description reached vless/trojan/ss through buildEndpointLinks, but
genHysteriaLink renders the fragment in its own externalProxy loop and never
added the suffix, so Happ fell back to its "Hysteria | hysteria | TLS" caption
for every Hysteria server on a host that also serves VLESS (#6738).

Reuse appendHappServerDescription with the description the endpoint map already
carries, so no key lookup is duplicated and a host with no description emits the
same bytes as before. genTuicLink (service.go:912) has the same gap; left alone
to keep this diff to the reported protocol.

Regression test is red without the fix for both hysteria:// and hysteria2://.

* chore(sub): trim the hysteria serverDescription regression test

The no-description test passed with and without the #6738 fix: the empty
description branch is already pinned by TestAppendHappServerDescription, so
it certified nothing about this change. Also cut the remaining test's comment
block to the two-line limit CLAUDE.md sets.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-05 19:05:13 +02:00
Chester Fishmans b42a1c0ba1 fix(systemd): harden shipped x-ui unit files (#6718)
* fix(systemd): harden shipped x-ui unit files

The units ran the panel as root with no sandboxing: systemd-analyze
security rates them 9.6 UNSAFE.

Add NoNewPrivileges, ProtectSystem=full with ReadWritePaths for the
default XUI_DB_FOLDER/XUI_BIN_FOLDER/XUI_LOG_FOLDER stores, kernel and
clock protections, UMask=0077, RestrictAddressFamilies, a
CapabilityBoundingSet with NET_ADMIN/NET_BIND_SERVICE/NET_RAW and
SystemCallFilter=@system-service.

PrivateTmp is deliberately omitted: the web updater hands a path inside
the system temp directory to a systemd-run transient unit, which does not
share the service's private tmpfs. ProtectHome stays read-only because
installs keep TLS certificates under the root home directory.

Fixes #6605

* fix(systemd): ship ReadWriteDirectories= alias for systemd < 231

ReadWritePaths= only exists since systemd 231; install.sh still supports
CentOS 7 (systemd 219), where the directive is ignored and ProtectSystem=full
would leave the panel state directory read-only, breaking its database.

* fix(systemd): keep root's DAC bits and regenerate the write paths

Two follow-ups to the hardening, both reported by review on #6718.

CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH were dropped from the bounding set.
Root holds them normally, and a bounding set is subtracted from root too: the
panel could no longer read a private key it does not own (a Caddy-issued
certificate under its own state dir, an acme.sh home, any 0600 file owned by
another account). That fails quietly for TLS -- the panel listener logs the
tls.LoadX509KeyPair error and keeps serving plain HTTP, and Xray inbounds using
that key stop -- so both bits stay.

ProtectSystem=full plus a hard-coded ReadWritePaths list broke installs whose
XUI_DB_FOLDER/XUI_LOG_FOLDER/XUI_BIN_FOLDER live outside the defaults, and the
workaround of editing the unit did not survive an update, because install.sh and
update.sh reinstall the unit from the release tarball. The folders actually in
use are now resolved from the same env file the unit passes to the panel and
regenerated into x-ui.service.d/10-xui-write-paths.conf on every install and
update, so a relocated store stays writable and the list is not reset. The unit
keeps the plain-install defaults plus XUI_SERVICE, which the in-panel updater
needs when systemd-run is unavailable and it falls back to a child process that
inherits this sandbox while update.sh lands the unit again. Uninstall removes
the drop-in with the unit.

* fix(systemd): keep the seccomp whitelist off old systemd, tighten the rest

Review of the previous head found that SystemCallFilter=@system-service plus
SystemCallErrorNumber=EPERM is a hard regression on the platforms this PR means
to keep working. @-named filter groups exist from systemd 239 on, and older
systemd does not ignore an unknown group name: on <231 the name fails to resolve
and the filter stays the built-in whitelist of execve/exit/exit_group/
rt_sigreturn/sigreturn, on 231..238 it degrades to @default. Either way the panel
then gets EPERM on read/openat/mmap/clone and cannot start -- a CentOS 7 or
Ubuntu 18.04 install would come up dead after this update. The two directives now
live in the generated drop-in and are written only when "systemctl --version"
reports 239 or newer, so old hosts keep the rest of the hardening and simply go
without seccomp.

The same review listed three more items, all addressed here:

- a comment claiming ProtectSystem=full "keeps everything outside /var, /run and
  the listed ReadWritePaths read-only" -- that is `strict`; `full` locks down
  /usr, /boot, /efi and /etc;
- /etc/systemd/system was granted writable for the in-panel updater's fallback,
  but that fallback cannot work under this sandbox at all: update.sh also stages
  the release archive beside the main folder, replaces /usr/bin/x-ui and calls
  the package manager. The entry is gone and update.sh now stops up front with
  one clear message when the directories it needs are read-only, instead of
  failing halfway with "Failed to download x-ui";
- CAP_DAC_READ_SEARCH is redundant next to CAP_DAC_OVERRIDE, so the bounding set
  keeps just the latter.

Relocating a store by editing the env file alone is documented in the unit and
in the generated drop-in: the drop-in is only written by install/update, so one
of those has to be re-run afterwards.

Verified with a local harness (9 checks: plain defaults, relocated store read
from the env file, the same list produced by update.sh, duplicate collapse,
seccomp present at systemd 249 and absent at 238, read-only guard) and bash -n
on install.sh, update.sh, x-ui.sh. systemd-analyze is not available here, so the
unit files themselves are unverified by a parser.

* fix(systemd): actually wire the read-only guard, drop the superseded drop-in

Re-review of the previous head caught two leftovers from that commit:

- require_writable_update_paths was defined but never called, so the guard the
  unit comments, the commit message and the PR comment promise did not exist at
  all. It is now called at the top level, before install_base, i.e. before
  anything with a side effect: a sandboxed fallback run stops with one clear
  message instead of failing halfway, which on a relocated main folder meant the
  old install removed and the service folder rewritten before dying on /usr/bin.
- the drop-in this branch replaced (10-xui-write-paths.conf) is no longer written
  or referenced, but nothing removed it either. Whoever installed the build that
  wrote it keeps its wider list, including the writable service folder, until it
  is deleted by hand. Both generators now remove it.

Harness extended to 11 checks: the superseded file is gone after a run, the guard
is actually called, plus the previous nine (defaults, env-file relocation, same
list from update.sh, dedupe, seccomp at 249 / absent at 238, read-only guard) and
bash -n on the three scripts.

* fix(systemd): correct two comments and keep spaces out of the path list

Second-opinion review of the previous head (two models, both asked to state
platforms and versions) produced three actionable items: a wrong comment kept
from the earlier commits, a wrong generalisation about the filter groups, and a
path-list case that would leave the panel unable to start.

- the ProtectSystem= comment claimed strict leaves /var and /run writable. It
  does not: strict mounts the whole hierarchy read-only and only the kernel API
  filesystems stay as they are. The sentence was already wrong before this
  branch and moving it to ProtectSystem=full did not fix it.
- "the @-named filter groups need systemd >= 239" is the wrong generalisation:
  named groups exist since 231, it is @system-service that arrived in 239. The
  unit files, both script comments and the drop-in body now name the group.
- a folder containing whitespace (XUI_DB_FOLDER="/srv/panel data") was written
  into ReadWritePaths= verbatim. That directive is a whitespace-separated list,
  so the entry splits into "-/srv/panel" and "data", and systemd rejects the
  whole drop-in: the panel then does not start at all. Such folders are left
  out and reported to the operator instead; the other paths are still written.

Harness extended with three checks for the whitespace case (folder left out,
remaining paths intact, warning emitted) and the duplicate-store case now reads
its own env file instead of the previous one, so it tests what it claims.
14 checks plus bash -n on the three scripts, all passing.

* fix(systemd): act on the independent review of the drop-in generator

A read-only review of the branch head (another model, given the diff and the
sources, asked to cite only verified lines) confirmed the earlier work and
turned up four items that are fixed here:

- a folder name carrying a literal % went into ReadWritePaths= as it was, and
  systemd expands %-specifiers in unit files: with XUI_DB_FOLDER=/srv/x%-ui the
  entry no longer named the directory the panel writes to and the panel could
  not write its database. The path is now emitted as %%; the duplicate check
  keeps comparing the unescaped value.
- systemd older than 229/242/244 does not know NoNewPrivileges, ProtectClock,
  ProtectHostname and ProtectKernelLogs. It logs them and carries on, so
  CentOS 7 (systemd 219, which install.sh explicitly supports) runs with less
  hardening than the unit lists. install.sh and update.sh now print which
  protections need a newer systemd, which ones still apply, and that upgrading
  systemd is what changes it.
- the updater's writability guard asked [[ -w ]] about the parent directories.
  It creates and removes a probe file instead, so an immutable attribute or a
  full filesystem is caught as well (a read-only mount was already caught).
- the generator's comment claimed to resolve the folders the service actually
  uses, while the shipped unit hard-codes WorkingDirectory= and ExecStart= under
  /usr/local/x-ui. The comment now states what XUI_MAIN_FOLDER really feeds --
  the location install.sh/update.sh install into and the base for a relative
  XUI_BIN_FOLDER -- and that a relocated main folder needs the unit edited too.

Rejected from the same review, with the evidence: that [[ -w ]] cannot see a
read-only mount (access(W_OK)/faccessat consults __mnt_is_readonly before the
mode bits), and that the /etc ReadWritePaths entry is an exception granted for
/etc rather than a default store already in the list.

Harness extended: 19 checks (escaped %, the old-systemd note, whitespace and
duplicate folders, seccomp gating, the read-only guard) plus bash -n on the
three scripts, all passing.

* fix(systemd): name the hardening old systemd really ignores

The old-systemd note fired only below 239 and listed wrong versions:
RHEL 8 (239) and Debian 10 (241) silently lose ProtectHostname and
RestrictSUIDSGID (242), ProtectKernelLogs (244) and ProtectClock (245)
with no note, while CentOS 7 was told NoNewPrivileges (187) and
ProtectHome=read-only (214) were not applied although both are. The
note is now built from a directive/version table taken from
systemd.exec(5) and lists only what the running systemd lacks.

Also drop the removal of 10-xui-write-paths.conf: only an intermediate
commit of this branch wrote that file, no release ever shipped it.

---------

Co-authored-by: Кот <kot@zeroclaw.local>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-10-05 18:47:10 +02:00
Mr. Nickson a8d65a55b0 fix(update): run the database migration before starting the service (#6729)
update_x-ui() started x-ui.service and then called config_after_update right
away, which runs `x-ui setting -show true` and `x-ui migrate`. The service and
the CLI each run InitDB(), and with it every schema migration, on the same
database at the same time. On an upgrade that adds schema, the loser exits
with an error. Upgrading 3.8.5 to 3.9.0 stopped the service with
"duplicate column name: exclude_from_sub", and only Restart=on-failure
brought it back 5 s later.

Tolerating the duplicate-column error in the column helpers is not enough.
The same race also hits the tables new in 3.9.0: concurrent InitDB fails with
"table `node_pending_resets` already exists" and
"table `tuic_traffic_receipts` already exists". The cause is two processes
migrating at once, so the fix is to stop that from happening during update.

Run `x-ui migrate` to completion before the service is started, on both the
systemd and the OpenRC path. This mirrors install.sh, whose
config_after_install already migrates before the first start. The service and
the follow-up CLI calls then find the schema current, and their InitDB has
nothing to change.

Refs #6728
2026-10-05 16:48:50 +02:00
MHSanaei d7da64f2f0 fix(qr): hide the QR only for links carrying post-quantum keys
A share link's QR is suppressed only when it carries a post-quantum key
payload too large to scan: an ML-DSA-65 verify key (pqv) or an ML-KEM-768
VLESS-encryption auth key. isPostQuantumLink substring-matched "mlkem768"
anywhere in the URL, so it misfired on:

- every VLESS/Trojan REALITY link, since ce221c33 added the
  support-x25519mlkem768=true hint (235 chars, QR version 10);
- every VLESS-encryption link authenticated by an X25519 key, whose
  value always starts with mlkem768x25519plus (321 chars, version 11);
- any remark or host containing mlkem768 / mldsa65 / ML-KEM-768.

All four QR surfaces (inbound QR, client QR, client info, public sub
page) lost their QR button for those links. The detector now reads the
query: a non-empty pqv, or an encryption whose auth key
vlessEncryptionAuthKind classifies as ML-KEM-768.

Closes #6730
2026-10-05 16:30:14 +02:00
Yuri Khachaturyan 2c5fc8e72c fix(node): don't delete clients when a node reports an empty snapshot (#6734)
* fix(node): don't delete clients when a node reports an empty snapshot

A node snapshot that comes back with zero clients for an inbound the hub
still has clients on was treated as authoritative: SyncInbound strips
every link for that inbound, the orphan sweep marks the now-linkless
clients, and ReapSyncOrphans hard-deletes them once the grace period
elapses. But a zero-client snapshot is indistinguishable from a degraded
node — one that was just deleted, reset, restarted, or answered before
its config loaded. On 2026-10-04 this deleted clients across the whole
hub when a single node was removed.

Treat a zero-client snapshot for an inbound that still has clients as
non-authoritative: skip the link rebuild and the orphan sweep for that
inbound (the same handling a failed SyncInbound already gets) and wait
for a snapshot that carries clients. Removing a node's last client is
done from the hub (which updates links and pushes); a node still serving
other clients prunes a removed one through the existing partial path.

The two orphan tests that drove removal via an empty snapshot now drive
it via a partial snapshot (node alive, still serving another client),
the authoritative path. New tests in node_degraded_snapshot_test.go
cover the guard and its narrowness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(node): keep hub settings on an empty node snapshot; address review

The empty-snapshot guard skipped only the link rebuild and orphan sweep,
but Phase A had already adopted the node's `{"clients":[]}` blob into
`inbounds.settings`. Reconcile builds each push from that blob, so the hub
kept re-pushing an empty client list and a reset/restarted node never got
its clients back — the guard fired forever. Phase A now refuses to blank the
settings of an inbound the hub still populates, so the hub stays
authoritative and reconcile re-pushes the real clients (recovery). A
node-side removal of the last client is therefore hub-authoritative by
design; the partial-snapshot path still prunes an inbound that reports other
clients.

- test: assert the inbound's settings survive an empty snapshot (fails
  without the Phase A fix).
- drop TestSetRemoteTraffic_EmptySnapshotSurvivesReap (no branch the core
  test doesn't already cover) and the duplicate orphanMark helper
  (readOrphanMark already exists); trim the comment blocks to the 2-line
  CLAUDE.md limit.
- fix a pre-existing -race/-shuffle flake: TestGetAmneziaWGLogs owned no DB
  and relied on the ambient global one, which a sibling's dbtest cleanup
  closes under shuffle, panicking on nil in amneziawgLogActivity. It now
  owns a throwaway DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(node): re-push hub clients to a node that reports an empty snapshot

23c51074 stopped the empty snapshot from blanking inbounds.settings, but
nothing re-sent those settings: the node was never marked dirty, and the
reconcile fingerprint from the last good push still matched, so
ReconcileInbound skipped the inbound. A reset node stayed empty while the
hub kept handing out links to it. The ClientStats sweep also deleted the
node baseline on that tick, so traffic used until recovery was lost.

The empty snapshot is now classified once, before adoption: its settings
and wire fields are not adopted, the ClientStats sweep, link rebuild and
orphan sweep skip it, the node is marked dirty and the inbound's pushed
fingerprint is dropped (Remote.ForgetPushedInbound) so reconcile re-sends
the hub's clients.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: Yuriy Khachaturian <y.khachaturian@souzmult.ru>
2026-10-05 16:12:08 +02:00
Younes Beriane d4a7086c4e fix(inbounds): list clients in the detach/attach modals when they mount open (#6736)
* fix(inbounds): list clients in the detach modal when it mounts open

DetachClientsModal seeded its synced-source state with the open source,
so a modal first mounted with open and a source saw no change on its
first render and never read the client rows. The table stayed empty
until the modal was closed and reopened.

Seed the state with null so the first open render loads the rows, as
the reset-during-render check already expects. The test mounts the
modal already open and fails without this change.

Fixes #6733

* fix(inbounds): list clients in the attach modal when it mounts open

AttachClientsModal seeds its synced-source state the same way
DetachClientsModal did, so a modal first mounted with open and a source
saw no change on its first render and never read the client rows. The
table stayed empty, and nothing was pre-selected, until the modal was
closed and reopened.

Seed the state with null here too. The test mounts the attach modal
already open and fails without this change.

Refs #6733

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-10-05 15:27:56 +02:00
MHSanaei d1b60799ec fix(frontend): show toasts through message.useMessage, never the static API
Invariant: an antd toast lives inside the React tree of the component that
raised it, so it takes the theme from ConfigProvider and unmounts with it.

Four components called antd's static message.* (Happ settings, TUIC form,
routing tab, command palette); the other 40 use message.useMessage. A static
toast renders in a detached root that RTL's cleanup never unmounts, so its
rAF-driven close timer kept updating state for ~3s after a test file ended.
happ-routing-editor.test.tsx logged 419 act() warnings, 226 of them after
its last test; on a slow runner the worker closed first and vitest failed
the job with "Closing rpc while onUserConsoleLog was pending" (CI runs
37075337071, 37303631843). In the product the same toasts ignored the
dark/ultra theme.

The command palette keeps its holder mounted while closed: restartXray
closes the palette before its success toast. The Happ editor test now
asserts its toast unmounts with the component (red on the static API), and
no-static-message.test.ts fails on any static call in src. The spy that
silenced message.success in happ-settings-presets.test.tsx intercepted
nothing after the change and is gone.
2026-10-05 14:01:47 +02:00
MHSanaei 5819a01cdc fix(api-token): let a node-sync master reach every node endpoint it calls
Invariant: every request runtime.Remote sends to a node is accepted under
the node-sync scope, except /server/updatePanel, which #6201 withholds on
purpose.

The allowlist was written on 2026-08-15 and three Remote calls arrived
after it without being added: /clients/activeInbounds (#6164, same day),
/inbounds/:id/subSortIndex (#6179) and /clients/bulkResetTraffic
(4210a50c). A master enrolled with a node-sync token or mTLS got 403 on
each: the subscription sort order and multi-client traffic resets never
reached the node, and online attribution silently fell back to email-only
because FetchTrafficSnapshot logs that failure at debug level.

TestMasterNodeContract drives every exported Remote method through the
production router, once per enrollment scope, fails on any 401/403 the
node returns even when Remote swallows it, and fails when a Remote method
has no cell. It replaces TestNodeSyncScopeAllowlistMatchesRemoteInventory,
a hand-copied duplicate of the allowlist that never read Remote and so
missed all three; its updatePanel rule stays in
TestNodeSyncScopeUsesFullPathPatterns. It also supersedes
TestMasterPushWithAdminTokenAppliesClients (its UpdateInbound cell); that
file's removal landed in dae91276 by a staging slip.
2026-10-05 13:31:02 +02:00
MHSanaei dae91276aa chore(nodes): run the master+node scopes in parallel and document both layers
Each enrollment scope owns its panels, ports and temp dirs, so the two run
side by side; the only shared state, the process-global database handle the
harness borrows for setup and for simulating a lost inbound, is now behind a
mutex. On Linux the suite drops from 188s to 95s.

CLAUDE.md now names the fast contract layer (node_contract_test.go, in
make test-go) next to the multi-tick nodee2e layer.
2026-10-05 13:30:18 +02:00
MHSanaei f843fe5570 chore(nodes): add a master+node end-to-end harness and CI job
Node sync had no test with two real panels: the single-panel tests either
call the node's controller in-process or hand-set the node-sync scope, so
823db059 shipped a regression no test could see (an admin-token node
dropped every client and enable flag its master pushed).

internal/nodee2e starts a master and a node as separate panel processes
(the DB is a process-wide global, so one process cannot be both) and walks
17 operations per enrollment scope (admin token, node-sync token): adopt,
create/edit on the master, small and bulk attach, client disable, detach,
client delete, inbound disable, traffic pull and reset, node-side delete
mirrored centrally (#6219), master-side delete, node down, node-lost
inbound re-created, create/edit while down, node disabled on the master,
and selected sync mode leaving unselected inbounds alone.

Against the build before 2ffc694a it fails 6 cells for an admin-token node
and 1 for a node-sync one; on main all 34 pass, on Windows and on Linux.
`make node-e2e` builds a stub-dist binary and runs it; ci.yml runs it as
its own job where a SKIP fails the build. Xray is not started, so cells
assert the node's stored state, not traffic through the core.
2026-10-05 13:14:30 +02:00
MHSanaei 2ffc694a6d fix(nodes): let a master's push and reconcile converge node inbounds
Invariant: every inbound the master holds for a node, with its clients and
enable flag, converges onto that node at the next push or reconcile.

823db059 made an inbound save keep the stored clients and enable unless the
request is a master push, recognised only by a node-sync token scope. Nodes
are usually enrolled with an admin token (the -getApiToken and install
default), so their nodes treated every master push as a stale form save:
clients attached on the master (Attach existing clients, bulk attach above
the per-client threshold, any dirty-node reconcile) never reached the node,
yet the push was recorded as successful so reconcile stopped retrying.
Remote now marks every request with X-3x-Master-Push, and the node honours
it like the node-sync scope. The browser form never sends it, so the
stale-form protection for panel saves and plain API scripts is unchanged.

Separately, an inbound deleted on the node kept its tag->id in the
master's cache, so reconcile sent update/<deleted id> forever instead of
re-creating it. When the node reports no form of the tag, ReconcileInbound
now drops the cached id so the resolve re-reads the node and falls back to
add. Two runtime tests pinned the old update-to-cached-id path for an
absent inbound; they now count the add, or report the inbound present.
2026-10-05 12:17:59 +02:00
MHSanaei 815c9c5772 fix(tuic): accept the server's STOP_SENDING when tests close uni streams
The race job failed in TestAudit3ManagerEnsureActualSendersWithPersistentTraffic
with "close called for canceled stream 14". The server parses one command
per uni stream and then calls CancelRead, as the quinn reference server does
on drop, so its STOP_SENDING can reach the client before the client's own
Close and quic-go reports that Close as an error. The data was already read.

Every test that wrote a command on a uni stream and required Close to
succeed shared this race. closeUniStream accepts only a remote StreamError
on the stream's context, so any other Close failure still fails the test.
2026-10-03 15:48:05 +02:00
MHSanaei 05eb06f333 fix(tuic): wait for both traffic counters in the relay E2E tests
The race job failed on TestServerUDPDatagramE2E with Up:0 Down:1300.
BytesUp is added on the sending goroutine after the relay Send returns,
while BytesDown is added on the response goroutine, so the mock echo can
be counted and delivered before the upload is. The test drained the
counters once right after the reply and assumed both were present.

Production is unaffected: deltas left for the next collection window are
still summed. The TCP E2E test made the same assumption, so both now
accumulate drained deltas until up and down reach the payload size.
2026-10-03 13:43:06 +02:00
56 changed files with 2686 additions and 161 deletions
+16
View File
@@ -141,6 +141,22 @@ jobs:
# internal/web/service runs ~10x slower under -race and overruns the 10m default. # internal/web/service runs ~10x slower under -race and overruns the 10m default.
go test -race -shuffle=on -count=1 -timeout 25m $(cat /tmp/go-packages.txt) go test -race -shuffle=on -count=1 -timeout 25m $(cat /tmp/go-packages.txt)
# A real master and node panel, each its own process, driven through node sync.
# A SKIP here means the binary was never handed over, so it fails the job.
node-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Master + node end to end
run: |
set -o pipefail
make node-e2e 2>&1 | tee /tmp/node-e2e.log
if grep -q -- '--- SKIP' /tmp/node-e2e.log; then echo "node-e2e skipped"; exit 1; fi
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the # Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug. # generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
fuzz-smoke: fuzz-smoke:
+10 -5
View File
@@ -160,9 +160,10 @@ file locations when it can answer in one hop.
`-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or `-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or
migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template. migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template.
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`. - Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`, - Postgres, xray-gRPC-e2e, master+node and scale tests `t.Skip` unless
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a `XUI_TEST_PG_DSN`, `XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY`,
green `go test ./...` does not mean those paths ran. `XUI_NODE_E2E_BINARY` or `XUI_SCALE_TEST` is set — a green `go test ./...`
does not mean those paths ran.
## Frontend conventions (summary; full version in frontend/CLAUDE.md) ## Frontend conventions (summary; full version in frontend/CLAUDE.md)
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites. - Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
@@ -191,9 +192,13 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
make verify # gen-check + lint + typecheck + test + build + build-storybook make verify # gen-check + lint + typecheck + test + build + build-storybook
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`, That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a `make vulncheck`, a live-Postgres job (where a SKIP counts as a failure),
`make node-e2e` (a real master and node panel, `internal/nodee2e/`) and a
30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when 30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when
you touch DB/dialect or parser code. you touch DB/dialect, node sync or parser code. Node sync has two layers: every
`runtime.Remote` call gets a cell in `internal/web/node_contract_test.go` (fast,
in `make test-go`; a method without one fails it), and a multi-tick flow (cron,
adopt, node down) gets one in `internal/nodee2e/node_sync_test.go`.
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend), Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`. `make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
+7
View File
@@ -58,6 +58,13 @@ test-go: dist-stub ## Go tests (shuffle, no cache)
race: dist-stub ## Go tests with the race detector (needs a C compiler) race: dist-stub ## Go tests with the race detector (needs a C compiler)
go test -race -shuffle=on -count=1 -timeout 25m $(GO_PKGS) go test -race -shuffle=on -count=1 -timeout 25m $(GO_PKGS)
.PHONY: node-e2e
# Two real panel processes (master + node); test-go only runs nodee2e as a skip.
NODE_E2E_BIN = $(CURDIR)/.cache/node-e2e/x-ui$(shell go env GOEXE)
node-e2e: dist-stub ## Master+node sync end to end with two real panel processes
go build -o $(NODE_E2E_BIN) .
XUI_NODE_E2E_BINARY=$(NODE_E2E_BIN) go test -count=1 -timeout 20m -v ./internal/nodee2e/
.PHONY: test-fe .PHONY: test-fe
test-fe: ## Frontend tests (vitest) test-fe: ## Frontend tests (vitest)
cd $(FRONTEND) && npm test cd $(FRONTEND) && npm test
+1 -1
View File
@@ -29,7 +29,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN. - **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
- **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY. - **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
- **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install. - **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install.
- **TUIC v5 sidecar** — High-performance QUIC-based proxy with native UDP relay traffic metering, 0-RTT handshakes, and BBR congestion control. - **Native TUIC v5 server** — In-process Go QUIC server with Xray routing and per-client traffic accounting; BBR and New Reno are available server-side. CUBIC is preserved in the client profile but currently falls back to New Reno on the server.
- **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections. - **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections.
- **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support. - **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
- **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions. - **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions.
+1 -1
View File
@@ -29,7 +29,7 @@
- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN. - **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY. - **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
- **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов. - **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов.
- **Встроенный TUIC v5** — высокопроизводительный прокси на базе QUIC с нативным учётом трафика через UDP-релей, 0-RTT рукопожатиями и контролем перегрузок BBR. - **Нативный TUIC v5** — Go QUIC-сервер работает внутри процесса панели. Трафик маршрутизируется через Xray, а учёт ведётся по клиентам. На сервере доступны BBR и New Reno; CUBIC сохраняется в профиле клиента, но на сервере пока использует New Reno.
- **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения. - **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения.
- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray. - **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик. - **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
+6 -4
View File
@@ -15,16 +15,16 @@ unstable networks.
## Key settings ## Key settings
### Server & QUIC parameters ### Server, QUIC & client-profile parameters
| Field | Description | | Field | Description |
| --- | --- | | --- | --- |
| **Port** | UDP port for incoming client QUIC connections. | | **Port** | UDP port for incoming client QUIC connections. |
| **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. | | **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. |
| **SNI** | Server Name Indication matching your TLS certificate domain name. | | **SNI** | Client-profile Server Name Indication. Set it to the domain covered by the server certificate; this field does not configure the listener certificate. |
| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. The server runs `bbr` or `new_reno`; `cubic` is sent to clients but served as `new_reno`. | | **Congestion Control** | QUIC congestion control algorithm used in the server setting and exported client profile: `bbr`, `cubic`, or `new_reno`. The server runs BBR or New Reno; when CUBIC is selected, the client profile keeps CUBIC while this server currently falls back to New Reno. |
| **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). | | **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. | | **UDP Relay Mode** | Client-profile packet mode: `native` (QUIC datagrams) or `quic` (unidirectional streams). The server accepts both modes regardless of this exported preference. |
| **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. | | **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. |
| **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). | | **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). |
| **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). | | **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). |
@@ -105,6 +105,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain. - **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.). - **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client. - **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions. - **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node. - **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
</Callout> </Callout>
+6 -4
View File
@@ -14,16 +14,16 @@ icon: Zap
## Ключевые параметры ## Ключевые параметры
### Параметры сервера и QUIC ### Параметры сервера, QUIC и клиентского профиля
| Поле | Описание | | Поле | Описание |
| --- | --- | | --- | --- |
| **Порт** | UDP-порт для входящих QUIC-соединений клиентов. | | **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
| **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. | | **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. | | **SNI** | Server Name Indication для профиля клиента. Укажите домен, покрытый сертификатом сервера; это поле не настраивает сертификат listener'а. |
| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. Сервер работает с `bbr` или `new_reno`; `cubic` передаётся клиентам, но на сервере применяется как `new_reno`. | | **Контроль перегрузок** | Алгоритм QUIC в настройках сервера и экспортируемом профиле: `bbr`, `cubic` или `new_reno`. Сервер использует BBR или New Reno; при выборе CUBIC клиентский профиль сохраняет CUBIC, а сервер пока применяет New Reno. |
| **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). | | **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. | | **Режим UDP Relay** | Режим UDP в профиле клиента: `native` (QUIC datagrams) или `quic` (однонаправленные потоки). Сервер принимает оба режима независимо от этого значения. |
| **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. | | **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
| **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). | | **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
| **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). | | **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
@@ -104,6 +104,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется. - **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.). - **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента. - **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей. - **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет. - **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
</Callout> </Callout>
+25 -8
View File
@@ -16027,15 +16027,9 @@
}, },
{ {
"type": "traffic", "type": "traffic",
"summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.", "summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
"payloadSchema": { "payloadSchema": {
"type": "object", "type": "object",
"required": [
"onlineClients",
"onlineByGuid",
"activeInbounds",
"lastOnlineMap"
],
"properties": { "properties": {
"traffics": { "traffics": {
"type": "array", "type": "array",
@@ -16049,6 +16043,18 @@
"$ref": "#/components/schemas/ClientTraffic" "$ref": "#/components/schemas/ClientTraffic"
} }
}, },
"clientTrafficSource": {
"type": "string",
"enum": [
"xray",
"tuic"
],
"description": "Present for native TUIC samples; omitted Xray samples default to xray."
},
"clientTrafficIntervalMs": {
"type": "integer",
"description": "Sampling interval used to calculate client speed, in milliseconds."
},
"nodeTraffics": { "nodeTraffics": {
"type": "array", "type": "array",
"nullable": true, "nullable": true,
@@ -16092,13 +16098,24 @@
{ {
"required": [ "required": [
"traffics", "traffics",
"clientTraffics" "clientTraffics",
"onlineClients",
"onlineByGuid",
"activeInbounds",
"lastOnlineMap"
] ]
}, },
{ {
"required": [ "required": [
"nodeTraffics" "nodeTraffics"
] ]
},
{
"required": [
"clientTraffics",
"clientTrafficSource",
"clientTrafficIntervalMs"
]
} }
] ]
}, },
+25 -8
View File
@@ -16027,15 +16027,9 @@
}, },
{ {
"type": "traffic", "type": "traffic",
"summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.", "summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
"payloadSchema": { "payloadSchema": {
"type": "object", "type": "object",
"required": [
"onlineClients",
"onlineByGuid",
"activeInbounds",
"lastOnlineMap"
],
"properties": { "properties": {
"traffics": { "traffics": {
"type": "array", "type": "array",
@@ -16049,6 +16043,18 @@
"$ref": "#/components/schemas/ClientTraffic" "$ref": "#/components/schemas/ClientTraffic"
} }
}, },
"clientTrafficSource": {
"type": "string",
"enum": [
"xray",
"tuic"
],
"description": "Present for native TUIC samples; omitted Xray samples default to xray."
},
"clientTrafficIntervalMs": {
"type": "integer",
"description": "Sampling interval used to calculate client speed, in milliseconds."
},
"nodeTraffics": { "nodeTraffics": {
"type": "array", "type": "array",
"nullable": true, "nullable": true,
@@ -16092,13 +16098,24 @@
{ {
"required": [ "required": [
"traffics", "traffics",
"clientTraffics" "clientTraffics",
"onlineClients",
"onlineByGuid",
"activeInbounds",
"lastOnlineMap"
] ]
}, },
{ {
"required": [ "required": [
"nodeTraffics" "nodeTraffics"
] ]
},
{
"required": [
"clientTraffics",
"clientTrafficSource",
"clientTrafficIntervalMs"
]
} }
] ]
}, },
@@ -64,6 +64,7 @@ interface PaletteItem {
export default function CommandPalette() { export default function CommandPalette() {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const navigate = useNavigate(); const navigate = useNavigate();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const { isOpen, close } = useCommandPalette(); const { isOpen, close } = useCommandPalette();
@@ -194,14 +195,14 @@ export default function CommandPalette() {
const copySubscription = useCallback( const copySubscription = useCallback(
async (client: ClientRecord) => { async (client: ClientRecord) => {
if (!client.subId || !allSetting.subURI) { if (!client.subId || !allSetting.subURI) {
message.warning(t('pages.clients.noSubId')); messageApi.warning(t('pages.clients.noSubId'));
return; return;
} }
const link = `${allSetting.subURI}${client.subId}`; const link = `${allSetting.subURI}${client.subId}`;
const ok = await ClipboardManager.copyText(link); const ok = await ClipboardManager.copyText(link);
if (ok) message.success(t('copied')); if (ok) messageApi.success(t('copied'));
}, },
[allSetting.subURI, t], [allSetting.subURI, messageApi, t],
); );
const restartXray = useCallback(async () => { const restartXray = useCallback(async () => {
@@ -210,9 +211,9 @@ export default function CommandPalette() {
silentSuccess: true, silentSuccess: true,
}); });
if (msg?.success) { if (msg?.success) {
message.success(t('commandPalette.restartXraySuccess')); messageApi.success(t('commandPalette.restartXraySuccess'));
} }
}, [close, t]); }, [close, messageApi, t]);
const cycleTheme = useCallback(() => { const cycleTheme = useCallback(() => {
if (!isDark) { if (!isDark) {
@@ -679,13 +680,15 @@ export default function CommandPalette() {
} }
}; };
if (!isOpen) return null; // Kept mounted while closed: restartXray closes the palette before its toast.
if (!isOpen) return messageContextHolder;
let lastCategory = ''; let lastCategory = '';
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light'; const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<div <div
className={`command-palette-backdrop ${themeModeClass}`} className={`command-palette-backdrop ${themeModeClass}`}
role="presentation" role="presentation"
+60 -4
View File
@@ -98,6 +98,8 @@ export interface ClientSpeedEntry {
down: number; down: number;
} }
type ClientSpeedSource = 'xray' | 'tuic';
type ClientStatRow = ClientTraffic & { email?: string }; type ClientStatRow = ClientTraffic & { email?: string };
export function sameSpeedMap( export function sameSpeedMap(
@@ -299,7 +301,31 @@ export function useClients(options: UseClientsOptions = {}) {
// settings request still lets the page fall back and render. // settings request still lets the page fall back and render.
const settingsReady = defaultsQuery.isFetched; const settingsReady = defaultsQuery.isFetched;
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({}); const [clientSpeedBySource, setClientSpeedBySource] = useState<
Partial<Record<ClientSpeedSource, Record<string, ClientSpeedEntry>>>
>({});
const clientSpeedExpiryTimers = useRef<Partial<Record<ClientSpeedSource, number>>>({});
const clientSpeedSourceVersions = useRef<Record<ClientSpeedSource, number>>({ xray: 0, tuic: 0 });
const clientSpeed = useMemo(() => {
const combined: Record<string, ClientSpeedEntry> = {};
for (const source of Object.values(clientSpeedBySource)) {
if (!source) continue;
for (const [email, speed] of Object.entries(source)) {
const current = combined[email] ?? { up: 0, down: 0 };
combined[email] = { up: current.up + speed.up, down: current.down + speed.down };
}
}
return combined;
}, [clientSpeedBySource]);
useEffect(
() => () => {
for (const timer of Object.values(clientSpeedExpiryTimers.current)) {
if (timer !== undefined) window.clearTimeout(timer);
}
},
[],
);
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY; const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
const invalidateAll = useCallback(() => { const invalidateAll = useCallback(() => {
@@ -725,6 +751,8 @@ export function useClients(options: UseClientsOptions = {}) {
const p = payload as { const p = payload as {
onlineClients?: string[]; onlineClients?: string[];
clientTraffics?: { email: string; up: number; down: number }[]; clientTraffics?: { email: string; up: number; down: number }[];
clientTrafficSource?: 'xray' | 'tuic';
clientTrafficIntervalMs?: number;
}; };
if (Array.isArray(p.onlineClients)) { if (Array.isArray(p.onlineClients)) {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients); queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
@@ -736,17 +764,45 @@ export function useClients(options: UseClientsOptions = {}) {
// dropped and an unchanged result returns the previous object — which lets // dropped and an unchanged result returns the previous object — which lets
// React bail out of the update instead of re-rendering the table. // React bail out of the update instead of re-rendering the table.
const next: Record<string, ClientSpeedEntry> = {}; const next: Record<string, ClientSpeedEntry> = {};
const source = p.clientTrafficSource === 'tuic' ? 'tuic' : 'xray';
const sampleIntervalMs =
typeof p.clientTrafficIntervalMs === 'number' &&
Number.isFinite(p.clientTrafficIntervalMs) &&
p.clientTrafficIntervalMs > 0
? p.clientTrafficIntervalMs
: TRAFFIC_POLL_INTERVAL_S * 1000;
const sampleIntervalSeconds = sampleIntervalMs / 1000;
for (const ct of p.clientTraffics) { for (const ct of p.clientTraffics) {
if (!ct || !ct.email) continue; if (!ct || !ct.email) continue;
const up = ct.up || 0; const up = ct.up || 0;
const down = ct.down || 0; const down = ct.down || 0;
if (up === 0 && down === 0) continue; if (up === 0 && down === 0) continue;
const current = next[ct.email] ?? { up: 0, down: 0 };
next[ct.email] = { next[ct.email] = {
up: up / TRAFFIC_POLL_INTERVAL_S, up: current.up + up / sampleIntervalSeconds,
down: down / TRAFFIC_POLL_INTERVAL_S, down: current.down + down / sampleIntervalSeconds,
}; };
} }
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next)); setClientSpeedBySource((prev) =>
sameSpeedMap(prev[source] ?? {}, next) ? prev : { ...prev, [source]: next },
);
const version = ++clientSpeedSourceVersions.current[source];
const previousTimer = clientSpeedExpiryTimers.current[source];
if (previousTimer !== undefined) window.clearTimeout(previousTimer);
clientSpeedExpiryTimers.current[source] = window.setTimeout(
() => {
if (clientSpeedSourceVersions.current[source] !== version) return;
delete clientSpeedExpiryTimers.current[source];
setClientSpeedBySource((prev) => {
if (!prev[source]) return prev;
const nextSources = { ...prev };
delete nextSources[source];
return nextSources;
});
},
Math.min(sampleIntervalMs * 2, 120_000),
);
} }
}, },
[queryClient], [queryClient],
+9 -4
View File
@@ -17,6 +17,7 @@ import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { getHeaderValue } from './headers'; import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities'; import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x'; import { deriveSpiderX } from './spider-x';
import { vlessEncryptionAuthKind } from './vless-encryption';
import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic'; import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
// Share-link generators. Each per-protocol fn takes a typed inbound plus // Share-link generators. Each per-protocol fn takes a typed inbound plus
@@ -1723,9 +1724,13 @@ function wgPeerCommentSuffix(peer: unknown): string {
return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : ''; return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
} }
// Only the post-quantum key payloads outgrow a QR; the REALITY ML-KEM hint and the
// mlkem768x25519plus prefix of an X25519-authenticated encryption do not (#6730).
export function isPostQuantumLink(link: string): boolean { export function isPostQuantumLink(link: string): boolean {
if (/[?&]pqv=/.test(link)) return true; const withoutRemark = link.split('#', 1)[0];
if (link.includes('mlkem768') || link.includes('mldsa65')) return true; const queryStart = withoutRemark.indexOf('?');
if (link.includes('ML-KEM-768')) return true; if (queryStart < 0) return false;
return false; const params = new URLSearchParams(withoutRemark.slice(queryStart + 1));
if (params.get('pqv')) return true;
return vlessEncryptionAuthKind(params.get('encryption') ?? '')?.startsWith('mlkem768') ?? false;
} }
@@ -120,10 +120,18 @@ const statusPayloadSchema = {
const trafficPayloadSchema = { const trafficPayloadSchema = {
type: 'object', type: 'object',
required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'],
properties: { properties: {
traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } }, traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } }, clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
clientTrafficSource: {
type: 'string',
enum: ['xray', 'tuic'],
description: 'Present for native TUIC samples; omitted Xray samples default to xray.',
},
clientTrafficIntervalMs: {
type: 'integer',
description: 'Sampling interval used to calculate client speed, in milliseconds.',
},
nodeTraffics: { nodeTraffics: {
type: 'array', type: 'array',
nullable: true, nullable: true,
@@ -134,7 +142,20 @@ const trafficPayloadSchema = {
activeInbounds: stringArrayMap, activeInbounds: stringArrayMap,
lastOnlineMap: timestampMap, lastOnlineMap: timestampMap,
}, },
oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }], oneOf: [
{
required: [
'traffics',
'clientTraffics',
'onlineClients',
'onlineByGuid',
'activeInbounds',
'lastOnlineMap',
],
},
{ required: ['nodeTraffics'] },
{ required: ['clientTraffics', 'clientTrafficSource', 'clientTrafficIntervalMs'] },
],
}; };
const clientStatsPayloadSchema = { const clientStatsPayloadSchema = {
@@ -205,7 +226,7 @@ export function buildWebSocketEvents(
{ {
type: 'traffic', type: 'traffic',
summary: summary:
'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.', 'Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.',
payloadSchema: trafficPayloadSchema, payloadSchema: trafficPayloadSchema,
example: { example: {
type: 'traffic', type: 'traffic',
@@ -60,7 +60,7 @@ export default function AttachClientsModal({
// React resets this during render rather than in an effect so the modal's // React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields. // first open frame already shows cleared fields.
const openSource = open ? source : null; const openSource = open ? source : null;
const [syncedSource, setSyncedSource] = useState(openSource); const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
if (openSource !== syncedSource) { if (openSource !== syncedSource) {
setSyncedSource(openSource); setSyncedSource(openSource);
if (openSource) { if (openSource) {
@@ -54,7 +54,7 @@ export default function DetachClientsModal({
// Reset during render, not in an effect, so the first frame is already clean. // Reset during render, not in an effect, so the first frame is already clean.
const openSource = open ? source : null; const openSource = open ? source : null;
const [syncedSource, setSyncedSource] = useState(openSource); const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
if (openSource !== syncedSource) { if (openSource !== syncedSource) {
setSyncedSource(openSource); setSyncedSource(openSource);
if (openSource) { if (openSource) {
@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
export default function TuicFields() { export default function TuicFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const { control, setValue } = useFormContext(); const { control, setValue } = useFormContext();
const [loadingPanelCert, setLoadingPanelCert] = useState(false); const [loadingPanelCert, setLoadingPanelCert] = useState(false);
@@ -36,7 +37,7 @@ export default function TuicFields() {
const autofillFromSni = () => { const autofillFromSni = () => {
const cleanSni = (sni || '').trim(); const cleanSni = (sni || '').trim();
if (!cleanSni) { if (!cleanSni) {
message.warning(t('pages.xray.tuic.sniRequired')); messageApi.warning(t('pages.xray.tuic.sniRequired'));
return; return;
} }
setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`); setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
@@ -51,12 +52,12 @@ export default function TuicFields() {
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true }) ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true }); : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) { if (!msg?.success) {
message.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty')); messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
return; return;
} }
const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
if (!obj?.webCertFile && !obj?.webKeyFile) { if (!obj?.webCertFile && !obj?.webKeyFile) {
message.warning(t('pages.inbounds.setDefaultCertEmpty')); messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
return; return;
} }
if (obj.webCertFile) { if (obj.webCertFile) {
@@ -65,9 +66,9 @@ export default function TuicFields() {
if (obj.webKeyFile) { if (obj.webKeyFile) {
setValue('settings.server.private_key', obj.webKeyFile); setValue('settings.server.private_key', obj.webKeyFile);
} }
message.success(t('pages.inbounds.setSuccess')); messageApi.success(t('pages.inbounds.setSuccess'));
} catch { } catch {
message.error(t('somethingWentWrong')); messageApi.error(t('somethingWentWrong'));
} finally { } finally {
setLoadingPanelCert(false); setLoadingPanelCert(false);
} }
@@ -145,6 +146,7 @@ export default function TuicFields() {
return ( return (
<> <>
{messageContextHolder}
<Form.Item label={t('pages.inbounds.publicKey')}> <Form.Item label={t('pages.inbounds.publicKey')}>
<AutoComplete <AutoComplete
value={certificate} value={certificate}
@@ -164,7 +166,7 @@ export default function TuicFields() {
</Form.Item> </Form.Item>
<Form.Item label=" "> <Form.Item label=" ">
<Space> <Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
<Button <Button
type="primary" type="primary"
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
@@ -19,6 +19,8 @@
.actions-row { .actions-row {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
@@ -25,6 +25,8 @@
.actions-row { .actions-row {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
@@ -28,6 +28,7 @@ export default function HappSettingsContent({
remoteSourceBadge, remoteSourceBadge,
}: HappSettingsContentProps) { }: HappSettingsContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
// Generator choices stay local until Apply updates the draft; page Save persists it. // Generator choices stay local until Apply updates the draft; page Save persists it.
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass'); const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
const [includeAdblock, setIncludeAdblock] = useState(false); const [includeAdblock, setIncludeAdblock] = useState(false);
@@ -37,18 +38,19 @@ export default function HappSettingsContent({
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock); const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
if (payload) { if (payload) {
updateSetting({ subRoutingRules: payload }); updateSetting({ subRoutingRules: payload });
message.success(t('pages.settings.subHappPresetApplied')); messageApi.success(t('pages.settings.subHappPresetApplied'));
} }
}; };
const handleBuildDeeplink = (deeplink: string) => { const handleBuildDeeplink = (deeplink: string) => {
updateSetting({ subRoutingRules: deeplink }); updateSetting({ subRoutingRules: deeplink });
setIsModalOpen(false); setIsModalOpen(false);
message.success(t('pages.settings.subHappDeeplinkGenerated')); messageApi.success(t('pages.settings.subHappDeeplinkGenerated'));
}; };
return ( return (
<> <>
{messageContextHolder}
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.subHappAutoDetect')} title={t('pages.settings.subHappAutoDetect')}
@@ -45,6 +45,7 @@ export default function RoutingTab({
isMobile, isMobile,
}: RoutingTabProps) { }: RoutingTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [ruleModalOpen, setRuleModalOpen] = useState(false); const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null); const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
@@ -179,7 +180,7 @@ export default function RoutingTab({
try { try {
parsed = JSON.parse(value); parsed = JSON.parse(value);
} catch { } catch {
message.error(t('pages.xray.importInvalidJson')); messageApi.error(t('pages.xray.importInvalidJson'));
return; return;
} }
const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } }; const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
@@ -191,7 +192,7 @@ export default function RoutingTab({
? obj.routing!.rules ? obj.routing!.rules
: null; : null;
if (!list) { if (!list) {
message.error(t('pages.xray.importInvalidJson')); messageApi.error(t('pages.xray.importInvalidJson'));
return; return;
} }
mutate((tt) => { mutate((tt) => {
@@ -347,6 +348,7 @@ export default function RoutingTab({
return ( return (
<> <>
{modalContextHolder} {modalContextHolder}
{messageContextHolder}
<Tabs <Tabs
defaultActiveKey="basic" defaultActiveKey="basic"
items={[ items={[
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import AttachClientsModal from '@/pages/inbounds/clients/AttachClientsModal';
import { DBInbound } from '@/models/dbinbound';
import { renderWithProviders } from './test-utils';
function sourceInbound() {
return new DBInbound({
id: 7,
port: 443,
listen: '',
protocol: 'vless',
remark: 'edge',
enable: true,
settings: JSON.stringify({
clients: [
{ id: 'uuid-1', email: 'alice@test' },
{ id: 'uuid-2', email: 'bob@test' },
],
decryption: 'none',
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: '',
});
}
describe('AttachClientsModal', () => {
it('lists the source clients, selected, when first mounted already open', async () => {
renderWithProviders(
<AttachClientsModal open source={sourceInbound()} dbInbounds={[]} onClose={() => {}} />,
);
expect(await screen.findByText('alice@test')).toBeTruthy();
expect(screen.getByText('bob@test')).toBeTruthy();
const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
const rowBoxes = boxes.slice(1);
expect(rowBoxes).toHaveLength(2);
expect(rowBoxes.every((b) => b.checked)).toBe(true);
});
});
@@ -115,4 +115,71 @@ describe('client summary always reflects the server, never a client_stats recomp
expect(result.current.summary).toEqual(serverSummary); expect(result.current.summary).toEqual(serverSummary);
}); });
it('combines independently sampled TUIC and Xray speeds and replaces each source snapshot', async () => {
const result = await loadedHook();
act(() => {
result.current.applyTrafficEvent({
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 150 }],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 20, down: 30 });
act(() => {
result.current.applyTrafficEvent({
clientTrafficSource: 'tuic',
clientTrafficIntervalMs: 10_000,
clientTraffics: [
{ email: 'shared@example.test', up: 300, down: 100 },
{ email: 'shared@example.test', up: 100, down: 100 },
],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 60, down: 50 });
act(() => {
result.current.applyTrafficEvent({
clientTraffics: [{ email: 'shared@example.test', up: 50, down: 25 }],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 50, down: 25 });
act(() => {
result.current.applyTrafficEvent({
clientTrafficSource: 'tuic',
clientTrafficIntervalMs: 10_000,
clientTraffics: [],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 10, down: 5 });
});
it('expires stale per-source speeds without clearing the other source', async () => {
const result = await loadedHook();
vi.useFakeTimers();
try {
act(() => {
result.current.applyTrafficEvent({
clientTrafficSource: 'xray',
clientTrafficIntervalMs: 1_000,
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 200 }],
});
result.current.applyTrafficEvent({
clientTrafficSource: 'tuic',
clientTrafficIntervalMs: 2_000,
clientTraffics: [{ email: 'shared@example.test', up: 300, down: 400 }],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 250, down: 400 });
act(() => vi.advanceTimersByTime(2_000));
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 150, down: 200 });
act(() => vi.advanceTimersByTime(2_000));
expect(result.current.clientSpeed).toEqual({});
} finally {
vi.useRealTimers();
}
});
}); });
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import DetachClientsModal from '@/pages/inbounds/clients/DetachClientsModal';
import { DBInbound } from '@/models/dbinbound';
import { renderWithProviders } from './test-utils';
function sourceInbound() {
return new DBInbound({
id: 7,
port: 443,
listen: '',
protocol: 'vless',
remark: 'edge',
enable: true,
settings: JSON.stringify({
clients: [
{ id: 'uuid-1', email: 'alice@test' },
{ id: 'uuid-2', email: 'bob@test' },
],
decryption: 'none',
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: '',
});
}
describe('DetachClientsModal', () => {
it('lists the attached clients when first mounted already open', async () => {
renderWithProviders(<DetachClientsModal open source={sourceInbound()} onClose={() => {}} />);
expect(await screen.findByText('alice@test')).toBeTruthy();
expect(screen.getByText('bob@test')).toBeTruthy();
});
});
+16 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, screen, within } from '@testing-library/react'; import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import { AllSetting } from '@/models/setting'; import { AllSetting } from '@/models/setting';
@@ -319,4 +319,19 @@ describe('Happ routing editor', () => {
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' })); fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
expect(generatedProfile()).toEqual(minimal); expect(generatedProfile()).toEqual(minimal);
}); });
// The static message API outlived the test file and logged act() warnings
// after teardown, failing CI with "Closing rpc while onUserConsoleLog was pending".
it('takes its toast down with it when unmounted', async () => {
renderSettings();
openEditor();
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
await screen.findByText('Deeplink generated and applied to routing rules');
cleanup();
expect(document.body.textContent).not.toContain(
'Deeplink generated and applied to routing rules',
);
});
}); });
@@ -1,15 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react'; import { fireEvent, screen } from '@testing-library/react';
import { message } from 'antd';
import { AllSetting } from '@/models/setting'; import { AllSetting } from '@/models/setting';
import HappSettingsContent from '@/pages/settings/HappSettingsContent'; import HappSettingsContent from '@/pages/settings/HappSettingsContent';
import { renderWithProviders } from './test-utils'; import { renderWithProviders } from './test-utils';
vi.spyOn(message, 'success').mockImplementation(() => undefined as never);
const chinaProfile = { const chinaProfile = {
Name: 'Bypass-CN', Name: 'Bypass-CN',
GlobalProxy: 'true', GlobalProxy: 'true',
+44
View File
@@ -16,6 +16,7 @@ import {
genVmessLink, genVmessLink,
genWireguardConfig, genWireguardConfig,
genWireguardLink, genWireguardLink,
isPostQuantumLink,
preferPublicHost, preferPublicHost,
resolveAddr, resolveAddr,
} from '@/lib/xray/inbound-link'; } from '@/lib/xray/inbound-link';
@@ -1415,3 +1416,46 @@ describe('genTuicLink', () => {
expect(link).not.toContain('#TUIC-Node-US-US'); expect(link).not.toContain('#TUIC-Node-US-US');
}); });
}); });
describe('isPostQuantumLink', () => {
type RealityFixture = {
settings: { clients: Array<{ id: string }>; encryption?: string };
streamSettings: { realitySettings: { settings: { mldsa65Verify?: string } } };
};
const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
const clientId = (raw as RealityFixture).settings.clients[0].id;
const x25519Key = 'G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
const mlkem768Key = 'A'.repeat(1579);
function realityLink(edit: (inbound: RealityFixture) => void = () => {}): string {
const copy = structuredClone(raw) as RealityFixture;
edit(copy);
return genVlessLink({ inbound: InboundSchema.parse(copy), address: 'example.test', clientId });
}
// #6730: the REALITY ML-KEM support hint is a short flag, not a large PQ payload.
it('keeps the QR for a plain REALITY link', () => {
expect(isPostQuantumLink(realityLink())).toBe(false);
});
it('keeps the QR for VLESS encryption authenticated by an X25519 key', () => {
const link = realityLink((ib) => {
ib.settings.encryption = `mlkem768x25519plus.native.0rtt.${x25519Key}`;
});
expect(isPostQuantumLink(link)).toBe(false);
});
it('hides the QR for VLESS encryption authenticated by an ML-KEM-768 key', () => {
const link = realityLink((ib) => {
ib.settings.encryption = `mlkem768x25519plus.native.0rtt.${mlkem768Key}`;
});
expect(isPostQuantumLink(link)).toBe(true);
});
it('hides the QR for a REALITY link carrying an ML-DSA-65 verify key', () => {
const link = realityLink((ib) => {
ib.streamSettings.realitySettings.settings.mldsa65Verify = 'B'.repeat(2603);
});
expect(isPostQuantumLink(link)).toBe(true);
});
});
@@ -0,0 +1,31 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, relative, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const srcRoot = resolve(fileURLToPath(import.meta.url), '../..');
const staticCall = /(?<![\w.$])message\.(success|error|warning|info|loading|open)\(/;
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return name === 'test' ? [] : sourceFiles(path);
return /\.tsx?$/.test(name) ? [path] : [];
});
}
// antd's static message renders outside React: it ignores the theme and its
// timers outlive the component, which broke CI after the Happ tests tore down.
describe('antd message', () => {
it('is only used through message.useMessage()', () => {
const offenders = sourceFiles(srcRoot).flatMap((file) =>
readFileSync(file, 'utf8')
.split('\n')
.flatMap((line, i) =>
staticCall.test(line) ? [`${relative(srcRoot, file)}:${i + 1}: ${line.trim()}`] : [],
),
);
expect(offenders).toEqual([]);
});
});
+168
View File
@@ -1384,6 +1384,169 @@ setup_fail2ban() {
return 0 return 0
} }
# Major version of the local systemd, 0 when it cannot be determined. The
# SystemCallFilter=@system-service group only exists from systemd 239 on (other
# @-named groups exist since 231); on older versions an unknown group is not
# ignored safely, the filter stays in force and leaves a whitelist the panel
# cannot run under.
_xui_systemd_major_version() {
local version=""
if command -v systemctl > /dev/null 2>&1; then
version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')"
fi
if [[ ! "$version" =~ ^[0-9]+$ ]]; then
echo 0
return 0
fi
echo "$version"
}
# The shipped units list hardening that older systemd does not know: the
# directive is logged and ignored at load time rather than rejected, so the
# panel still starts, only without that protection. Each entry is the systemd
# release that introduced the directive (systemd.exec(5)); everything else in
# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219).
# SystemCallFilter= is listed because the drop-in only writes it from 239 on.
_xui_warn_unsupported_hardening() {
local version entry missing=""
version="$(_xui_systemd_major_version)"
[[ "$version" -gt 0 ]] || return 0
for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \
ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \
SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \
ProtectKernelLogs:244 ProtectClock:245; do
if [[ "$version" -lt "${entry##*:}" ]]; then
missing="${missing:+$missing, }${entry%%:*} (${entry##*:})"
fi
done
[[ -n "$missing" ]] || return 0
echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}"
echo " Not applied, needs a newer systemd: ${missing}."
if [[ "$version" -lt 231 ]]; then
echo " The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs."
fi
echo " The rest of the hardening is in force. Upgrade systemd to apply the above."
return 0
}
# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem=
# strict would make the whole hierarchy read-only (only the kernel API
# filesystems stay as they are), and that would break the panel's own use of
# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER,
# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place
# the files in -- the unit's WorkingDirectory on a stock install, and what a
# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit
# either misses a relocated store -- the panel then cannot write its own SQLite
# database and sits in a Restart=on-failure loop -- or forces the operator to
# edit a file that every install/update overwrites from the release tarball.
# install.sh and update.sh therefore regenerate the drop-in from the folders
# actually in use, and the unit's own ReadWritePaths only carry the
# plain-install defaults. A relocated store means re-running install or update:
# the drop-in is only written here.
_xui_service_write_paths_dropin() {
# $1 is the env file to resolve the XUI_* folders from; callers pass nothing
# and get the OS-specific path the unit itself uses.
local env_file="${1:-}"
local dropin_dir dropin temp_file
local db_folder log_folder bin_folder main_folder
local path line="" whitespace_paths="" seen_paths="" escaped_path
if [[ -z "$env_file" ]]; then
case "${release}" in
ubuntu | debian | armbian)
env_file="/etc/default/x-ui"
;;
arch | manjaro | parch | alpine)
env_file="/etc/conf.d/x-ui"
;;
*)
env_file="/etc/sysconfig/x-ui"
;;
esac
fi
if [[ -r "$env_file" ]]; then
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
fi
# XUI_* wins over the script's own default: the unit hands that same env
# file to the panel through EnvironmentFile=, so these are the folders it
# will actually use.
main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}"
db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}"
# An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working
# directory, which the unit sets to the main folder.
bin_folder="${XUI_BIN_FOLDER:-bin}"
if [[ "$bin_folder" != /* ]]; then
bin_folder="${main_folder%/}/${bin_folder#./}"
fi
for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do
[[ "$path" == /* ]] || continue
# ReadWritePaths= is a whitespace-separated list, and a folder whose
# name contains whitespace cannot be written into it without relying on
# quoting. A wrong entry makes systemd reject the whole drop-in and the
# panel would not start, so leave such a folder out and say so instead.
if [[ "$path" != "${path//[[:space:]]/}" ]]; then
whitespace_paths="${whitespace_paths:+$whitespace_paths }$path"
continue
fi
case " $seen_paths " in
*" $path "*) continue ;;
esac
seen_paths="${seen_paths}${seen_paths:+ }$path"
# systemd expands %-specifiers in unit files, so a folder name carrying
# a literal % has to be written as %%, or the entry stops naming the
# folder systemd is meant to keep writable.
escaped_path="${path//%/%%}"
line="${line} -${escaped_path}"
done
if [[ -n "$whitespace_paths" ]]; then
echo "Warning: these folders contain whitespace and were left out of" >&2
echo " 10-xui-sandbox.conf: $whitespace_paths" >&2
echo " The panel cannot write to them under the unit's sandbox." >&2
fi
line="${line# }"
[[ -n "$line" ]] || return 1
dropin_dir="${xui_service}/x-ui.service.d"
dropin="${dropin_dir}/10-xui-sandbox.conf"
temp_file="${dropin}.tmp.$$"
mkdir -p "$dropin_dir" || return 1
cat > "$temp_file" << EOF
# Regenerated by install.sh/update.sh on every install and update: edits here
# are lost, and the list only reflects the XUI_* variables read from
# ${env_file} at that moment. Re-run install/update after moving a store.
# It lists the folders the panel writes to. Put local additions in their own
# drop-in, for example 20-x-ui-local.conf, which nothing here touches.
[Service]
ReadWritePaths=${line}
ReadWriteDirectories=${line}
EOF
if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then
cat >> "$temp_file" << 'EOF'
# @system-service needs systemd >= 239; on older versions the unknown group
# would leave the panel with a filter it cannot start under (x-ui.service.*).
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
EOF
fi
if [[ ! -s "$temp_file" ]]; then
rm -f "$temp_file"
return 1
fi
chmod 644 "$temp_file"
mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; }
if command -v systemctl > /dev/null 2>&1; then
systemctl daemon-reload > /dev/null 2>&1 || true
fi
return 0
}
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file + # Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a # atomic mv, so a failed cp/curl or an interrupted mv never leaves a
# truncated unit file at the live path -- systemd would then fail to parse # truncated unit file at the live path -- systemd would then fail to parse
@@ -1415,6 +1578,11 @@ _install_xui_service_unit() {
rm -f "$temp_file" rm -f "$temp_file"
return 1 return 1
fi fi
if ! _xui_service_write_paths_dropin; then
echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}"
echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}"
fi
_xui_warn_unsupported_hardening
return 0 return 0
} }
+365
View File
@@ -0,0 +1,365 @@
// Package nodee2e drives a real master panel and a real node panel, each its own
// process, through the node-sync paths. Gated by XUI_NODE_E2E_BINARY.
package nodee2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
const settleTimeout = 30 * time.Second
func panelBinary(t *testing.T) string {
t.Helper()
bin := os.Getenv("XUI_NODE_E2E_BINARY")
if bin == "" {
t.Skip("XUI_NODE_E2E_BINARY not set; run `make node-e2e`")
}
abs, err := filepath.Abs(bin)
if err != nil {
t.Fatalf("resolve %s: %v", bin, err)
}
return abs
}
type panel struct {
t *testing.T
name string
bin string
dir string
port int
token string
cmd *exec.Cmd
logOut *os.File
}
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func (p *panel) env() []string {
return append(os.Environ(),
"XUI_DB_FOLDER="+filepath.Join(p.dir, "db"),
"XUI_LOG_FOLDER="+filepath.Join(p.dir, "log"),
"XUI_BIN_FOLDER="+filepath.Join(p.dir, "bin"),
"XUI_ENABLE_FAIL2BAN=false",
"MSYS_NO_PATHCONV=1",
)
}
func (p *panel) cli(args ...string) string {
p.t.Helper()
cmd := exec.Command(p.bin, args...)
cmd.Env = p.env()
out, err := cmd.CombinedOutput()
if err != nil {
p.t.Fatalf("%s %v: %v\n%s", p.name, args, err, out)
}
return string(out)
}
var apiTokenLine = regexp.MustCompile(`(?m)^apiToken:\s*(\S+)`)
func (p *panel) mintToken(name, scope string) string {
p.t.Helper()
out := p.cli("setting", "-getApiToken", "-tokenName", name, "-tokenScope", scope)
m := apiTokenLine.FindStringSubmatch(out)
if m == nil {
p.t.Fatalf("%s: no apiToken in output:\n%s", p.name, out)
}
return m[1]
}
// newPanel prepares a panel's database: credentials, a private port, its own
// sub-server port (two panels on one host would race for 2096) and an admin token.
func newPanel(t *testing.T, bin, name string) *panel {
t.Helper()
p := preparePanel(t, bin, name)
p.token = p.mintToken("e2e-driver", "admin")
return p
}
// sharedDBMu guards the process-global database handle the harness borrows
// while the scopes run in parallel.
var sharedDBMu sync.Mutex
func preparePanel(t *testing.T, bin, name string) *panel {
t.Helper()
p := &panel{t: t, name: name, bin: bin, dir: t.TempDir(), port: freePort(t)}
for _, d := range []string{"db", "log", "bin"} {
if err := os.MkdirAll(filepath.Join(p.dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
p.cli("setting", "-username", "e2e", "-password", "e2e-pass", "-port", strconv.Itoa(p.port), "-webBasePath", "/")
sharedDBMu.Lock()
defer sharedDBMu.Unlock()
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
t.Fatalf("%s: open db: %v", name, err)
}
db := database.GetDB()
db.Exec("DELETE FROM settings WHERE key = ?", "subPort")
if err := db.Exec("INSERT INTO settings(key, value) VALUES (?, ?)", "subPort", strconv.Itoa(freePort(t))).Error; err != nil {
t.Fatalf("%s: set subPort: %v", name, err)
}
if err := database.CloseDB(); err != nil {
t.Fatalf("%s: close db: %v", name, err)
}
t.Cleanup(p.stop)
return p
}
func (p *panel) start() {
p.t.Helper()
logOut, err := os.OpenFile(filepath.Join(p.dir, "stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
p.t.Fatal(err)
}
p.logOut = logOut
p.cmd = exec.Command(p.bin, "run")
p.cmd.Env = p.env()
p.cmd.Stdout = logOut
p.cmd.Stderr = logOut
if err := p.cmd.Start(); err != nil {
p.t.Fatalf("%s: start: %v", p.name, err)
}
eventually(p.t, settleTimeout, p.name+" answers /server/status", func() (bool, string) {
env, err := p.try(http.MethodGet, "/panel/api/server/status", nil)
if err != nil {
return false, err.Error()
}
return env.Success, env.Msg
})
}
func (p *panel) stop() {
if p.cmd == nil || p.cmd.Process == nil {
return
}
_ = p.cmd.Process.Kill()
_, _ = p.cmd.Process.Wait()
p.cmd = nil
if p.logOut != nil {
_ = p.logOut.Close()
p.logOut = nil
}
if p.t.Failed() {
if b, err := os.ReadFile(filepath.Join(p.dir, "stdout.log")); err == nil {
tail := string(b)
if len(tail) > 6000 {
tail = tail[len(tail)-6000:]
}
p.t.Logf("---- %s stdout tail ----\n%s", p.name, tail)
}
}
}
// deleteInboundRow simulates a node that lost an inbound (restore, reinstall)
// while stopped; it must not run against a live panel.
func (p *panel) deleteInboundRow(id int) {
p.t.Helper()
if p.cmd != nil {
p.t.Fatalf("%s: deleteInboundRow on a running panel", p.name)
}
sharedDBMu.Lock()
defer sharedDBMu.Unlock()
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
p.t.Fatalf("%s: open db: %v", p.name, err)
}
defer func() { _ = database.CloseDB() }()
db := database.GetDB()
for _, q := range []string{"DELETE FROM client_inbounds WHERE inbound_id = ?", "DELETE FROM client_traffics WHERE inbound_id = ?", "DELETE FROM inbounds WHERE id = ?"} {
if err := db.Exec(q, id).Error; err != nil {
p.t.Fatalf("%s: %s: %v", p.name, q, err)
}
}
}
func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
type envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
func (p *panel) try(method, path string, body any) (*envelope, error) {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, p.url()+path, rd)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("decode %s: %w (%s)", path, err, raw)
}
return &env, nil
}
// call fails the test on transport errors or success:false.
func (p *panel) call(method, path string, body any) json.RawMessage {
p.t.Helper()
env, err := p.try(method, path, body)
if err != nil {
p.t.Fatalf("%s %s %s: %v", p.name, method, path, err)
}
if !env.Success {
p.t.Fatalf("%s %s %s: success=false msg=%q", p.name, method, path, env.Msg)
}
return env.Obj
}
func eventually(t *testing.T, timeout time.Duration, what string, check func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(timeout)
last := ""
for {
ok, detail := check()
if ok {
return
}
last = detail
if time.Now().After(deadline) {
t.Fatalf("timed out after %s waiting for %s; last: %s", timeout, what, last)
}
time.Sleep(500 * time.Millisecond)
}
}
// inboundView is the subset of an inbound row the scenarios assert on.
type inboundView struct {
Id int `json:"id"`
Remark string `json:"remark"`
Enable bool `json:"enable"`
Port int `json:"port"`
Tag string `json:"tag"`
NodeID *int `json:"nodeId"`
Settings json.RawMessage `json:"settings"`
}
type clientEntry map[string]any
func (c clientEntry) email() string { s, _ := c["email"].(string); return s }
func (ib inboundView) clients() []clientEntry {
raw := ib.Settings
var asString string
if json.Unmarshal(raw, &asString) == nil {
raw = json.RawMessage(asString)
}
var s struct {
Clients []clientEntry `json:"clients"`
}
_ = json.Unmarshal(raw, &s)
return s.Clients
}
func (ib inboundView) emails() []string {
out := []string{}
for _, c := range ib.clients() {
out = append(out, c.email())
}
return out
}
func (ib inboundView) client(email string) clientEntry {
for _, c := range ib.clients() {
if strings.EqualFold(c.email(), email) {
return c
}
}
return nil
}
func (p *panel) inbounds() []inboundView {
p.t.Helper()
var list []inboundView
if err := json.Unmarshal(p.call(http.MethodGet, "/panel/api/inbounds/list", nil), &list); err != nil {
p.t.Fatalf("%s: decode inbound list: %v", p.name, err)
}
return list
}
func (p *panel) inboundOnPort(port int) (inboundView, bool) {
p.t.Helper()
for _, ib := range p.inbounds() {
if ib.Port == port {
return ib, true
}
}
return inboundView{}, false
}
const tcpStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`
func vlessInbound(remark string, port int, nodeID *int, clients ...map[string]any) map[string]any {
if clients == nil {
clients = []map[string]any{}
}
settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
body := map[string]any{
"remark": remark, "enable": true, "port": port, "protocol": "vless",
"settings": string(settings), "streamSettings": tcpStream, "sniffing": `{}`,
}
if nodeID != nil {
body["nodeId"] = *nodeID
}
return body
}
func vlessClient(email string) map[string]any {
return map[string]any{"email": email, "enable": true, "id": newUUID(email)}
}
// newUUID derives a stable, valid UUID from a label so failures are reproducible.
func newUUID(label string) string {
var b [16]byte
copy(b[:], []byte(label+"________________"))
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+344
View File
@@ -0,0 +1,344 @@
package nodee2e
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"testing"
"time"
)
// TestNodeSync walks one master/node pair per enrollment scope through every
// operation that must converge onto the node. Each subtest names its invariant.
func TestNodeSync(t *testing.T) {
bin := panelBinary(t)
for _, scope := range []string{"admin", "node-sync"} {
t.Run("enrolled with "+scope+" token", func(t *testing.T) {
t.Parallel()
runNodeSyncScenarios(t, bin, scope)
})
}
}
type pair struct {
t *testing.T
master *panel
node *panel
nodeID int
}
func (pr *pair) nodeInbound(port int) (inboundView, bool) { return pr.node.inboundOnPort(port) }
func (pr *pair) masterInbound(port int) (inboundView, bool) {
for _, ib := range pr.master.inbounds() {
if ib.Port == port && ib.NodeID != nil && *ib.NodeID == pr.nodeID {
return ib, true
}
}
return inboundView{}, false
}
func (pr *pair) waitNode(what string, port int, ok func(inboundView) bool) {
pr.t.Helper()
eventually(pr.t, settleTimeout, what, func() (bool, string) {
ib, found := pr.nodeInbound(port)
if !found {
return ok(inboundView{}) && false, fmt.Sprintf("node has no inbound on %d", port)
}
return ok(ib), fmt.Sprintf("node inbound %d: enable=%v remark=%q emails=%v", port, ib.Enable, ib.Remark, ib.emails())
})
}
func (pr *pair) waitNodeAbsent(what string, port int) {
pr.t.Helper()
eventually(pr.t, settleTimeout, what, func() (bool, string) {
ib, found := pr.nodeInbound(port)
return !found, fmt.Sprintf("node still has inbound %d with %v", port, ib.emails())
})
}
func (pr *pair) bulkAttach(emails []string, masterInboundID int) {
pr.t.Helper()
var res struct {
Attached []string `json:"attached"`
Errors []string `json:"errors"`
}
obj := pr.master.call(http.MethodPost, "/panel/api/clients/bulkAttach", map[string]any{"emails": emails, "inboundIds": []int{masterInboundID}})
if err := json.Unmarshal(obj, &res); err != nil {
pr.t.Fatalf("decode bulkAttach: %v", err)
}
if len(res.Errors) != 0 || len(res.Attached) != len(emails) {
pr.t.Fatalf("bulkAttach attached=%d/%d errors=%v", len(res.Attached), len(emails), res.Errors)
}
}
func emailRange(prefix string, from, to int) []string {
out := make([]string, 0, to-from+1)
for i := from; i <= to; i++ {
out = append(out, prefix+strconv.Itoa(i))
}
return out
}
func hasAll(have []string, want ...string) bool {
for _, w := range want {
if !slices.Contains(have, w) {
return false
}
}
return true
}
func runNodeSyncScenarios(t *testing.T, bin, scope string) {
master := newPanel(t, bin, "master")
node := newPanel(t, bin, "node")
linkToken := node.mintToken("master-link", scope)
master.start()
node.start()
pr := &pair{t: t, master: master, node: node}
var (
adoptedPort = freePort(t)
madePort = freePort(t)
lostPort = freePort(t)
droppedPort = freePort(t)
offlinePort = freePort(t)
unmanagedPort = freePort(t)
localPort = freePort(t)
)
node.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("pre-existing", adoptedPort, nil))
local := master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("local-pool", localPort, nil))
var localIb inboundView
_ = json.Unmarshal(local, &localIb)
for _, email := range emailRange("p", 1, 45) {
master.call(http.MethodPost, "/panel/api/clients/add", map[string]any{
"client": map[string]any{"email": email, "enable": true}, "inboundIds": []int{localIb.Id},
})
}
var nodeView struct {
Id int `json:"id"`
}
obj := master.call(http.MethodPost, "/panel/api/nodes/add", map[string]any{
"name": "n1", "scheme": "http", "address": "127.0.0.1", "port": node.port, "basePath": "/",
"apiToken": linkToken, "enable": true, "allowPrivateAddress": true,
})
if err := json.Unmarshal(obj, &nodeView); err != nil || nodeView.Id == 0 {
t.Fatalf("decode node add: %v (%s)", err, obj)
}
pr.nodeID = nodeView.Id
var adoptedID, madeID int
t.Run("an inbound already on the node is adopted by the master", func(t *testing.T) {
pr.t = t
eventually(t, settleTimeout, "master adopts the node inbound", func() (bool, string) {
ib, ok := pr.masterInbound(adoptedPort)
adoptedID = ib.Id
return ok, "not adopted yet"
})
})
if adoptedID == 0 {
t.Fatal("no adopted inbound; later scenarios depend on it")
}
t.Run("an inbound created on the master for the node lands there with its clients", func(t *testing.T) {
pr.t = t
obj := master.call(http.MethodPost, "/panel/api/inbounds/add",
vlessInbound("made-on-master", madePort, &pr.nodeID, vlessClient("m1"), vlessClient("m2")))
var ib inboundView
_ = json.Unmarshal(obj, &ib)
madeID = ib.Id
pr.waitNode("node holds the master-made inbound", madePort, func(ib inboundView) bool {
return hasAll(ib.emails(), "m1", "m2")
})
})
t.Run("editing the inbound on the master updates the node and keeps its clients", func(t *testing.T) {
pr.t = t
body := vlessInbound("renamed-on-master", madePort, &pr.nodeID)
body["settings"] = `{"decryption":"none"}`
master.call(http.MethodPost, "/panel/api/inbounds/update/"+strconv.Itoa(madeID), body)
pr.waitNode("node shows the new remark with both clients", madePort, func(ib inboundView) bool {
return ib.Remark == "renamed-on-master" && hasAll(ib.emails(), "m1", "m2")
})
})
t.Run("attaching a few existing clients reaches the node", func(t *testing.T) {
pr.t = t
pr.bulkAttach([]string{"p1", "p2", "p3"}, madeID)
pr.waitNode("node holds p1..p3", madePort, func(ib inboundView) bool {
return hasAll(ib.emails(), "m1", "m2", "p1", "p2", "p3")
})
})
t.Run("attaching more clients than the per-client push limit reaches the node", func(t *testing.T) {
pr.t = t
emails := emailRange("p", 4, 43)
pr.bulkAttach(emails, adoptedID)
pr.waitNode("node holds all 40", adoptedPort, func(ib inboundView) bool {
return hasAll(ib.emails(), emails...)
})
})
t.Run("disabling a client on the master disables it on the node", func(t *testing.T) {
pr.t = t
mib, _ := pr.masterInbound(madePort)
entry := mib.client("p1")
if entry == nil {
t.Fatalf("master inbound has no p1: %v", mib.emails())
}
entry["enable"] = false
master.call(http.MethodPost, "/panel/api/clients/update/p1", entry)
pr.waitNode("node p1 disabled", madePort, func(ib inboundView) bool {
c := ib.client("p1")
return c != nil && c["enable"] == false
})
})
t.Run("detaching a client from the node inbound removes it there", func(t *testing.T) {
pr.t = t
master.call(http.MethodPost, "/panel/api/clients/p2/detach", map[string]any{"inboundIds": []int{madeID}})
pr.waitNode("node drops p2", madePort, func(ib inboundView) bool {
return ib.client("p2") == nil && ib.client("p3") != nil
})
})
t.Run("deleting a client on the master removes it from the node", func(t *testing.T) {
pr.t = t
master.call(http.MethodPost, "/panel/api/clients/del/p4", nil)
pr.waitNode("node drops p4", adoptedPort, func(ib inboundView) bool {
return ib.client("p4") == nil && ib.client("p5") != nil
})
})
t.Run("switching the inbound off on the master switches it off on the node", func(t *testing.T) {
pr.t = t
master.call(http.MethodPost, "/panel/api/inbounds/setEnable/"+strconv.Itoa(madeID), map[string]any{"enable": false})
pr.waitNode("node inbound disabled", madePort, func(ib inboundView) bool { return !ib.Enable })
})
t.Run("node traffic reaches the master and a master reset clears the node", func(t *testing.T) {
pr.t = t
node.call(http.MethodPost, "/panel/api/clients/updateTraffic/p5", map[string]any{"upload": 1000, "download": 2000})
usage := func(p *panel) int64 {
var tr struct{ Up, Down int64 }
_ = json.Unmarshal(p.call(http.MethodGet, "/panel/api/clients/traffic/p5", nil), &tr)
return tr.Up + tr.Down
}
eventually(t, settleTimeout, "master sees p5's node traffic", func() (bool, string) {
u := usage(master)
return u == 3000, fmt.Sprintf("master p5 usage %d", u)
})
master.call(http.MethodPost, "/panel/api/clients/resetTraffic/p5", nil)
eventually(t, settleTimeout, "node p5 usage reset", func() (bool, string) {
u := usage(node)
return u == 0, fmt.Sprintf("node p5 usage %d", u)
})
time.Sleep(12 * time.Second)
if u := usage(master); u != 0 {
t.Fatalf("master p5 usage %d after reset settled, want 0", u)
}
})
t.Run("an inbound deleted on the node is removed from the master too", func(t *testing.T) {
pr.t = t
master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-node", lostPort, &pr.nodeID, vlessClient("l1")))
pr.waitNode("node holds the inbound", lostPort, func(ib inboundView) bool { return ib.client("l1") != nil })
nib, _ := pr.nodeInbound(lostPort)
node.call(http.MethodPost, "/panel/api/inbounds/del/"+strconv.Itoa(nib.Id), nil)
eventually(t, settleTimeout, "master mirrors the node-side delete (#6219)", func() (bool, string) {
_, still := pr.masterInbound(lostPort)
return !still, "master still has the inbound"
})
})
t.Run("deleting the inbound on the master removes it from the node", func(t *testing.T) {
pr.t = t
obj := master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-master", droppedPort, &pr.nodeID, vlessClient("d1")))
var ib inboundView
_ = json.Unmarshal(obj, &ib)
pr.waitNode("node holds the inbound", droppedPort, func(ib inboundView) bool { return ib.client("d1") != nil })
master.call(http.MethodPost, "/panel/api/inbounds/del/"+strconv.Itoa(ib.Id), nil)
pr.waitNodeAbsent("node drops the inbound", droppedPort)
})
t.Run("a change made while the node is down reaches it once it is back", func(t *testing.T) {
pr.t = t
node.stop()
pr.bulkAttach([]string{"p44"}, adoptedID)
node.start()
pr.waitNode("node holds p44 after restart", adoptedPort, func(ib inboundView) bool {
return ib.client("p44") != nil
})
})
t.Run("an inbound the node lost while down is re-created with the master's pending change", func(t *testing.T) {
pr.t = t
nib, ok := pr.nodeInbound(adoptedPort)
if !ok {
t.Fatal("node has no adopted inbound to lose")
}
node.stop()
node.deleteInboundRow(nib.Id)
pr.bulkAttach([]string{"p45"}, adoptedID)
node.start()
pr.waitNode("node re-creates the inbound with p44 and p45", adoptedPort, func(ib inboundView) bool {
return ib.client("p44") != nil && ib.client("p45") != nil
})
})
t.Run("an inbound created and edited while the node is down lands once it is back", func(t *testing.T) {
pr.t = t
node.stop()
obj := master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("made-while-down", offlinePort, &pr.nodeID, vlessClient("o1")))
var ib inboundView
_ = json.Unmarshal(obj, &ib)
body := vlessInbound("edited-while-down", offlinePort, &pr.nodeID)
body["settings"] = `{"decryption":"none"}`
master.call(http.MethodPost, "/panel/api/inbounds/update/"+strconv.Itoa(ib.Id), body)
node.start()
pr.waitNode("node holds the edited inbound with o1", offlinePort, func(ib inboundView) bool {
return ib.Remark == "edited-while-down" && ib.client("o1") != nil
})
})
t.Run("a change made while the node is disabled on the master lands once it is re-enabled", func(t *testing.T) {
pr.t = t
nodePath := "/panel/api/nodes/setEnable/" + strconv.Itoa(pr.nodeID)
master.call(http.MethodPost, nodePath, map[string]any{"enable": false})
pr.bulkAttach([]string{"p6"}, madeID)
time.Sleep(6 * time.Second)
if ib, _ := pr.nodeInbound(madePort); ib.client("p6") != nil {
t.Fatal("a disabled node received a push")
}
master.call(http.MethodPost, nodePath, map[string]any{"enable": true})
pr.waitNode("node holds p6 after re-enable", madePort, func(ib inboundView) bool { return ib.client("p6") != nil })
})
t.Run("selected sync mode leaves the node's unselected inbounds alone", func(t *testing.T) {
pr.t = t
var selected []string
for _, ib := range master.inbounds() {
if ib.NodeID != nil && *ib.NodeID == pr.nodeID {
selected = append(selected, ib.Tag)
}
}
master.call(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(pr.nodeID), map[string]any{
"name": "n1", "scheme": "http", "address": "127.0.0.1", "port": node.port, "basePath": "/",
"enable": true, "allowPrivateAddress": true, "inboundSyncMode": "selected", "inboundTags": selected,
})
node.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("node-only", unmanagedPort, nil, vlessClient("u1")))
pr.bulkAttach([]string{"p7"}, madeID)
pr.waitNode("selected inbound still converges", madePort, func(ib inboundView) bool { return ib.client("p7") != nil })
time.Sleep(12 * time.Second)
if _, adopted := pr.masterInbound(unmanagedPort); adopted {
t.Fatal("master adopted an unselected node inbound")
}
if ib, ok := pr.nodeInbound(unmanagedPort); !ok || ib.client("u1") == nil {
t.Fatal("reconcile swept or rewrote an unselected node inbound")
}
})
}
@@ -0,0 +1,57 @@
package sub
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// #6738: without the host's Description on hysteria(2):// links, Happ falls back
// to its own "Hysteria | hysteria | TLS" caption on a host that also serves VLESS.
func TestGenHysteriaLinkAppendsHostServerDescription(t *testing.T) {
tests := map[string]struct {
version int
scheme string
}{
"hysteria v1": {version: 1, scheme: "hysteria://"},
"hysteria v2": {version: 2, scheme: "hysteria2://"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
host := &model.Host{
Address: "hy.example.com", Port: 443,
Remark: "Poland", ServerDescription: "Wi-Fi",
}
stream := map[string]any{
"security": "tls",
"externalProxy": []any{hostToExternalProxyMap(host, "hy.example.com", 443)},
}
rawStream, err := json.Marshal(stream)
if err != nil {
t.Fatalf("marshal stream settings: %v", err)
}
// The inbound's own `version` picks the hysteria vs hysteria2 scheme.
rawSettings, err := json.Marshal(map[string]any{
"version": tc.version,
"clients": []any{map[string]any{"auth": "secret", "email": "user"}},
})
if err != nil {
t.Fatalf("marshal inbound settings: %v", err)
}
in := &model.Inbound{
Id: 920010, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
Remark: "hy", StreamSettings: string(rawStream), Settings: string(rawSettings),
}
got := (&SubService{}).genHysteriaLink(in, "user")
if !strings.HasPrefix(got, tc.scheme) {
t.Fatalf("link scheme changed.\n got: %s\nwant prefix: %s", got, tc.scheme)
}
// base64("Wi-Fi"), matching the reporter's subscription output.
if !strings.HasSuffix(got, "?serverDescription=V2ktRmk=") {
t.Fatalf("host serverDescription missing from fragment.\n got: %s\nwant suffix: ?serverDescription=V2ktRmk=", got)
}
})
}
}
+5 -1
View File
@@ -1535,7 +1535,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
applyExternalProxyHysteriaParams(ep, epParams) applyExternalProxyHysteriaParams(ep, epParams)
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF))) link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic"))) // VLESS/Trojan/SS get the host's description through buildEndpointLinks;
// this loop renders the fragment itself, so add it here too (#6738).
remark := s.endpointRemark(inbound, email, ep, "quic")
remark = appendHappServerDescription(remark, externalProxyToEndpoint(ep).ServerDescription)
links = append(links, buildLinkWithParams(link, epParams, remark))
} }
return strings.Join(links, "\n") return strings.Join(links, "\n")
} }
+2 -6
View File
@@ -96,9 +96,7 @@ func TestAudit3ManagerEnsureActualSendersWithPersistentTraffic(t *testing.T) {
if _, err := stream.Write(frame.Bytes()); err != nil { if _, err := stream.Write(frame.Bytes()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := stream.Close(); err != nil { closeUniStream(t, stream)
t.Fatal(err)
}
response, err := p.client.AcceptUniStream(ctx) response, err := p.client.AcceptUniStream(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -164,9 +162,7 @@ func TestAudit3ManagerEnsureActualSendersWithPersistentTraffic(t *testing.T) {
if _, err := auth.Write(authBytes); err != nil { if _, err := auth.Write(authBytes); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := auth.Close(); err != nil { closeUniStream(t, auth)
t.Fatal(err)
}
waitForClientCongestionSender(t, server, client, served) waitForClientCongestionSender(t, server, client, served)
var serverConn *quic.Conn var serverConn *quic.Conn
+1 -3
View File
@@ -57,9 +57,7 @@ func audit3LogsStart(t *testing.T, level, marker, relayAddr string) (*Server, *c
if _, err := auth.Write(frame); err != nil { if _, err := auth.Write(frame); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := auth.Close(); err != nil { closeUniStream(t, auth)
t.Fatal(err)
}
_, _ = authenticatedServerConnection(t, s, id) _, _ = authenticatedServerConnection(t, s, id)
return s, c, id, password, token return s, c, id, password, token
} }
+49 -18
View File
@@ -10,12 +10,14 @@ import (
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"errors"
"io" "io"
"math/big" "math/big"
"net" "net"
"testing" "testing"
"time" "time"
serverquic "github.com/apernet/quic-go"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/quic-go/quic-go" "github.com/quic-go/quic-go"
) )
@@ -196,12 +198,51 @@ func testServerTCPConnectE2E(t *testing.T, controller string) {
t.Fatalf("expected active email alice@example.com, got %v", activeEmails) t.Fatalf("expected active email alice@example.com, got %v", activeEmails)
} }
deltas := server.CollectClientTraffic() waitForClientTraffic(t, server, "alice@example.com", int64(len(testMsg)))
if len(deltas) == 0 { }
t.Fatalf("expected traffic deltas, got none")
// closeUniStream tolerates only the server's STOP_SENDING: it cancels the read side of a
// uni stream once the command is parsed, which can land before the client's FIN.
func closeUniStream(t *testing.T, stream interface {
Close() error
Context() context.Context
},
) {
t.Helper()
err := stream.Close()
if err == nil {
return
} }
if deltas[0].Email != "alice@example.com" || deltas[0].Up < int64(len(testMsg)) || deltas[0].Down < int64(len(testMsg)) { cause := context.Cause(stream.Context())
t.Fatalf("unexpected traffic deltas: %+v", deltas[0]) var clientErr *quic.StreamError
var serverErr *serverquic.StreamError
if (errors.As(cause, &clientErr) && clientErr.Remote) || (errors.As(cause, &serverErr) && serverErr.Remote) {
return
}
t.Fatalf("close uni stream: %v (cause %v)", err, cause)
}
// waitForClientTraffic accumulates drained deltas because the up and down counters are
// bumped on different relay goroutines, so the echo can arrive before the upload is counted.
func waitForClientTraffic(t *testing.T, server *Server, email string, minBytes int64) {
t.Helper()
var up, down int64
deadline := time.Now().Add(4 * time.Second)
for {
for _, delta := range server.CollectClientTraffic() {
if delta.Email != email {
t.Fatalf("unexpected traffic delta for %q: %+v", delta.Email, delta)
}
up += delta.Up
down += delta.Down
}
if up >= minBytes && down >= minBytes {
return
}
if time.Now().After(deadline) {
t.Fatalf("traffic for %s = up %d, down %d; want both >= %d", email, up, down, minBytes)
}
time.Sleep(5 * time.Millisecond)
} }
} }
@@ -356,13 +397,7 @@ func testServerUDPDatagramE2E(t *testing.T, controller string) {
} }
// 4. Verify traffic // 4. Verify traffic
deltas := server.CollectClientTraffic() waitForClientTraffic(t, server, "bob@example.com", int64(len(udpMsg)))
if len(deltas) == 0 {
t.Fatalf("expected traffic deltas, got none")
}
if deltas[0].Email != "bob@example.com" || deltas[0].Up < int64(len(udpMsg)) || deltas[0].Down < int64(len(udpMsg)) {
t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
}
} }
func TestServerUDPStreamE2E(t *testing.T) { func TestServerUDPStreamE2E(t *testing.T) {
@@ -433,9 +468,7 @@ func testServerUDPStreamE2E(t *testing.T, controller string) {
if _, err := authStream.Write(authPayload); err != nil { if _, err := authStream.Write(authPayload); err != nil {
t.Fatalf("write authentication payload failed: %v", err) t.Fatalf("write authentication payload failed: %v", err)
} }
if err := authStream.Close(); err != nil { closeUniStream(t, authStream)
t.Fatalf("close authentication stream failed: %v", err)
}
target := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53} target := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}
udpMsg := bytes.Repeat([]byte("s"), 8500) udpMsg := bytes.Repeat([]byte("s"), 8500)
@@ -458,9 +491,7 @@ func testServerUDPStreamE2E(t *testing.T, controller string) {
if _, err := packetStream.Write(frame.Bytes()); err != nil { if _, err := packetStream.Write(frame.Bytes()); err != nil {
t.Fatalf("write packet frame failed: %v", err) t.Fatalf("write packet frame failed: %v", err)
} }
if err := packetStream.Close(); err != nil { closeUniStream(t, packetStream)
t.Fatalf("close packet stream failed: %v", err)
}
} }
replyReassembler := newPacketReassembler(maxUdpRelayPacketSize) replyReassembler := newPacketReassembler(maxUdpRelayPacketSize)
+1 -3
View File
@@ -57,9 +57,7 @@ func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *
if _, err := stream.Write(auth); err != nil { if _, err := stream.Write(auth); err != nil {
t.Fatalf("write authentication: %v", err) t.Fatalf("write authentication: %v", err)
} }
if err := stream.Close(); err != nil { closeUniStream(t, stream)
t.Fatalf("close authentication stream: %v", err)
}
return server, client, clientID, password return server, client, clientID, password
} }
+2
View File
@@ -16,6 +16,8 @@ const (
HashHeader = "X-Config-Sha256" HashHeader = "X-Config-Sha256"
// CapsHeader is set by a node on its API responses to advertise support. // CapsHeader is set by a node on its API responses to advertise support.
CapsHeader = "X-3x-Node-Caps" CapsHeader = "X-3x-Node-Caps"
// MasterPushHeader marks a request as a master's push, whatever its token scope.
MasterPushHeader = "X-3x-Master-Push"
// EncodingZstd is the Content-Encoding value for a zstd-compressed body. // EncodingZstd is the Content-Encoding value for a zstd-compressed body.
EncodingZstd = "zstd" EncodingZstd = "zstd"
// CapZstd is the capability token advertised in CapsHeader. // CapZstd is the capability token advertised in CapsHeader.
+3
View File
@@ -98,6 +98,7 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/inbounds/add": {http.MethodPost: {}}, "/inbounds/add": {http.MethodPost: {}},
"/inbounds/del/:id": {http.MethodPost: {}}, "/inbounds/del/:id": {http.MethodPost: {}},
"/inbounds/update/:id": {http.MethodPost: {}}, "/inbounds/update/:id": {http.MethodPost: {}},
"/inbounds/:id/subSortIndex": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}}, "/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}}, "/clients/del/:email": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}}, "/clients/:email/detach": {http.MethodPost: {}},
@@ -106,11 +107,13 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/server/getWebCertFiles": {http.MethodGet: {}}, "/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}}, "/server/descendants": {http.MethodGet: {}},
"/clients/resetTraffic/:email": {http.MethodPost: {}}, "/clients/resetTraffic/:email": {http.MethodPost: {}},
"/clients/bulkResetTraffic": {http.MethodPost: {}},
"/inbounds/resetAllTraffics": {http.MethodPost: {}}, "/inbounds/resetAllTraffics": {http.MethodPost: {}},
"/inbounds/:id/resetTraffic": {http.MethodPost: {}}, "/inbounds/:id/resetTraffic": {http.MethodPost: {}},
"/clients/onlinesByGuid": {http.MethodPost: {}}, "/clients/onlinesByGuid": {http.MethodPost: {}},
"/clients/onlines": {http.MethodPost: {}}, "/clients/onlines": {http.MethodPost: {}},
"/clients/lastOnline": {http.MethodPost: {}}, "/clients/lastOnline": {http.MethodPost: {}},
"/clients/activeInbounds": {http.MethodPost: {}},
"/inbounds/pushClientTraffics": {http.MethodPost: {}}, "/inbounds/pushClientTraffics": {http.MethodPost: {}},
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}}, "/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
"/clients/clientIpsByGuid": {http.MethodPost: {}}, "/clients/clientIpsByGuid": {http.MethodPost: {}},
-34
View File
@@ -7,7 +7,6 @@ import (
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"reflect"
"testing" "testing"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -138,39 +137,6 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
} }
} }
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/restartXrayService": {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) { func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
engine, _ := newAPIAuthTestEngine(t) engine, _ := newAPIAuthTestEngine(t)
cases := []struct { cases := []struct {
+4 -1
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "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/session"
@@ -64,7 +65,9 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService { func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
svc := a.inboundService svc := a.inboundService
scope, _ := c.Get("api_token_scope") scope, _ := c.Get("api_token_scope")
svc.FromNodeSync = scope == model.ApiScopeNodeSync // A master enrolled with an admin token (the -getApiToken default) has no
// node-sync scope, so it marks every request it sends instead.
svc.FromNodeSync = scope == model.ApiScopeNodeSync || c.GetHeader(wirecodec.MasterPushHeader) != ""
return &svc return &svc
} }
+36
View File
@@ -2,16 +2,22 @@ package job
import ( import (
"fmt" "fmt"
"sync"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/tuic" "github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "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" "github.com/mhsanaei/3x-ui/v3/internal/xray"
) )
const defaultTuicSpeedSampleInterval = 10 * time.Second
type TuicJob struct { type TuicJob struct {
inboundService service.InboundService inboundService service.InboundService
runMu sync.Mutex
lastSpeedSample time.Time
} }
func NewTuicJob() *TuicJob { func NewTuicJob() *TuicJob {
@@ -19,6 +25,9 @@ func NewTuicJob() *TuicJob {
} }
func (j *TuicJob) Run() { func (j *TuicJob) Run() {
j.runMu.Lock()
defer j.runMu.Unlock()
tuicJournalMu.Lock() tuicJournalMu.Lock()
journalErr := j.replayTuicJournal() journalErr := j.replayTuicJournal()
tuicJournalMu.Unlock() tuicJournalMu.Unlock()
@@ -44,20 +53,28 @@ func (j *TuicJob) Run() {
onlineEmails, _ := mgr.GetActiveClients(30 * time.Second) onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails) clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
sampledAt := time.Now()
sampleInterval := tuicSpeedSampleInterval(j.lastSpeedSample, sampledAt)
// Inbound total traffic is already metered through the loopback SOCKS relay // Inbound total traffic is already metered through the loopback SOCKS relay
// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here. // by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
persisted := true
if len(clientTraffics) > 0 { if len(clientTraffics) > 0 {
needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics) needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
if err != nil { if err != nil {
logger.Warning("tuic job: add traffic failed:", err) logger.Warning("tuic job: add traffic failed:", err)
mgr.RequeueClientTraffic(clientDeltas) mgr.RequeueClientTraffic(clientDeltas)
persisted = false
} else if needRestart { } else if needRestart {
if desired, err := j.inboundService.DesiredTuicInstances(); err == nil { if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
mgr.Reconcile(desired) mgr.Reconcile(desired)
} }
} }
} }
if persisted {
websocket.BroadcastTraffic(tuicSpeedPayload(clientTraffics, sampleInterval))
j.lastSpeedSample = sampledAt
}
if len(onlineEmails) > 0 { if len(onlineEmails) > 0 {
if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil { if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
@@ -68,6 +85,25 @@ func (j *TuicJob) Run() {
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags) j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
} }
func tuicSpeedSampleInterval(previous, current time.Time) time.Duration {
if previous.IsZero() || !current.After(previous) {
return defaultTuicSpeedSampleInterval
}
return current.Sub(previous)
}
func tuicSpeedPayload(clientTraffics []*xray.ClientTraffic, sampleInterval time.Duration) map[string]any {
intervalMs := sampleInterval.Milliseconds()
if intervalMs < 1 {
intervalMs = 1
}
return map[string]any{
"clientTraffics": clientTraffics,
"clientTrafficSource": "tuic",
"clientTrafficIntervalMs": intervalMs,
}
}
// FlushStoppedTraffic persists counters drained when the TUIC manager stops its // FlushStoppedTraffic persists counters drained when the TUIC manager stops its
// listeners. Call it after scheduled jobs have stopped and before the traffic // listeners. Call it after scheduled jobs have stopped and before the traffic
// writer shuts down. // writer shuts down.
+11
View File
@@ -180,3 +180,14 @@ func TestAggregateTuicClientTrafficPreservesStableIdentityAcrossEmailRename(t *t
t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down) t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
} }
} }
func TestTuicSpeedSampleIntervalUsesElapsedPollTime(t *testing.T) {
current := time.Date(2026, time.October, 3, 12, 0, 10, 0, time.UTC)
previous := current.Add(-12 * time.Second)
if got := tuicSpeedSampleInterval(previous, current); got != 12*time.Second {
t.Fatalf("sample interval = %s, want 12s", got)
}
if got := tuicSpeedSampleInterval(time.Time{}, current); got != defaultTuicSpeedSampleInterval {
t.Fatalf("initial sample interval = %s, want %s", got, defaultTuicSpeedSampleInterval)
}
}
+383
View File
@@ -0,0 +1,383 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/robfig/cron/v3"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"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/xray"
)
// nodeUnderContract serves the production router as a node and records every
// request the node refused for auth or scope.
type nodeUnderContract struct {
srv *httptest.Server
mu sync.Mutex
refused []string
}
func startContractNode(t *testing.T) *nodeUnderContract {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
prevMgr := runtime.GetManager()
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(prevMgr) })
previous := global.GetWebServer()
s := NewServer()
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
global.SetWebServer(s)
t.Cleanup(func() {
s.cancel()
global.SetWebServer(previous)
})
engine, err := s.initRouter()
if err != nil {
t.Fatalf("initRouter: %v", err)
}
n := &nodeUnderContract{}
n.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
engine.ServeHTTP(rec, r)
if rec.status == http.StatusUnauthorized || rec.status == http.StatusForbidden {
n.mu.Lock()
n.refused = append(n.refused, r.Method+" "+r.URL.Path+" -> "+strconv.Itoa(rec.status))
n.mu.Unlock()
}
}))
t.Cleanup(n.srv.Close)
return n
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (n *nodeUnderContract) takeRefused() []string {
n.mu.Lock()
defer n.mu.Unlock()
out := n.refused
n.refused = nil
return out
}
func (n *nodeUnderContract) masterWithToken(t *testing.T, scope string) *runtime.Remote {
t.Helper()
token := "contract-" + scope
if err := database.GetDB().Create(&model.ApiToken{
Name: "master-" + scope, Token: crypto.HashTokenSHA256(token), Enabled: true, Scope: scope,
}).Error; err != nil {
t.Fatalf("seed %s token: %v", scope, err)
}
u, _ := url.Parse(n.srv.URL)
port, _ := strconv.Atoi(u.Port())
return runtime.NewRemote(&model.Node{
Id: 1, Name: "contract-node", Scheme: "http", Address: u.Hostname(), Port: port,
BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
}, nil)
}
func nodeRow(t *testing.T, tag string) (*model.Inbound, bool) {
t.Helper()
var ib model.Inbound
err := database.GetDB().Where("tag = ?", tag).First(&ib).Error
return &ib, err == nil
}
func nodeTraffic(t *testing.T, email string) int64 {
t.Helper()
var ct xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&ct).Error; err != nil {
t.Fatalf("client_traffics %s: %v", email, err)
}
return ct.Up + ct.Down
}
func seedNodeTraffic(t *testing.T, emails ...string) {
t.Helper()
for _, e := range emails {
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", e).
Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
t.Fatalf("seed traffic %s: %v", e, err)
}
}
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", contractTag).
Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
t.Fatalf("seed inbound traffic: %v", err)
}
}
const contractTag = "in-51001-tcp"
func masterInbound(remark string, enable bool, clients ...string) *model.Inbound {
entries := make([]string, 0, len(clients))
for i, email := range clients {
entries = append(entries, `{"email":"`+email+`","enable":true,"subId":"s-`+email+
`","id":"0b6d5c2e-7c1a-4f4e-9d3b-00000000000`+strconv.Itoa(i)+`"}`)
}
return &model.Inbound{
Tag: contractTag, Remark: remark, Enable: enable, Port: 51001, Protocol: model.VLESS,
Settings: `{"clients":[` + strings.Join(entries, ",") + `],"decryption":"none"}`,
StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
Sniffing: `{}`,
}
}
func nodeEmails(t *testing.T) []string {
t.Helper()
ib, ok := nodeRow(t, contractTag)
if !ok {
t.Fatal("node has no contract inbound")
}
clients, err := (&service.InboundService{}).GetClients(ib)
if err != nil {
t.Fatalf("parse node clients: %v", err)
}
emails := make([]string, 0, len(clients))
for _, c := range clients {
emails = append(emails, c.Email)
}
return emails
}
// TestMasterNodeContract sends every node call the master makes through the production
// router, once per enrollment scope; UpdatePanel is excluded from node-sync on purpose.
func TestMasterNodeContract(t *testing.T) {
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} {
t.Run(scope, func(t *testing.T) {
node := startContractNode(t)
master := node.masterWithToken(t, scope)
ctx := context.Background()
cells := []struct {
name string
covers []string
run func() error
check func(t *testing.T)
}{
{"AddInbound creates the inbound with its clients", []string{"AddInbound"}, func() error {
return master.AddInbound(ctx, masterInbound("added", true, "c0", "c1"))
}, func(t *testing.T) {
if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1" {
t.Fatalf("node clients = %v, want c0,c1", got)
}
}},
{"UpdateInbound applies remark, clients and enable", []string{"UpdateInbound", "AddUser", "RemoveUser", "ReconcileInbound"}, func() error {
ib := masterInbound("updated", false, "c0", "c1", "c2")
if err := master.AddUser(ctx, ib, nil); err != nil {
return err
}
if err := master.RemoveUser(ctx, ib, ""); err != nil {
return err
}
if _, err := master.ReconcileInbound(ctx, ib, true); err != nil {
return err
}
return master.UpdateInbound(ctx, ib, ib)
}, func(t *testing.T) {
ib, _ := nodeRow(t, contractTag)
if ib.Remark != "updated" || ib.Enable {
t.Fatalf("node remark=%q enable=%v, want updated/false", ib.Remark, ib.Enable)
}
if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1,c2" {
t.Fatalf("node clients = %v, want c0,c1,c2", got)
}
}},
{"SetInboundSubSortIndex reaches the node", []string{"SetInboundSubSortIndex"}, func() error {
return master.SetInboundSubSortIndex(ctx, masterInbound("updated", false), 7)
}, func(t *testing.T) {
if ib, _ := nodeRow(t, contractTag); ib.SubSortIndex != 7 {
t.Fatalf("node subSortIndex = %d, want 7", ib.SubSortIndex)
}
}},
{"AddClient attaches one client", []string{"AddClient"}, func() error {
return master.AddClient(ctx, masterInbound("updated", false), model.Client{
Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true,
})
}, func(t *testing.T) {
if got := nodeEmails(t); !strings.Contains(strings.Join(got, ","), "c3") {
t.Fatalf("node clients = %v, want c3 among them", got)
}
}},
{"UpdateUser changes the client's limits", []string{"UpdateUser"}, func() error {
return master.UpdateUser(ctx, masterInbound("updated", false), "c3", model.Client{
Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true, TotalGB: 5 << 30,
})
}, func(t *testing.T) {
var ct xray.ClientTraffic
database.GetDB().Where("email = ?", "c3").First(&ct)
if ct.Total != 5<<30 {
t.Fatalf("node c3 total = %d, want %d", ct.Total, int64(5<<30))
}
}},
{"ResetClientTraffic zeroes one client", []string{"ResetClientTraffic"}, func() error {
seedNodeTraffic(t, "c0")
return master.ResetClientTraffic(ctx, nil, "c0")
}, func(t *testing.T) {
if u := nodeTraffic(t, "c0"); u != 0 {
t.Fatalf("node c0 usage = %d, want 0", u)
}
}},
{"ResetClientTraffics zeroes several clients", []string{"ResetClientTraffics"}, func() error {
seedNodeTraffic(t, "c1", "c2")
return master.ResetClientTraffics(ctx, []string{"c1", "c2"})
}, func(t *testing.T) {
if u := nodeTraffic(t, "c1") + nodeTraffic(t, "c2"); u != 0 {
t.Fatalf("node c1+c2 usage = %d, want 0", u)
}
}},
{"ResetInboundTraffic zeroes the inbound", []string{"ResetInboundTraffic"}, func() error {
seedNodeTraffic(t)
return master.ResetInboundTraffic(ctx, masterInbound("updated", false))
}, func(t *testing.T) {
if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
}
}},
{"ResetAllTraffics zeroes every inbound's counters", []string{"ResetAllTraffics"}, func() error {
seedNodeTraffic(t)
return master.ResetAllTraffics(ctx)
}, func(t *testing.T) {
if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
}
}},
{"FetchTrafficSnapshot reads every part of the snapshot", []string{"FetchTrafficSnapshot"}, func() error {
_, err := master.FetchTrafficSnapshot(ctx)
return err
}, nil},
{"PushGlobalClientTraffics is accepted", []string{"PushGlobalClientTraffics"}, func() error {
return master.PushGlobalClientTraffics(ctx, "master-guid", []*xray.ClientTraffic{{Email: "c0", Up: 1, Down: 2}})
}, nil},
{"client IP sync is accepted both ways", []string{"FetchAllClientIps", "PushAllClientIps", "FetchClientIpsByGuid"}, func() error {
ips, err := master.FetchAllClientIps(ctx)
if err != nil {
return err
}
if err := master.PushAllClientIps(ctx, ips); err != nil {
return err
}
_, err = master.FetchClientIpsByGuid(ctx)
return err
}, nil},
{"host groups, descendants and web cert files are readable", []string{"FetchHostGroups", "GetDescendants", "GetWebCertFiles", "ListInboundOptions", "ListRemoteTags"}, func() error {
if _, err := master.FetchHostGroups(ctx); err != nil {
return err
}
if _, err := master.GetDescendants(ctx); err != nil {
return err
}
if _, err := master.GetWebCertFiles(ctx); err != nil {
return err
}
if _, err := master.ListInboundOptions(ctx); err != nil {
return err
}
_, err := master.ListRemoteTags(ctx)
return err
}, nil},
{"RestartXray is accepted by the node", []string{"RestartXray"}, func() error {
// No core binary here: only the node's own restart failure may come back.
if err := master.RestartXray(ctx); err != nil && !strings.Contains(err.Error(), "rebooting the Xray") {
return err
}
return nil
}, nil},
{"DeleteUser detaches the client from the inbound", []string{"DeleteUser"}, func() error {
return master.DeleteUser(ctx, masterInbound("updated", false), "c3")
}, func(t *testing.T) {
if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c3") {
t.Fatalf("node clients = %v, want c3 gone", got)
}
}},
{"DeleteClient removes the client everywhere", []string{"DeleteClient"}, func() error {
return master.DeleteClient(ctx, "c2")
}, func(t *testing.T) {
if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c2") {
t.Fatalf("node clients = %v, want c2 gone", got)
}
}},
{"DelInbound removes the inbound", []string{"DelInbound"}, func() error {
return master.DelInbound(ctx, masterInbound("updated", false))
}, func(t *testing.T) {
if _, ok := nodeRow(t, contractTag); ok {
t.Fatal("node still has the inbound")
}
}},
}
covered := map[string]bool{}
for _, c := range cells {
for _, m := range c.covers {
covered[m] = true
}
}
assertEveryRemoteCallCovered(t, covered)
for _, c := range cells {
t.Run(c.name, func(t *testing.T) {
node.takeRefused()
if err := c.run(); err != nil {
t.Fatalf("master call failed: %v", err)
}
if refused := node.takeRefused(); len(refused) != 0 {
t.Fatalf("node refused master requests: %v", refused)
}
if c.check != nil {
c.check(t)
}
})
}
})
}
}
// Remote methods that never reach the node, or that this table must not run.
var remoteMethodsOutsideContract = map[string]string{
"Name": "local label",
"RecordAdoptedInbound": "local fingerprint bookkeeping",
"AdoptInboundAlias": "local alias bookkeeping",
"AdoptedInboundAliases": "local alias bookkeeping",
"AdvancePushedInbound": "local fingerprint bookkeeping",
"ForgetPushedInbound": "local fingerprint bookkeeping",
"UpdatePanel": "replaces the node binary; node-sync is denied it on purpose (#6201)",
}
// A Remote method with no cell is how activeInbounds and bulkResetTraffic
// drifted out of the node-sync allowlist unnoticed.
func assertEveryRemoteCallCovered(t *testing.T, covered map[string]bool) {
t.Helper()
rt := reflect.TypeOf(&runtime.Remote{})
for i := 0; i < rt.NumMethod(); i++ {
name := rt.Method(i).Name
if _, skip := remoteMethodsOutsideContract[name]; skip {
continue
}
if !covered[name] {
t.Errorf("runtime.Remote.%s has no cell in TestMasterNodeContract", name)
}
}
}
+36 -2
View File
@@ -17,7 +17,8 @@ import (
func TestReconcileInbound_SkipsUnchanged(t *testing.T) { func TestReconcileInbound_SkipsUnchanged(t *testing.T) {
var pushes atomic.Int32 var pushes atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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/") { if r.Method == http.MethodPost && (strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") ||
strings.Contains(r.URL.Path, "/panel/api/inbounds/add")) {
pushes.Add(1) pushes.Add(1)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -283,7 +284,7 @@ func TestDelInboundDropsReconcileFingerprint(t *testing.T) {
ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
r.cacheSet(ib.Tag, 7) r.cacheSet(ib.Tag, 7)
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed { if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err) t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err)
} }
if err := r.DelInbound(context.Background(), ib); err != nil { if err := r.DelInbound(context.Background(), ib); err != nil {
@@ -317,3 +318,36 @@ func TestUpdateInboundFallbackAddSeedsReconcileFingerprint(t *testing.T) {
t.Fatalf("reconcile sent %d full inbound updates, want 0", got) t.Fatalf("reconcile sent %d full inbound updates, want 0", got)
} }
} }
// An inbound deleted on the node must be re-created by the next reconcile; a
// cached tag→id from before the delete used to send update/<gone id> forever.
func TestReconcileInbound_RecreatesInboundTheNodeLost(t *testing.T) {
var adds, staleUpdates atomic.Int32
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/list"):
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
case strings.Contains(r.URL.Path, "/panel/api/inbounds/update/"):
staleUpdates.Add(1)
_, _ = w.Write([]byte(`{"success":false,"msg":"record not found"}`))
case strings.Contains(r.URL.Path, "/panel/api/inbounds/add"):
adds.Add(1)
_, _ = w.Write([]byte(`{"success":true,"obj":{"id":9,"tag":"in-1"}}`))
default:
_, _ = w.Write([]byte(`{"success":true}`))
}
}))
defer srv.Close()
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
ib := &model.Inbound{Tag: "n1-in-1", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
r.cacheSet("in-1", 7)
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
t.Fatalf("reconcile of a lost inbound: pushed=%v err=%v, want a re-create", pushed, err)
}
if staleUpdates.Load() != 0 || adds.Load() != 1 {
t.Fatalf("updates to the stale id=%d adds=%d, want 0 and 1", staleUpdates.Load(), adds.Load())
}
}
+23
View File
@@ -237,6 +237,7 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set(wirecodec.MasterPushHeader, "1")
if contentType != "" { if contentType != "" {
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
} }
@@ -359,6 +360,15 @@ func (r *Remote) cacheDel(tag string) {
delete(r.pushedFP, tag) delete(r.pushedFP, tag)
} }
// forgetTag drops every tag form cacheGetTag would match, once the node reports
// none of them, so the next resolve re-reads the node instead of a deleted id.
func (r *Remote) forgetTag(tag string) {
prefix := nodeInboundTagPrefix(r.node.Id)
bare := strings.TrimPrefix(tag, prefix)
r.cacheDel(bare)
r.cacheDel(prefix + bare)
}
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) { func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
if err := r.refreshRemoteIDs(ctx); err != nil { if err := r.refreshRemoteIDs(ctx); err != nil {
return nil, err return nil, err
@@ -494,6 +504,8 @@ func (r *Remote) ReconcileInbound(ctx context.Context, ib *model.Inbound, exists
if ok && prev == fp { if ok && prev == fp {
return false, nil return false, nil
} }
} else {
r.forgetTag(ib.Tag)
} }
if err := r.UpdateInbound(ctx, ib, ib); err != nil { if err := r.UpdateInbound(ctx, ib, ib); err != nil {
return false, err return false, err
@@ -516,6 +528,17 @@ func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
r.recordPushedInbound(ib) r.recordPushedInbound(ib)
} }
// ForgetPushedInbound drops the reconcile-skip fingerprint once the node is seen
// without the payload it stamped, so the next reconcile re-sends the inbound.
func (r *Remote) ForgetPushedInbound(tag string) {
prefix := nodeInboundTagPrefix(r.node.Id)
bare := strings.TrimPrefix(tag, prefix)
r.mu.Lock()
delete(r.pushedFP, bare)
delete(r.pushedFP, prefix+bare)
r.mu.Unlock()
}
// AdoptInboundAlias records a deployed alias without mutating either panel. // AdoptInboundAlias records a deployed alias without mutating either panel.
// The runtime association is rediscovered after a master restart. // The runtime association is rediscovered after a master restart.
func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) { func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {
+21 -12
View File
@@ -30,8 +30,8 @@ func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
} }
} }
// The merge must soft-orphan, not delete: everything stays recoverable until // A partial snapshot (node alive, still serving another client) authoritatively drops one;
// the grace period has elapsed and the reaper confirms nothing reclaimed it. // the merge soft-orphans, recoverable until the grace elapses and the reaper confirms it.
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) { func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
db := initTrafficTestDB(t) db := initTrafficTestDB(t)
svc := &InboundService{} svc := &InboundService{}
@@ -40,16 +40,20 @@ func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "gone@x" const email = "gone@x"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email) const keep = "keep@x"
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email) createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
syncNodeWithSettings(t, svc, 1, "n1-in", settings, bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
xray.ClientTraffic{Email: keep, Enable: true},
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true}) xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 { if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf) 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 { keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
t.Fatalf("orphaning merge: %v", err) t.Fatalf("orphaning merge: %v", err)
} }
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 { if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
@@ -84,7 +88,7 @@ func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
} }
} }
// A client the node reports again was never gone: clearing the mark is what // A client the node reports again (partial snapshot) was never gone: clearing the mark
// turns a bad merge into a recoverable blip instead of a delayed deletion. // turns a bad merge into a recoverable blip instead of a delayed deletion.
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) { func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
db := initTrafficTestDB(t) db := initTrafficTestDB(t)
@@ -94,19 +98,24 @@ func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "flaky@x" const email = "flaky@x"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email) const keep = "keep@x"
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email) createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
syncNodeWithSettings(t, svc, 1, "n1-in", settings, bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
xray.ClientTraffic{Email: keep, Enable: true},
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true}) xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil { keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
t.Fatalf("orphaning merge: %v", err) t.Fatalf("orphaning merge: %v", err)
} }
if readOrphanMark(t, db, email) <= 0 { if readOrphanMark(t, db, email) <= 0 {
t.Fatal("setup: expected the merge to mark the client") t.Fatal("setup: expected the merge to mark the client")
} }
syncNodeWithSettings(t, svc, 1, "n1-in", settings, syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
xray.ClientTraffic{Email: keep, Enable: true},
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true}) xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 { if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
@@ -287,6 +287,9 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
} }
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) { func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
// GetAmneziaWGLogs appends peer handshake activity, which reads the DB;
// own a throwaway one so -shuffle can't leave us the global nil DB.
setupConflictDB(t)
logger.InitLogger(logging.DEBUG) logger.InitLogger(logging.DEBUG)
logger.Info("amneziawg: started interface awg1 for inbound 1") logger.Info("amneziawg: started interface awg1 for inbound 1")
logger.Info("xray: unrelated line that must never show up here") logger.Info("xray: unrelated line that must never show up here")
+43 -1
View File
@@ -427,6 +427,20 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
return &a return &a
} }
// snapshotDropsEveryHubClient reports a node that lists no clients where the hub
// still links some: a reset or half-started node, never an authoritative removal.
func snapshotDropsEveryHubClient(tx *gorm.DB, inboundID int, wireSettings string) bool {
clients, err := ParseInboundSettingsClients(wireSettings)
if err != nil || len(clients) > 0 {
return false
}
var links int64
if err := tx.Table("client_inbounds").Where("inbound_id = ?", inboundID).Count(&links).Error; err != nil {
return false
}
return links > 0
}
// clientEmailsOwnedElsewhere returns the emails attached only to inbounds of // clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
// other nodes: email is unique, so adopting one would overwrite a client this // other nodes: email is unique, so adopting one would overwrite a client this
// node does not serve. Attached nowhere means soft-orphaned, hence adoptable. // node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
@@ -644,6 +658,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
wireSettings string wireSettings string
} }
var pendingAdopts []pendingAdopt var pendingAdopts []pendingAdopt
degradedInbounds := map[int]string{}
newInboundIDs := make(map[int]struct{}) newInboundIDs := make(map[int]struct{})
@@ -782,7 +797,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
adoptedSettings = deduped adoptedSettings = deduped
} }
updates := map[string]any{} updates := map[string]any{}
if !dirty { if !dirty && snapshotDropsEveryHubClient(tx, c.Id, adoptedSettings) {
degradedInbounds[c.Id] = c.Tag
} else if !dirty {
// Defer lifecycle lift until after client_traffics absorbs this tick's // Defer lifecycle lift until after client_traffics absorbs this tick's
// deltas so quota stale-disable matches SQL (#6228). // deltas so quota stale-disable matches SQL (#6228).
pendingAdopts = append(pendingAdopts, pendingAdopt{ pendingAdopts = append(pendingAdopts, pendingAdopt{
@@ -1121,6 +1138,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if k.inboundID != c.Id { if k.inboundID != c.Id {
continue continue
} }
if _, degraded := degradedInbounds[c.Id]; degraded {
continue
}
if _, kept := snapEmails[k.email]; kept { if _, kept := snapEmails[k.email]; kept {
continue continue
} }
@@ -1228,6 +1248,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
applyMasterClientLifecycle(&clients[i], existing, csPtr) applyMasterClientLifecycle(&clients[i], existing, csPtr)
filtered = append(filtered, clients[i]) filtered = append(filtered, clients[i])
} }
// A degraded node (reset/restart/removal) reports zero clients for an inbound the
// hub populates; adopting it empties links and ReapSyncOrphans deletes shared clients (#6734).
if _, degraded := degradedInbounds[c.Id]; degraded {
logger.Warningf("setRemoteTraffic: node %d reported zero clients for tag %q while the hub has %d attached — keeping them and re-pushing", nodeID, snapIb.Tag, len(oldEmailsRows))
syncFailedInbounds[c.Id] = struct{}{}
continue
}
localEmails := make([]string, 0, len(filtered)) localEmails := make([]string, 0, len(filtered))
for i := range filtered { for i := range filtered {
if filtered[i].Email != "" { if filtered[i].Email != "" {
@@ -1337,6 +1364,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
committed = true committed = true
if len(degradedInbounds) > 0 {
if mgr := runtime.GetManager(); mgr != nil {
if rt, rtErr := mgr.RuntimeFor(&nodeID); rtErr == nil {
if rem, ok := rt.(*runtime.Remote); ok {
for _, tag := range degradedInbounds {
rem.ForgetPushedInbound(tag)
}
}
}
}
if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil {
logger.Warningf("setRemoteTraffic: mark node %d dirty after an empty snapshot failed: %v", nodeID, err)
}
}
if lifecycleLifted && !dirty { if lifecycleLifted && !dirty {
var already model.Node var already model.Node
if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty { if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty {
@@ -0,0 +1,205 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// linkCount returns how many client_inbounds links a client currently has,
// across every inbound — the value ReapSyncOrphans checks before deleting.
func linkCount(t *testing.T, db *gorm.DB, email string) int64 {
t.Helper()
var n int64
if err := db.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ?", email).
Count(&n).Error; err != nil {
t.Fatalf("count links for %q: %v", email, err)
}
return n
}
// A degraded node reporting zero clients for an inbound the hub populates must
// keep its links and never orphan-mark, or SyncInbound/ReapSyncOrphans delete the row.
func TestSetRemoteTraffic_EmptySnapshotKeepsClients(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "svc@x")
settings := `{"clients":[{"email":"svc@x","enable":true}]}`
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", settings,
xray.ClientTraffic{Email: "svc@x", Enable: true}), false, false); err != nil {
t.Fatalf("seed sync: %v", err)
}
if n := linkCount(t, db, "svc@x"); n != 1 {
t.Fatalf("setup: svc@x links=%d, want 1", n)
}
// The node returns an empty snapshot — the trigger that deleted real clients.
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
t.Fatalf("empty-snapshot sync: %v", err)
}
if rec, _ := countClientRows(t, db, "svc@x"); rec != 1 {
t.Fatalf("empty snapshot deleted the client row: clients=%d, want 1", rec)
}
if n := linkCount(t, db, "svc@x"); n != 1 {
t.Fatalf("empty snapshot stripped the client link: links=%d, want 1", n)
}
if at := readOrphanMark(t, db, "svc@x"); at != 0 {
t.Fatalf("empty snapshot orphan-marked a live client: sync_orphaned_at=%d, want 0", at)
}
// The hub must keep the client in the inbound's settings, or reconcile re-pushes
// an empty blob to the node and the clients never come back (#6734).
var ib model.Inbound
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
t.Fatalf("read central inbound: %v", err)
}
if !strings.Contains(ib.Settings, "svc@x") {
t.Fatalf("empty snapshot blanked the inbound settings: %q", ib.Settings)
}
}
// The guard is narrow: a snapshot still carrying a client is authoritative, so a
// client the node really dropped is unlinked and orphan-marked; only all-empty is degraded.
func TestSetRemoteTraffic_PartialSnapshotStillPrunes(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "keep@x")
bothSettings := `{"clients":[{"email":"keep@x","enable":true},{"email":"drop@x","enable":true}]}`
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", bothSettings,
xray.ClientTraffic{Email: "keep@x", Enable: true},
xray.ClientTraffic{Email: "drop@x", Enable: true}), false, false); err != nil {
t.Fatalf("seed sync: %v", err)
}
if n := linkCount(t, db, "drop@x"); n != 1 {
t.Fatalf("setup: drop@x links=%d, want 1", n)
}
// Node now reports only keep@x — drop@x was genuinely removed there.
keepOnlySettings := `{"clients":[{"email":"keep@x","enable":true}]}`
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnlySettings,
xray.ClientTraffic{Email: "keep@x", Enable: true}), false, false); err != nil {
t.Fatalf("partial-snapshot sync: %v", err)
}
if n := linkCount(t, db, "keep@x"); n != 1 {
t.Fatalf("partial snapshot dropped a reported client: keep@x links=%d, want 1", n)
}
if n := linkCount(t, db, "drop@x"); n != 0 {
t.Fatalf("partial snapshot kept an unreported client linked: drop@x links=%d, want 0", n)
}
if at := readOrphanMark(t, db, "drop@x"); at <= 0 {
t.Fatalf("partial snapshot did not orphan-mark the removed client: sync_orphaned_at=%d, want >0", at)
}
}
// Keeping the hub's settings is not recovery: the node is only healed once the
// hub actually re-pushes them, which needs a dirty node and a stale fingerprint.
func TestSetRemoteTraffic_EmptySnapshotRepushesHubClients(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
var mu sync.Mutex
var pushed []string
writeOK := func(w http.ResponseWriter, obj any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
}
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
writeOK(w, []map[string]any{{"id": 7, "tag": "deg-in", "port": 41001, "protocol": "vless"}})
})
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mu.Lock()
pushed = append(pushed, r.PostForm.Get("settings"))
mu.Unlock()
writeOK(w, nil)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
node := reconcileTestNode(t, ts, "deg-node", "all", nil)
settings := `{"clients":[{"email":"svc@x","enable":true,"id":"11111111-1111-1111-1111-111111111111"}]}`
nid := node.Id
if err := db.Create(&model.Inbound{UserId: 1, Tag: "deg-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid, Settings: settings}).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
rt := runtime.NewRemote(node, nil)
mgr := runtime.NewManager(runtime.LocalDeps{})
mgr.SetRuntimeOverride(node.Id, rt)
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
if _, err := svc.setRemoteTrafficLocked(node.Id, snapshotWithClients(t, "deg-in", settings,
xray.ClientTraffic{Email: "svc@x", Enable: true}), false, false); err != nil {
t.Fatalf("seed sync: %v", err)
}
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
t.Fatalf("first reconcile: %v", err)
}
mu.Lock()
pushed = nil
mu.Unlock()
if _, err := svc.setRemoteTrafficLocked(node.Id, snapshotWithoutClients(t, "deg-in"), false, false); err != nil {
t.Fatalf("empty-snapshot sync: %v", err)
}
var after model.Node
if err := db.Where("id = ?", node.Id).First(&after).Error; err != nil {
t.Fatalf("reload node: %v", err)
}
if !after.ConfigDirty {
t.Fatal("empty snapshot left the node clean: the job never reconciles it, so the node stays without its clients")
}
if err := svc.ReconcileNode(context.Background(), rt, &after); err != nil {
t.Fatalf("reconcile after empty snapshot: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(pushed) != 1 || !strings.Contains(pushed[0], "svc@x") {
t.Fatalf("reconcile after empty snapshot pushed %d settings payload(s) %q, want one carrying svc@x", len(pushed), pushed)
}
}
// The traffic a client used while its node reported nothing must still count
// once the node reports it again.
func TestSetRemoteTraffic_EmptySnapshotKeepsTrafficBaseline(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "svc@x")
settings := `{"clients":[{"email":"svc@x","enable":true}]}`
for _, used := range []int64{100, 200} {
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: "svc@x", Up: used, Down: used, Enable: true})
}
before := readTraffic(t, db, "svc@x")
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
t.Fatalf("empty-snapshot sync: %v", err)
}
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: "svc@x", Up: 250, Down: 250, Enable: true})
assertUpDown(t, readTraffic(t, db, "svc@x"), before.Up+50, before.Down+50, "after the node recovered")
}
+3 -1
View File
@@ -94,10 +94,12 @@ func NewHub() *Hub {
} }
} }
// Traffic messages carry independent partial updates from Xray, TUIC, and node
// polling jobs. Throttling by message type would silently discard one source
// when two jobs publish within the throttle window.
var throttledMessageTypes = map[MessageType]struct{}{ var throttledMessageTypes = map[MessageType]struct{}{
MessageTypeInbounds: {}, MessageTypeInbounds: {},
MessageTypeOutbounds: {}, MessageTypeOutbounds: {},
MessageTypeTraffic: {},
MessageTypeClientStats: {}, MessageTypeClientStats: {},
} }
+43 -14
View File
@@ -81,21 +81,45 @@ func TestHub_BroadcastDeliversToClient(t *testing.T) {
waitClientCount(t, h, 1) waitClientCount(t, h, 1)
h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"}) h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
select { select {
case raw := <-c.Send: case raw := <-c.Send:
var m Message var message Message
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw) t.Fatalf("payload is not valid JSON: %v", err)
} }
if m.Type != MessageTypeStatus { if message.Type != MessageTypeStatus {
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus) t.Fatalf("message type = %q, want %q", message.Type, MessageTypeStatus)
} }
if m.Time == 0 { if message.Time == 0 {
t.Fatal("Time should be set to a non-zero unix-millis value") t.Fatal("Time should be set to a non-zero unix-millis value")
} }
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for broadcast to reach client") t.Fatal("timed out waiting for status broadcast to reach client")
}
for _, source := range []string{"tuic", "xray"} {
h.Broadcast(MessageTypeTraffic, map[string]string{"source": source})
}
for _, wantSource := range []string{"tuic", "xray"} {
select {
case raw := <-c.Send:
var message struct {
Type MessageType `json:"type"`
Payload map[string]string `json:"payload"`
}
if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("traffic event is not valid JSON: %v", err)
}
if message.Type != MessageTypeTraffic {
t.Fatalf("message type = %q, want %q", message.Type, MessageTypeTraffic)
}
if got := message.Payload["source"]; got != wantSource {
t.Fatalf("traffic source = %q, want %q", got, wantSource)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("timed out waiting for %q traffic event", wantSource)
}
} }
} }
@@ -156,23 +180,28 @@ func TestHub_ShouldThrottle(t *testing.T) {
t.Fatal("non-gated message type should never throttle on second call") t.Fatal("non-gated message type should never throttle on second call")
} }
if h.shouldThrottle(MessageTypeTraffic) { if h.shouldThrottle(MessageTypeInbounds) {
t.Fatal("first call for gated type should not throttle") t.Fatal("first call for gated type should not throttle")
} }
if !h.shouldThrottle(MessageTypeTraffic) { if !h.shouldThrottle(MessageTypeInbounds) {
t.Fatal("immediate second call for gated type should throttle") t.Fatal("immediate second call for gated type should throttle")
} }
for i := range 2 {
if h.shouldThrottle(MessageTypeTraffic) {
t.Fatalf("traffic event %d must not be throttled", i+1)
}
}
} }
func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) { func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
h := NewHub() h := NewHub()
defer h.Stop() defer h.Stop()
if h.shouldThrottle(MessageTypeTraffic) {
t.Fatal("first Traffic call should not throttle")
}
if h.shouldThrottle(MessageTypeInbounds) { if h.shouldThrottle(MessageTypeInbounds) {
t.Fatal("first Inbounds call should not throttle even after Traffic") t.Fatal("first Inbounds call should not throttle")
}
if h.shouldThrottle(MessageTypeOutbounds) {
t.Fatal("first Outbounds call should not throttle even after Inbounds")
} }
} }
+185
View File
@@ -940,6 +940,180 @@ setup_fail2ban() {
return 0 return 0
} }
# The hardened unit makes /usr, /boot, /efi and /etc read-only. The panel's own
# updater is expected to escape that sandbox by running this script through a
# transient systemd-run unit; when systemd-run is unavailable it starts this
# script as a plain child instead, and that child inherits the sandbox and then
# cannot write anything this update needs. Say so once, up front, instead of
# dying partway through with "Failed to download x-ui".
require_writable_update_paths() {
local dir probe
for dir in "${xui_folder%/*}" "/usr/bin"; do
[[ -n "$dir" && -d "$dir" ]] || continue
probe="${dir}/.x-ui-write-test.$$"
# A real write test rather than [[ -w ]]: this runs as root, where a
# permission bit means little and the test only reflects the file mode
# and the mount flags, not an immutable attribute or a full filesystem.
if ! : > "$probe" 2> /dev/null; then
_fail "ERROR: ${dir} is not writable for this process (read-only mount, attribute or full filesystem). The panel's fallback updater cannot run inside the hardened systemd sandbox; update from the panel UI (which uses systemd-run) or run 'x-ui update' in a shell."
fi
rm -f "$probe"
done
}
# Major version of the local systemd, 0 when it cannot be determined. The
# SystemCallFilter=@system-service group only exists from systemd 239 on (other
# @-named groups exist since 231); on older versions an unknown group is not
# ignored safely, the filter stays in force and leaves a whitelist the panel
# cannot run under.
_xui_systemd_major_version() {
local version=""
if command -v systemctl > /dev/null 2>&1; then
version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')"
fi
if [[ ! "$version" =~ ^[0-9]+$ ]]; then
echo 0
return 0
fi
echo "$version"
}
# The shipped units list hardening that older systemd does not know: the
# directive is logged and ignored at load time rather than rejected, so the
# panel still starts, only without that protection. Each entry is the systemd
# release that introduced the directive (systemd.exec(5)); everything else in
# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219).
# SystemCallFilter= is listed because the drop-in only writes it from 239 on.
_xui_warn_unsupported_hardening() {
local version entry missing=""
version="$(_xui_systemd_major_version)"
[[ "$version" -gt 0 ]] || return 0
for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \
ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \
SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \
ProtectKernelLogs:244 ProtectClock:245; do
if [[ "$version" -lt "${entry##*:}" ]]; then
missing="${missing:+$missing, }${entry%%:*} (${entry##*:})"
fi
done
[[ -n "$missing" ]] || return 0
echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}"
echo " Not applied, needs a newer systemd: ${missing}."
if [[ "$version" -lt 231 ]]; then
echo " The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs."
fi
echo " The rest of the hardening is in force. Upgrade systemd to apply the above."
return 0
}
# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem=
# strict would make the whole hierarchy read-only (only the kernel API
# filesystems stay as they are), and that would break the panel's own use of
# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER,
# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place
# the files in -- the unit's WorkingDirectory on a stock install, and what a
# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit
# either misses a relocated store -- the panel then cannot write its own SQLite
# database and sits in a Restart=on-failure loop -- or forces the operator to
# edit a file that every install/update overwrites from the release tarball.
# install.sh and update.sh therefore regenerate the drop-in from the folders
# actually in use, and the unit's own ReadWritePaths only carry the
# plain-install defaults. A relocated store means re-running install or update:
# the drop-in is only written here.
_xui_service_write_paths_dropin() {
# $1 is the env file to resolve the XUI_* folders from; callers pass nothing
# and get the OS-specific path the unit itself uses.
local env_file="${1:-}"
local dropin_dir dropin temp_file
local db_folder log_folder bin_folder main_folder
local path line="" whitespace_paths="" seen_paths="" escaped_path
if [[ -z "$env_file" ]]; then
env_file="$(xui_env_file_path)"
fi
if [[ -r "$env_file" ]]; then
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
fi
# XUI_* wins over the script's own default: the unit hands that same env
# file to the panel through EnvironmentFile=, so these are the folders it
# will actually use.
main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}"
db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}"
# An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working
# directory, which the unit sets to the main folder.
bin_folder="${XUI_BIN_FOLDER:-bin}"
if [[ "$bin_folder" != /* ]]; then
bin_folder="${main_folder%/}/${bin_folder#./}"
fi
for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do
[[ "$path" == /* ]] || continue
# ReadWritePaths= is a whitespace-separated list, and a folder whose
# name contains whitespace cannot be written into it without relying on
# quoting. A wrong entry makes systemd reject the whole drop-in and the
# panel would not start, so leave such a folder out and say so instead.
if [[ "$path" != "${path//[[:space:]]/}" ]]; then
whitespace_paths="${whitespace_paths:+$whitespace_paths }$path"
continue
fi
case " $seen_paths " in
*" $path "*) continue ;;
esac
seen_paths="${seen_paths}${seen_paths:+ }$path"
# systemd expands %-specifiers in unit files, so a folder name carrying
# a literal % has to be written as %%, or the entry stops naming the
# folder systemd is meant to keep writable.
escaped_path="${path//%/%%}"
line="${line} -${escaped_path}"
done
if [[ -n "$whitespace_paths" ]]; then
echo "Warning: these folders contain whitespace and were left out of" >&2
echo " 10-xui-sandbox.conf: $whitespace_paths" >&2
echo " The panel cannot write to them under the unit's sandbox." >&2
fi
line="${line# }"
[[ -n "$line" ]] || return 1
dropin_dir="${xui_service}/x-ui.service.d"
dropin="${dropin_dir}/10-xui-sandbox.conf"
temp_file="${dropin}.tmp.$$"
mkdir -p "$dropin_dir" || return 1
cat > "$temp_file" << EOF
# Regenerated by install.sh/update.sh on every install and update: edits here
# are lost, and the list only reflects the XUI_* variables read from
# ${env_file} at that moment. Re-run install/update after moving a store.
# It lists the folders the panel writes to. Put local additions in their own
# drop-in, for example 20-x-ui-local.conf, which nothing here touches.
[Service]
ReadWritePaths=${line}
ReadWriteDirectories=${line}
EOF
if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then
cat >> "$temp_file" << 'EOF'
# @system-service needs systemd >= 239; on older versions the unknown group
# would leave the panel with a filter it cannot start under (x-ui.service.*).
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
EOF
fi
if [[ ! -s "$temp_file" ]]; then
rm -f "$temp_file"
return 1
fi
chmod 644 "$temp_file"
mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; }
if command -v systemctl > /dev/null 2>&1; then
systemctl daemon-reload > /dev/null 2>&1 || true
fi
return 0
}
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file + # Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a # atomic mv, so a failed cp/curl or an interrupted mv never leaves a
# truncated unit file at the live path -- systemd would then fail to parse # truncated unit file at the live path -- systemd would then fail to parse
@@ -971,6 +1145,11 @@ _install_xui_service_unit() {
rm -f "$temp_file" rm -f "$temp_file"
return 1 return 1
fi fi
if ! _xui_service_write_paths_dropin; then
echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}"
echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}"
fi
_xui_warn_unsupported_hardening
return 0 return 0
} }
@@ -1171,6 +1350,11 @@ update_x-ui() {
chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1 chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
fi fi
# Finish the schema/data migrations before the service starts, so the service and
# config_after_update's CLI calls never run them on the same database at once (#6728).
echo -e "${green}Migrating database...${plain}"
"${xui_folder}/x-ui" migrate
if [[ $release == "alpine" ]]; then if [[ $release == "alpine" ]]; then
echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}" echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
xui_rc_temp="/etc/init.d/x-ui.tmp.$$" xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
@@ -1287,5 +1471,6 @@ update_x-ui() {
} }
echo -e "${green}Running...${plain}" echo -e "${green}Running...${plain}"
require_writable_update_paths
install_base install_base
update_x-ui $1 update_x-ui $1
+76
View File
@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
# The panel intentionally stays root: it supervises the Xray child processes,
# edits netfilter state and reads TLS private keys. These settings only bound
# what a panel-level flaw can reach.
#
# PrivateTmp=yes is deliberately absent: the web updater writes its script into
# /tmp and hands the absolute path to a "systemd-run" transient unit, which
# does not share this service's private /tmp (the download would vanish).
NoNewPrivileges=yes
ProtectSystem=full
# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
# floor, not the whole list: install.sh and update.sh regenerate a drop-in
# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
# the list survives an update instead of being reset to these defaults. Changing
# one of those variables in the env file is not enough by itself: the drop-in has
# to be refreshed as well, i.e. install or update the panel again.
# Add local extras in your own drop-in (e.g. 20-local.conf).
# The leading '-' keeps the unit startable if a path does not exist yet.
# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
# read-only, everything else stays writable. So this list matters for stores
# under those trees -- the default main folder under /usr/local is one.
#
# The in-panel updater is expected to leave this sandbox: it runs update.sh
# through a transient systemd-run unit, which does not inherit these settings.
# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
# here -- update.sh stages the release archive beside the main folder, replaces
# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
# message instead of failing halfway, and the sandbox deliberately does not
# grant /usr or /etc to accommodate it.
# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
# read-only rather than yes: installs keep TLS certs under /root/cert, and the
# panel must still be able to read them.
ProtectHome=read-only
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
UMask=0077
# AF_NETLINK for interface/route lookups and the ip(8) child used by the
# AmneziaWG IPv6-alias feature.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
# for raw sockets and SO_BINDTODEVICE.
#
# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
# subtracted from root's own privileges too: without it root can only read a
# file when the owner/group/other bits let uid 0 through, and any TLS private
# key belonging to another account becomes unreadable -- a certificate issued to
# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
# reads worked before the sandbox because the panel is root.
# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
# DAC_OVERRIDE, so it would only widen the set without adding anything.
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
SystemCallArchitectures=native
# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
# (other @-named groups exist since 231), and older systemd does not ignore an
# unknown group name gracefully: on
# <231 the name fails to resolve and the filter stays the built-in whitelist of
# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
# "systemctl --version" reports 239 or newer.
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+76
View File
@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
# The panel intentionally stays root: it supervises the Xray child processes,
# edits netfilter state and reads TLS private keys. These settings only bound
# what a panel-level flaw can reach.
#
# PrivateTmp=yes is deliberately absent: the web updater writes its script into
# /tmp and hands the absolute path to a "systemd-run" transient unit, which
# does not share this service's private /tmp (the download would vanish).
NoNewPrivileges=yes
ProtectSystem=full
# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
# floor, not the whole list: install.sh and update.sh regenerate a drop-in
# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
# the list survives an update instead of being reset to these defaults. Changing
# one of those variables in the env file is not enough by itself: the drop-in has
# to be refreshed as well, i.e. install or update the panel again.
# Add local extras in your own drop-in (e.g. 20-local.conf).
# The leading '-' keeps the unit startable if a path does not exist yet.
# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
# read-only, everything else stays writable. So this list matters for stores
# under those trees -- the default main folder under /usr/local is one.
#
# The in-panel updater is expected to leave this sandbox: it runs update.sh
# through a transient systemd-run unit, which does not inherit these settings.
# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
# here -- update.sh stages the release archive beside the main folder, replaces
# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
# message instead of failing halfway, and the sandbox deliberately does not
# grant /usr or /etc to accommodate it.
# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
# read-only rather than yes: installs keep TLS certs under /root/cert, and the
# panel must still be able to read them.
ProtectHome=read-only
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
UMask=0077
# AF_NETLINK for interface/route lookups and the ip(8) child used by the
# AmneziaWG IPv6-alias feature.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
# for raw sockets and SO_BINDTODEVICE.
#
# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
# subtracted from root's own privileges too: without it root can only read a
# file when the owner/group/other bits let uid 0 through, and any TLS private
# key belonging to another account becomes unreadable -- a certificate issued to
# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
# reads worked before the sandbox because the panel is root.
# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
# DAC_OVERRIDE, so it would only widen the set without adding anything.
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
SystemCallArchitectures=native
# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
# (other @-named groups exist since 231), and older systemd does not ignore an
# unknown group name gracefully: on
# <231 the name fails to resolve and the filter stays the built-in whitelist of
# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
# "systemctl --version" reports 239 or newer.
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+76
View File
@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
# The panel intentionally stays root: it supervises the Xray child processes,
# edits netfilter state and reads TLS private keys. These settings only bound
# what a panel-level flaw can reach.
#
# PrivateTmp=yes is deliberately absent: the web updater writes its script into
# /tmp and hands the absolute path to a "systemd-run" transient unit, which
# does not share this service's private /tmp (the download would vanish).
NoNewPrivileges=yes
ProtectSystem=full
# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
# floor, not the whole list: install.sh and update.sh regenerate a drop-in
# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
# the list survives an update instead of being reset to these defaults. Changing
# one of those variables in the env file is not enough by itself: the drop-in has
# to be refreshed as well, i.e. install or update the panel again.
# Add local extras in your own drop-in (e.g. 20-local.conf).
# The leading '-' keeps the unit startable if a path does not exist yet.
# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
# read-only, everything else stays writable. So this list matters for stores
# under those trees -- the default main folder under /usr/local is one.
#
# The in-panel updater is expected to leave this sandbox: it runs update.sh
# through a transient systemd-run unit, which does not inherit these settings.
# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
# here -- update.sh stages the release archive beside the main folder, replaces
# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
# message instead of failing halfway, and the sandbox deliberately does not
# grant /usr or /etc to accommodate it.
# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
# read-only rather than yes: installs keep TLS certs under /root/cert, and the
# panel must still be able to read them.
ProtectHome=read-only
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
UMask=0077
# AF_NETLINK for interface/route lookups and the ip(8) child used by the
# AmneziaWG IPv6-alias feature.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
# for raw sockets and SO_BINDTODEVICE.
#
# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
# subtracted from root's own privileges too: without it root can only read a
# file when the owner/group/other bits let uid 0 through, and any TLS private
# key belonging to another account becomes unreadable -- a certificate issued to
# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
# reads worked before the sandbox because the panel is root.
# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
# DAC_OVERRIDE, so it would only widen the set without adding anything.
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
SystemCallArchitectures=native
# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
# (other @-named groups exist since 231), and older systemd does not ignore an
# unknown group name gracefully: on
# <231 the name fails to resolve and the filter stays the built-in whitelist of
# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
# "systemctl --version" reports 239 or newer.
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+6
View File
@@ -294,6 +294,12 @@ uninstall() {
systemctl stop x-ui systemctl stop x-ui
systemctl disable x-ui systemctl disable x-ui
rm ${xui_service}/x-ui.service -f rm ${xui_service}/x-ui.service -f
# The sandbox drop-in generated by install.sh/update.sh lives beside the
# unit; leaving it behind would keep an empty x-ui.service.d around and
# silently re-apply on a later install of another unit of the same name.
# Local drop-ins the operator added go with it, which is what an
# uninstall is expected to do.
rm -rf -- "${xui_service}/x-ui.service.d"
systemctl daemon-reload systemctl daemon-reload
systemctl reset-failed systemctl reset-failed
fi fi