Compare commits

..

33 Commits

Author SHA1 Message Date
dependabot[bot] ccd56a56a8 chore(deps): bump github.com/klauspost/compress from 1.18.6 to 1.19.0 (#5731)
Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.18.6 to 1.19.0.
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.6...v1.19.0)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 18:27:34 +02:00
dependabot[bot] 7a844682b3 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.5 to 4.26.6 (#5730)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.5 to 4.26.6.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.5...v4.26.6)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 18:26:39 +02:00
dependabot[bot] 6626bf4a07 chore(deps): bump google.golang.org/grpc from 1.81.1 to 1.82.0 (#5729)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.81.1 to 1.82.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.81.1...v1.82.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 18:26:13 +02:00
MHSanaei c0df365524 chore(frontend): bump minor npm deps
Update frontend dependencies to newer patch/minor versions in package.json and refresh package-lock accordingly. This includes runtime libraries (i18next, react-router-dom, recharts) and tooling updates (typescript-eslint, vite) to keep the frontend stack current and aligned.
2026-07-02 18:24:09 +02:00
Vitaliy Pavlov 5361b56e5e fix(update): avoid full dnf system upgrade (#5717) 2026-07-02 18:20:15 +02:00
nima1024m 9e13b32c34 fix: make all self-managed file downloads/installs atomic, with real completion status (#5711)
* fix(script): download the live x-ui.sh script atomically before replacing it

update_menu(), update_shell(), and update.sh's update_x-ui() all overwrote
/usr/bin/x-ui in place via `curl -o`, truncating and rewriting the same
inode a currently-running x-ui process may still be reading from. A
network hiccup or slow write during that overwrite leaves a
half-old/half-new script on disk, which then fails with bogus syntax
errors on the next run. Download to /usr/bin/x-ui-temp and `mv -f` into
place instead, matching the atomic pattern install.sh already uses.

Also fixes update_menu() checking chmod's exit code instead of curl's,
which meant a failed download could still report "Update successful."

* fix(script): close remaining gaps in the atomic script-update path

Code review of the previous commit found the atomic mv fix was itself
incomplete:

- None of the mv -f calls checked their exit status, so a failed move
  fell through to chmod and "success" messaging while /usr/bin/x-ui
  stayed on the old file.
- update_shell()'s `[[ -s x-ui-temp ]]` guard couldn't tell "curl -z
  got a 304, nothing to do" from "a stale temp file survived an
  earlier crashed run" -- the latter could get moved into place with
  no freshness check.
- update_menu(), update_shell(), and update_x-ui() all hardcoded the
  same /usr/bin/x-ui-temp path, so two concurrent updates (e.g. a
  cron auto-update racing an interactive menu update) could collide.
- update.sh's update_x-ui() was missing the non-empty-file guard
  update_shell() already had.

x-ui.sh's update_menu() and update_shell() now share a
replace_xui_script() helper that uses a PID-suffixed temp path
(/usr/bin/x-ui-temp.$$), pre-cleans it before every attempt, and
checks the exit status of curl, the non-empty test, and mv before
treating the update as successful. update.sh's update_x-ui() gets the
same sequence inlined (it's fetched as a standalone script and can't
call x-ui.sh's function), closing the missing-guard gap and using its
own unique temp path.

* fix(script,panel): harden the remaining self-update download paths

install.sh had the same unguarded /usr/bin/x-ui-temp overwrite the two
already-fixed scripts had: no exit-status check on mv, and a fixed temp
name shared with x-ui.sh/update.sh's (now-unique) temp files. Give it
its own PID-suffixed temp path, an empty-file guard, and an mv
exit-status check, matching the pattern used there.

Audited the web dashboard's Go-native updater (panel.go) for the same
bug class: it already uses os.CreateTemp for a genuinely unique temp
file and cleans up via both a deferred Remove and a shell EXIT trap, so
it was never exposed to the fixed-path race. It was missing a check
for a zero-byte download (a 200 OK with an empty body would chmod +x
and exec an empty script) -- added that alongside the existing size
cap.

Not addressed here: once startUpdate()'s child process starts, the Go
service releases it and returns success immediately. If update.sh
fails partway through, the still-running old panel keeps answering
/status, so the frontend's poll can report success with no update
having happened. Fixing that needs update.sh to signal completion
status back and the frontend to check it -- a separate follow-up.

* feat(panel): report real completion status for the web self-update

Fixes the fire-and-forget gap flagged in the atomic-overwrite fix: once
startUpdate() launches update.sh detached, the Go service had no way to
learn whether it actually succeeded. If update.sh failed partway
(network drop, disk full, permission denied), the still-running old
panel kept answering /status, so the frontend's poll reported success
with nothing having changed.

update.sh now writes its outcome to a small JSON status file
(/etc/x-ui/update-status.json by default) via `trap ... EXIT`, which
covers every exit path in the script -- including the two bare `exit 1`
call sites that don't go through the existing _fail() helper. The Go
service generates a run ID before launching, passes it and the status
path to update.sh via XUI_UPDATE_RUN_ID/XUI_UPDATE_STATUS_FILE, and a
new GET /panel/api/server/getUpdateStatus endpoint reports it back. The
frontend now polls that instead of blindly trusting HTTP reachability,
and shows a distinct error or "couldn't confirm" message instead of
silently reloading into a false success.

Adversarial review of this surfaced three more issues, fixed here:
- No lock stopped two concurrent /updatePanel calls from launching two
  update.sh runs that would race each other on the actual update work
  (tar extraction, service unit swap). Added an in-memory guard with a
  5-minute self-expiring window, so a run that never reaches a terminal
  state doesn't lock out retries indefinitely.
- XUI_UPDATE_RUN_ID is read from the environment and was interpolated
  unquoted into the status JSON; a malformed value would produce
  invalid JSON. Now validated as digits-only before use.
- The run ID is a UnixNano timestamp (19 digits), sent as a raw JSON
  number it would lose precision in JavaScript (past
  Number.MAX_SAFE_INTEGER), letting two different runs round to the
  same value on the wire and defeat the whole comparison. It's now a
  decimal string end to end (Go, the status file, and the generated
  frontend type).

install.sh's equivalent temp-file/mv path and the Go-native
downloadPanelUpdater() path were audited for the same bug classes
during this work; findings from that audit were addressed separately.

* fix(panel): release the update lock as soon as the run finishes

An exhaustive multi-angle review of the whole branch (12 finder angles,
3-vote adversarial verification, a fresh-eyes sweep) surfaced a real
bug in the concurrency guard added in the previous commit, plus several
smaller issues; this fixes what's actionable now.

The bug: acquireUpdateSlot only ever released on the 5-minute stale
timeout or if launching itself failed. If update.sh launched fine but
failed fast (bad GitHub API response, "x-ui not installed", any of its
early exit paths), the status file correctly reported "failed" within
seconds, but a retry was still rejected with "a panel update is
already in progress" for up to 5 more minutes -- the guard never
looked at the very status file this branch built to know a run was
done. It now tracks which run ID currently holds the slot and checks
that run's own status before falling back to the timeout, so a fast
failure clears the way for an immediate retry. Added a regression test
for this, plus one confirming a stale, unrelated runID can't be
mistaken for the current run finishing.

Also:
- Added a genuinely concurrent test for the guard: 200 goroutines
  racing acquireUpdateSlot, asserting exactly one wins. The previous
  tests only ever called it from one goroutine, so they gave no signal
  if the mutex's check-then-set were silently broken -- verified this
  by temporarily removing the lock and confirming the old tests still
  passed while the new one caught it immediately under -race.
- Removed the redundant upfront "pending" status write: GetUpdateStatus
  already defaults a missing/stale file to pending, and the frontend
  matches by run ID regardless, so the write changed no observable
  behavior. Deleted writeUpdateStatus entirely since that was its only
  caller.
- Renamed replace_xui_script()'s unclear "conditional" parameter to
  use_if_modified_since, matching what it actually controls.
- Added HTTP-level tests for the new getUpdateStatus endpoint,
  including a regression test that the runId wire format is a JSON
  string (decoding into a Go string field fails outright if it were
  ever a bare number). updatePanel's actual launch path is not
  covered: on a Linux test runner it would make a real network call
  and could exec a real update.sh, so only its non-Linux guard path is
  safely testable without mocking.

Not fixed here, tracked separately: the same unsafe-overwrite pattern
this branch eliminated for /usr/bin/x-ui is still present for the
systemd unit file install in update.sh and install.sh (lower severity
since systemd only reads it on daemon-reload, not continuously); and
startUpdate's systemd-run-vs-detached-fallback branching has no test
coverage since testing it safely needs dependency injection this fix
doesn't warrant bundling in.

* fix(script): make systemd unit file installation atomic

Same anti-pattern as the /usr/bin/x-ui overwrite fixed earlier: every
site that lands the systemd unit at ${xui_service}/x-ui.service --
copying it from the extracted release tarball, or falling back to a
GitHub download per distro family -- wrote straight onto the live
path via cp/curl, no temp file, no verification. A network drop
mid-download or an interrupted cp leaves the unit file truncated;
systemd then fails to parse it on the next daemon-reload/start,
leaving the panel unable to come up until an operator manually
re-copies a good unit file.

Lower severity than the /usr/bin/x-ui case (systemd only reads this
file on demand at daemon-reload time, not continuously the way bash
interprets a running script line by line), but it's the identical
gap, just left uncovered when that fix landed.

Added a small shared helper in both update.sh and install.sh --
_install_xui_service_unit() -- covering both source types (cp from
the tarball, curl from GitHub): write to a PID-suffixed temp file,
verify the copy/download succeeded and the result is non-empty, then
mv -f into place and check that exit status too, matching the pattern
already used for /usr/bin/x-ui. All 4 cp sites and the 3-way curl
fallback in each file now go through it; verified no other site
writes new content to the unit path (the remaining ${xui_service}
references are a pre-install existence check, an rm during old-version
cleanup, and the chown/chmod that already ran after the file is safely
in place -- none of those need atomicity).

Verified with bash -n on both files, plus a standalone scratch test
exercising cp-success, cp-with-missing-source, cp-with-empty-source,
and curl-failure paths: on every failure the previous, good unit file
content is left untouched and no temp file is leaked behind.

* fix(script): make Alpine's OpenRC init script install atomic; drop a stray comment

A final maximum-rigor review of the whole PR (12 finder angles including
a repo-wide sweep for any remaining instance of the bug class this PR
fixes) found two more real issues:

- Alpine's /etc/init.d/x-ui startup script is downloaded via a bare
  `curl -fLRo` straight onto the live path in both update.sh and
  install.sh -- the exact same unguarded-overwrite pattern already
  fixed for /usr/bin/x-ui and the systemd unit file, just left
  uncovered on the OpenRC side. A network drop mid-download truncates
  the live init script; OpenRC then fails to source/execute it on the
  next start, leaving the panel unable to come up. Fixed with the same
  temp-file + non-empty check + mv -f (with its own exit-status check)
  pattern used everywhere else in this PR. Verified with bash -n and a
  standalone scratch-script test covering success, empty-download, and
  destination-preserved-on-failure paths.

- internal/web/service/panel/panel_test.go had one line-level `//`
  comment on a call site, which the root CLAUDE.md's hard rule ("No //
  line comments in committed Go/TS... rename instead of annotating")
  explicitly prohibits. The comment duplicated context already stated
  in the test's own doc comment two lines above, so it's simply
  removed rather than reworded.

Also flagged, deliberately not bundled here since it's a different
subsystem: x-ui.sh's update_geofiles() downloads Xray's live
geoip.dat/geosite.dat with the same unguarded curl -o pattern. Tracked
as its own follow-up.

* fix(script): make geo-data file downloads atomic

Same anti-pattern as /usr/bin/x-ui, the systemd unit file, and the
Alpine init script fixed in prior PRs: update_geofiles() downloaded
Xray's live geoip.dat/geosite.dat (and the IR/RU variants) with curl
writing straight onto the exact path Xray reads at runtime
(internal/xray/process.go's GetGeoipPath/GetGeositePath), no temp
file, no verification. The existing check only inspected the reported
HTTP status via -w '%{http_code}', not file integrity, so a network
drop mid-download could leave a truncated .dat file on disk that
passes the status check. Xray then fails to parse it on the next
restart/reload, breaking any routing rules that reference geoip:/
geosite:.

The -z conditional-GET usage needed care here: the original code
pointed both -z and -o at the same live path. Fixed by pointing -z at
the live file (to keep the "already current" freshness check) while
-o writes to a PID-suffixed temp file, matching the pattern already
proven in x-ui.sh's replace_xui_script(). Verified with a local HTTP
server that a 304 response leaves the temp file untouched/nonexistent
(so the existing "already up to date" branch still works unchanged),
and added a non-empty check plus a checked mv -f before treating a
download as installed.

Verified with bash -n and an end-to-end scratch test against a local
server covering: fresh download, 304-not-modified, empty response
body, and a 404 -- confirming a failure at any stage leaves the
previous good .dat file completely untouched and no temp file behind.

* fix(script): verify the release tarball extraction, not just the download

The final maximum-rigor review found the most significant remaining gap
in this whole effort: update.sh and install.sh check the tarball
download's exit status, but never check tar's exit status, and never
verify the extracted x-ui binary actually exists before continuing.
Worse, by the time extraction runs, the previous installation has
already been stopped and deleted -- there's no rollback. A truncated
download that still passes curl's own check, or a tar failure (disk
full, killed process), left the panel silently in a broken half-state:
chmod/config/service-install all continued to run against a missing or
empty binary, with no error surfaced anywhere. This is the same bug
class as everything else in this PR (unverified write to a path
something then depends on), just for the tarball itself rather than a
single file -- and it also covers the geo-data files this PR already
fixed once for the interactive/cron path, since they ship inside this
same tarball on every panel update.

Added: a non-empty check on the downloaded archive (both files, both
install.sh call sites) and a check that tar succeeded and produced a
non-empty x-ui binary before proceeding, failing loudly with a message
that explicitly says the previous install is already gone, since
silently continuing here is worse than anywhere else in this PR.

This doesn't make the multi-file extraction fully atomic (that would
mean extracting to a temp directory and atomically swapping the whole
install tree into place, a materially larger restructuring than
anything else in this PR) -- but it closes the "fails silently, user
discovers it days later when Xray can't start" gap, which was the
actual reported problem this whole effort traces back to.

Also fixed, all much smaller:
- replace_xui_script() in x-ui.sh implicitly returned chmod's exit
  status instead of success, so a successful atomic install could be
  reported as failed if chmod transiently failed after the mv already
  landed the new script. Added an explicit `return 0`.
- update_geofiles() had no default case branch; an unrecognized
  argument would silently reuse whatever dat_files/dat_source values a
  previous call left in the un-scoped globals instead of failing.
  Currently unreachable (all three call sites pass fixed literals) but
  cheap, defensive, and worth having.
- internal/web/controller/server.go's updatePanel has one branch (an
  unparseable "dev" form value) that's both untested and safe to test
  on any platform, since it's rejected before any real exec/network
  call. Added the missing test case.

Verified: bash -n on all three scripts; an empirical scratch test
covering an empty downloaded archive, a corrupt (non-gzip) archive,
and a successfully-extracting-but-empty archive, confirming each is
caught before the script proceeds; full go build/vet/test -race
across the whole module; frontend generation confirmed still in sync.

* fix(panel): base the update-slot staleness fallback on process liveness

Addresses the automated review on the upstream PR (MHSanaei/3x-ui#5711).

Blocking finding: acquireUpdateSlot's staleness fallback freed the
update slot purely on elapsed wall-clock time (5 minutes), with no
check on whether the update.sh process it launched was actually still
running. update.sh runs install_base() (apt-get/dnf/pacman update and
install) before update_x-ui even starts, plus several GitHub
downloads (release tarball, x-ui.sh, and possibly a service unit or
x-ui.rc) -- on a slow or throttled host, a small VPS being the typical
deployment target for this project, that alone can plausibly exceed 5
minutes with nothing wrong. A second /updatePanel call arriving in
that window (an admin retrying after the frontend's 90s poll times
out, or overlapping master-node bulk-update calls) would launch a
second update.sh, racing the exact rm/tar/mv/systemctl sequence this
whole PR exists to make safe.

Fixed by recording the launched process's PID (detached-fallback path
only; the systemd-run path's own process has already exited by the
time startUpdate returns, so it never learns update.sh's real PID) and
checking it via the standard POSIX kill(pid, 0) liveness probe before
treating a run as stale, following the existing panel_unix.go /
panel_other.go platform-split pattern already used for
setDetachedProcess. A confirmed-alive process now keeps the slot held
past updateStaleAfter (raised from 5 to 20 minutes as a safer baseline
for the systemd-run path, which still has no way to check liveness
directly). updateHardCeiling (2 hours) is an absolute backstop so a
genuinely wedged run can never lock out retries permanently even on
the PID-tracked path.

Added two regression tests exercising the new logic (gated to Linux,
since processAlive is a no-op stub elsewhere): a live PID keeps the
slot held past the stale window, and the hard ceiling overrides
liveness. Traced both by hand against the new acquireUpdateSlot logic;
could not execute-verify processAlive itself on this Windows dev
machine (no WSL distro installed, and installing one felt
disproportionate to validate kill(pid, 0), an extremely well-established
POSIX primitive), but cross-compiled clean for linux/amd64 and this
repo's CI runs the real test suite on Linux.

Also fixed, both suggestions from the same review:
- install.sh: two failure paths right after tarball extraction were
  exiting without cleaning up the already-downloaded x-ui.sh temp file
  (xui_script_temp), leaving it behind. Every other new failure branch
  in this PR removes its temp file before exiting; these two now do
  too.
- frontend/src/pages/api-docs/endpoints.ts: updatePanel's doc entry
  did not reflect that a successful response now carries an obj with
  runId. Added an inline response example matching the existing
  pattern used for other ad hoc (non-schema-backed) responses like
  getWebCertFiles.

Verified: go build/vet clean on both windows (native) and a linux/amd64
cross-compile; full go test ./... clean; go test -race on the panel
and controller packages; bash -n on all three shell scripts; npm run
gen confirms the openapi.json diff is exactly the new response example
with no stray changes to src/generated; TestAPIRoutesDocumented still
passes.
2026-07-02 18:19:33 +02:00
nima1024m ade74eb321 fix(balancers): keep mixed strategies on one observer (#5674)
* fix(balancers): keep mixed strategies on one observer

Xray resolves Observatory and Burst Observatory through the same global observer feature. When any burst-required strategy is present, keep all observer-backed balancer selectors on burstObservatory and remove the regular observatory so mixed leastPing configs cannot generate two competing observer blocks.

* test(balancers): cover observer strategy combinations

Exercise the observer sync matrix for random, round-robin, leastPing, and leastLoad balancers. Include mixed and stale-observer cases so the panel keeps only the observer type that Xray should consume.

* fix(balancers): clarify observer empty state

Update the Observatory tab empty hint to describe the actual auto-managed cases. Least Ping, Least Load, and fallback Random or Round-robin balancers now explain why an observer is added before the balancer can choose a target.

* fix(balancers): remove mixed observer switch

Show only the observer settings panel that matches the current balancer requirements. Legacy configs that still contain both observatory blocks now display a warning instead of a tab switch, since saving balancers normalizes the config back to one global observer.

* test(balancers): cover observer cleanup on deletion

Add direct balancer deletion and outbound cascade cases for leastLoad, fallback, and mixed leastPing scenarios. These tests pin that the final unneeded observer is removed, burst switches back to regular observatory when only leastPing remains, and burst remains when a burst-required balancer survives.
2026-07-02 18:18:30 +02:00
MHSanaei 97e2c9e7ba fix(web): sync the VLESS generate-key dropdown with the encryption field
The auth-kind dropdown in the VLESS "Generate Key" block was hardcoded to
x25519 on mount, while the "Already selected" text next to it was derived
independently from settings.encryption. Editing an inbound whose encryption
uses another kind (e.g. ML-KEM-768) showed a mismatched dropdown, and
clicking Generate without noticing would produce a keypair of the wrong
kind for the inbound.

Extract the encryption-string parsing into a shared pure helper
(lib/xray/vless-encryption), use it both for the selected-auth label and to
initialize/sync the dropdown, so the two can no longer diverge. When the
encryption is none or unparseable the dropdown keeps its x25519 default.

Closes #5744
2026-07-02 17:37:04 +02:00
MHSanaei 5e8327e728 fix(settings): include savePayload in the category body memo deps
react-hooks/exhaustive-deps flagged the omission; a stale closure could
hand SecurityTab an outdated save callback after a mutation state change.
2026-07-02 17:16:12 +02:00
MHSanaei 7c12700c7d fix(sub): resolve subscription clients and stats from normalized tables
A subscription fetch inside a large inbound cost seconds because every
layer re-parsed the inbound's full settings JSON: getInboundsBySubId
preloaded the whole client_traffics table of each matched inbound,
matchingClients parsed all clients to filter by subId, and then every
per-protocol generator (raw links, JSON outbounds, Clash proxies) parsed
the blob again per link — once to find the client by email and once for
inbound-level fields like encryption or method. At 500k clients in one
inbound that was 13s per raw fetch and 8.5s per JSON fetch; at 100k,
2.6s/1.7s. After this change both cost ~70ms at 100k.

matchingClients now resolves through the indexed clients/client_inbounds
tables (ListForInboundBySubId, ordered by clients.id like ListForInbound
— the same source the running Xray users are built from), and the
per-request SubService carries two caches: clientsByInbound, primed by
matchingClients/inboundLinks so clientForLink resolves a client without
parsing settings (with the old full-parse as fallback, which also fixes
the export-all-links path that re-parsed the blob once per client), and
settingsByInbound, a once-per-request shallow decode that skips
materializing the clients array entirely. The ClientStats preload is
replaced by loading only the subscriber's traffic rows (indexed
clients.sub_id); statsForClient's per-email DB fallback (#5567) covers
any miss, and the case-insensitive email dedupe keeps the #5134
guarantee for case-differing duplicate rows.
2026-07-02 16:58:00 +02:00
MHSanaei c0d17e132d fix(job): batch ip-limit per-email lookups and persistence
processObserved paid four round-trips per observed email every 10s scan:
an inbound-resolving join, a tracking-row read, an autocommit Save (one
fsync each under synchronous=FULL), and — worst of all — a full JSON
parse of the owning inbound's settings blob just to read that one
client's limitIp. On a big single inbound that parse alone made a scan
cost ~1.5s per online client.

The scan now front-loads three chunked batch queries (clients.limit_ip,
email->inbound through the client_inbounds relation keeping the lowest
inbound id like the old First(), and the tracking rows) and writes every
inbound_client_ips change inside one transaction, so M observed emails
cost a handful of queries and a single fsync. The per-email LIKE fallback
remains for emails missing from the relation, preserving the #4963
stale-email cleanup. limitIp now comes from the clients table (same
source B3 gates on) instead of the settings blob, and xray disconnects
for banned clients run after the commit so their network round-trips
never extend the write transaction node syncs contend with.
2026-07-02 16:39:31 +02:00
MHSanaei fc5be5b9e4 feat(web): broadcast delta client stats above a snapshot threshold
Both 5s broadcasters (the local traffic poll and the node traffic sync)
shipped the complete client_traffics table on every cycle while a browser
was connected. At 500k clients that is a 1.7s full-table read plus an
86MB marshal per job per poll — and the hub drops any payload over 10MB
and sends an invalidate the frontend ignores for these message types, so
past ~55k clients all of it was pure waste and the UI got nothing.

Installs at or below 5000 clients (clientStatsSnapshotMaxClients) keep
the exact full-snapshot behavior — it exists because a pure delta feed
left UI rows stale when nothing moved in a cycle (see GetAllClientTraffics)
— and the payload now carries snapshot=true. Above the threshold the jobs
send only this cycle's active rows (the xray poll's active emails, or the
emails online on the synced nodes) with snapshot=false, and scope the
last-online map to those rows; the initial full map still arrives over
REST and the clients page refetches every 5s.

GetActiveClientTraffics gains the overlayGlobalTraffic pass so delta rows
carry the same cross-panel usage as snapshot rows. The node job also
stops reading the full last-online map before the has-clients gate, which
was a wasted full-table read on every tick with no dashboard open.

Frontend: useClients keeps its live summary strictly snapshot-driven
(snapshot=false payloads skip the allClientStats replace and the summary
falls back to the server-computed one); the per-row page merge and the
inbounds-page merges already handle deltas.
2026-07-02 16:34:01 +02:00
MHSanaei c3cc8b4374 fix(job): gate ip-limit scan on clients.limit_ip instead of parsing all settings
hasLimitIp ran settings LIKE '%limitIp%' and JSON-parsed every matching
inbound's settings blob — and since clients marshal limitIp without
omitempty, every inbound matched, so each 10s scan loaded and parsed
every settings blob in the database (~75MB of JSON at 500k clients) just
to decide whether any limit exists.

It now probes the normalized clients table (limit_ip > 0, Limit(1) count
like depletedCond does), which SyncInbound and the legacy seeder keep in
sync with the settings JSON. Semantics note: a limitIp that exists only
in settings JSON with no clients row no longer enables enforcement — the
enforcement path itself already resolves clients through the same
normalized tables.
2026-07-02 16:24:18 +02:00
MHSanaei 97588dd0b9 fix(traffic): disable depleted clients by id instead of a second full scan
disableInvalidClients evaluated the depleted predicate twice per poll:
once to SELECT the rows (for xray removal and settings sync) and again in
the UPDATE that flips enable off — each a full client_traffics scan, the
second also re-running the cross-panel EXISTS subquery when global rows
exist.

The UPDATE now flips the already-collected rows by primary key in
sqlInChunk batches, sorted for stable lock order. Same rows, same
RowsAffected, half the scan cost; id-based matching also stays correct
for rows with empty emails.
2026-07-02 16:24:18 +02:00
MHSanaei fb1d055b06 fix(traffic): persist delayed-start expiry only for converted clients
addClientTraffic's second pass wrote expiry_time for every polled row via
UPDATE ... WHERE expiry_time < 0 — a no-op statement per active client on
every 5s poll, since almost all rows carry a positive expiry. At 10k
active clients that was 10k pointless indexed UPDATEs per poll.

adjustTraffics now returns the emails it actually converted this tick and
the persistence pass writes exactly those, in sorted order to keep
concurrent writers lock-compatible on Postgres. Behavior is unchanged:
unconverted rows never matched the WHERE clause anyway.
2026-07-02 16:24:18 +02:00
MHSanaei 4fc301682f test(scale): cover traffic poll, ws payloads, ip-limit job, sub and xray config at 500k
The paths that run continuously in production had no scale coverage: the
5s traffic poll (AddTraffic with its auto-renew and depleted scans), the
websocket snapshot the job broadcasts while a browser is connected, the
10s ip-limit job (hasLimitIp LIKE scan + per-email settings parse), a
subscription fetch inside a huge inbound, and the full Xray config build.

New benchmarks reuse the XUI_SCALE_TEST / XUI_DB_TYPE gating and stay
log-only. Sizes default to 10k/100k; XUI_SCALE_SIZES=500000 raises the
ladder without editing code. seedScaleDataset writes inbounds, clients,
client_inbounds and client_traffics directly in one transaction instead
of SyncInbound, so a 500k seed takes seconds. XUI_SCALE_DB_PATH persists
the seeded SQLite file for manual smoke runs against a live panel.
2026-07-02 16:12:46 +02:00
MHSanaei 28f7690224 docs: move architecture map into docs/ and refresh it against the live tree
The architecture/code map previously lived in .claude/CLAUDE.md, which was
gitignored (local-only) and auto-loaded into every agent session alongside
the root CLAUDE.md. Track it in docs/architecture.md instead and reference
it from CLAUDE.md so it is read on demand.

While moving it, fact-check the whole map against the current tree:
- add the missing internal/eventbus and internal/tunnelmonitor packages,
  the service/email subpackage, and util/wirecodec
- document node mTLS (tls_client.go, node_mtls.go, setting_mtls.go) and the
  fourth TLS verify mode
- add the Host, ClientExternalLink, NodeClientIp and ClientGlobalTraffic
  models plus their symptom-index rows
- correct the cron table (check_cpu_usage is 1m not 10s; add
  check_memory_usage and free_os_memory), the middleware chain
  (MaxBodyBytes, ConfigEnvelope, CSRF) and the controller route prefixes
- refresh the sub/ and service/ file listings, frontend pages (hosts/,
  index/), CI workflow list, and replace stale exact line counts with
  rounded sizes
2026-07-02 14:21:21 +02:00
MHSanaei 92303094fd feat(settings): let users clear stored secrets from the UI
Redacted secrets (SMTP password, Telegram bot token, LDAP password) are
always served blank to the browser, so the update path treats a blank
submission as "unchanged" and silently restores the stored value. That
made a once-set secret impossible to remove without editing the database
— e.g. switching to a passwordless localhost SMTP relay kept sending the
old credentials forever.

Blank stays "unchanged"; clearing is now its own signal. The update
request carries explicit clear flags (request-scoped fields on the
controller form, so they are never persisted as settings rows), and
preserveRedactedSecrets skips the restore for a flagged secret. Each
secret field gets a Clear/Undo button that arms the flag; typing a new
value disarms it. The 2FA token keeps its existing behavior: it is
already clearable by disabling 2FA.

Closes #5724
2026-07-02 13:57:34 +02:00
MHSanaei fb3a1559b2 fix(sub): default https:// for scheme-less support and profile URLs
A support URL saved without a scheme (e.g. "t.me/handle") is served
verbatim in the subscription Support-Url header and page data, and client
apps resolve it relative to the subscription domain — clicking it lands
on "https://panel.example/t.me/handle". Same hazard for the profile URL.

Default the scheme to https:// when none is present, both when saving the
settings and when reading already-stored values, so existing databases are
covered without a migration. Deliberate non-http schemes (tg://, mailto:,
tel:) pass through untouched, which is why these two fields don't go
through SanitizeHTTPURL's http(s)-only validation.

Closes #5738
2026-07-02 13:47:10 +02:00
MHSanaei a335456cd3 fix(settings): repair legacy path settings that block every settings save
A subJsonPath (or subPath/subClashPath/webBasePath) stored without its
leading/trailing slash — written before the slash rules existed, or
restored from an old backup — fails the frontend's whole-form validation,
so every save on the Settings page is rejected client-side. The backend's
CheckValid would normalize the value, but a save request never reaches it,
leaving the panel wedged until someone edits the database by hand.

Normalize the stored path rows at startup, mirroring CheckValid's slash
rules. The pass is idempotent and not seeder-gated, since a restored
backup can reintroduce bad values at any time.

Also add the missing pages.settings.validation.pathLeadingSlash key to
all 13 locales — the validation error used to render as its raw key.

Closes #5726
2026-07-02 13:42:03 +02:00
MHSanaei 9a3a12b260 fix(node): stop Postgres deadlocks and deleted-client resurrection in node sync
Two defects in the node traffic sync, both hit hard on busy
master+multi-node Postgres deployments:

Client-IP merges deadlocked. Each node syncs on its own goroutine and
shared clients appear in several nodes' reports, but MergeInboundClientIps
and upsertNodeClientIps locked rows in whatever order each node's report
arrived. Two concurrent merges taking the same rows in opposite order is
exactly what Postgres aborts with SQLSTATE 40P01 ("merge client ips from
<node> failed: deadlock detected"). Both merges now process emails in
sorted order so every transaction acquires row locks in one global order.

Deleted clients resurrected with zeroed traffic. A snapshot fetched just
before a deletion still names the deleted email; applying it after the
delete committed re-added the client. The delete tombstone existed for
precisely this race but only zeroed the seed counters: the sync still
recreated the client_traffics row, and worse, adopted the node's stale
settings JSON wholesale, putting the client back in the central inbound
as if it were brand new with 0 traffic. Snapshot application now skips
row creation for tombstoned emails on known inbounds and strips
tombstoned clients from adopted settings; fresh node-adoption semantics
(rows seeded at zero) are unchanged.

The mass-disconnect part of the report is the forced node restart on
auto-disable, removed separately in 4d6f2ddd.

Closes #5739
2026-07-02 13:37:06 +02:00
MHSanaei 4d6f2ddd97 fix(node): stop force-restarting a node's Xray when its clients auto-disable
When a depleted or expired client lived on a node, the master pushed the
updated inbound (client flipped off) to the node and then also told the
node to fully restart Xray. The push alone already applies the disable:
the node updates that one inbound on its running core. The extra restart
dropped every live connection on the node each time any of its clients
crossed a quota or expiry, and a restart that failed to come back left
the node forwarding nothing until someone restarted Xray by hand.

This mirrors e5b56c94, which removed the same forced restart from the
local auto-disable path; remote nodes now get the same graceful
reconcile-by-push treatment.

Closes #5740
2026-07-02 13:27:36 +02:00
MHSanaei 62f303905e fix(scripts): pass --force to acme.sh --installcert so it survives sudo
acme.sh guards every non-install command behind _checkSudo: when a
non-root user runs the panel scripts via sudo, it prints the sudo wiki
warning and exits before doing anything, unless FORCE is set. All our
--issue calls already pass --force and were unaffected, but none of the
--installcert calls did, so issuance succeeded and installation then
aborted silently, ending in "Certificate files not found after
installation". FORCE has no other effect on the installcert path, so
mirror the --issue calls and pass --force everywhere we install certs.

Closes #5741
2026-07-02 13:20:31 +02:00
MHSanaei c8ef1b1f68 feat(reality): derive a stable per-client spiderX for shared links
The inbound's spiderX now acts as a per-client seed: exports emit
sha256(seed|subKey) truncated to a 15-hex "/path", so a client's spx no
longer changes on every subscription fetch (#5718) while different
clients stop sharing one fingerprintable value. The form gains a
regenerate button that rotates every client's path at once.

The frontend link builders derive through the same function
(lib/xray/spider-x.ts, @noble/hashes) keyed on subId-then-email like
the Go subKey, so panel QR/copy links and subscription output agree —
cross-language vector tests lock both sides byte-for-byte. streamData
now tolerates malformed stored stream settings (unparseable JSON, null
tls/reality settings) instead of panicking the subscription request.
2026-07-02 12:53:08 +02:00
MHSanaei 64c306037f feat(wireguard): make client allowedIPs editable with validation
The WireGuard peer address was allocated server-side and shown read-only
in the client editor, so changing it required hand-editing the inbound's
raw settings JSON (#5715). The backend add/update paths already honored a
submitted allowedIPs; only the form withheld it.

Make the field editable (comma-separated, empty still auto-assigns) and
validate submissions server-side: entries must parse as an IP or CIDR,
bare addresses normalize to single-host prefixes, and an address already
used by another peer on the inbound is rejected.

Closes #5715
2026-07-02 09:45:54 +02:00
MHSanaei 8dd3b31ee8 fix(node): show the activated first-use deadline on the Clients page
With "start after first use" on a node inbound, the node activates the
absolute deadline and the master adopts it into client_traffics via the
sync CASE merge — but the client record (what the Clients page reads) was
only refreshed by SyncInbound from the snapshot's settings JSON. A node
whose JSON still carried the negative duration (stale conversion, older
node build, or a mixed local+node attachment) kept rewriting the record
back to "not started" even though the DB held the real deadline (#5714).

Lift the activated deadline from client_traffics onto still-negative
client records at the end of every node sync, after SyncInbound has run.
Intentional resets back to delayed start are unaffected: editing a client
also resets client_traffics to the negative duration, so the lift's
expiry_time > 0 guard never matches.

Closes #5714
2026-07-02 09:36:07 +02:00
MHSanaei e5b56c9444 fix(xray): reconcile client auto-disable through the API instead of a forced restart
When a client expired or hit its traffic limit, XrayTrafficJob called
RestartXray(true), stopping the whole process and dropping every live
connection on every inbound (#5712 reported this as XHTTP on 443 dying) —
even though disableInvalidClients had already removed the user from the
running core over gRPC. The force restart existed only to re-sync the
process's config snapshot.

Switch the job to a non-forced restart and teach ComputeHotDiff to express
a client-only inbound change as per-user AlterInbound operations for
vless/vmess/trojan, so the reconcile is a no-op RemoveUser plus a snapshot
update rather than a handler swap that would still blip that inbound's
listener. Anything beyond the clients list still falls back to handler
replacement or a full restart as before.

Closes #5712
2026-07-02 09:26:53 +02:00
MHSanaei 1153d5db8c fix(groups): keep group traffic totals stable across client resets and deletes
ListGroups displays live_sum(client_traffics) minus the group's stored
reset baseline, but only ResetGroupTraffic ever moved the baseline. Any
client-level operation that zeroed or deleted traffic rows (single/bulk
reset, client delete, removing a client's last inbound) shrank the live
sum and silently subtracted that client's history from the group total.

Shift the baseline down by the removed counters inside the same
transaction, so group totals only change through group reset. Derived
groups without a stored row get one with a negative baseline, which the
existing clamp handles.

Closes #5675
2026-07-02 09:17:47 +02:00
MHSanaei 539bcc897c fix(inbounds): apply the legacy xhttp session-key migration when editing
rawInboundToFormValues injected the stored xhttpSettings blob into the form
store without running it through XHttpStreamSettingsSchema, so the
sessionPlacement/sessionKey -> sessionIDPlacement/sessionIDKey rename from
xray-core v26.6.22 (and the v3.4.0 field defaults) never applied on the
edit path. Inbounds saved before the rename opened with blank session
fields, and the stale keys could ride back on save even though the core no
longer reads them. Parse the sub-object through the schema on load, and
lift any stale legacy keys in normalizeXhttpForWire as a backstop.

Closes #5621
2026-07-01 23:11:58 +02:00
MHSanaei 273f88721e fix(database): stop noisy per-startup errors in the Postgres server log
Two statements failed server-side on every panel start after a SQLite to
Postgres migration, flooding the postgres log even though the Go side
suppressed them:

- resyncPostgresSequences issued SELECT MAX(id) against client_inbounds,
  whose composite primary key has no id column; Postgres validates the
  SELECT list at parse time, so the WHERE pg_get_serial_sequence(...) guard
  never got a chance to no-op it. Skip models whose GORM schema maps no id
  column before issuing the statement.

- AutoMigrate detects existing columns via information_schema filtered by
  table_catalog = CURRENT_DATABASE(), which misdetects on some setups and
  re-issues ALTER TABLE ... ADD for columns that already exist. HasColumn/
  HasIndex query without that filter and are reliable (the existing
  duplicate-column suppressor depends on exactly that), so skip AutoMigrate
  outright when the table, every column, and every index already exist.

Closes #5665
2026-07-01 23:07:05 +02:00
MHSanaei 1f2e3e1447 fix(sub): use configured spiderX instead of always randomizing
applyShareRealityParams and SubJsonService.realityData generated a fresh
random spx on every export, so share links, "export all links", and JSON
subscriptions never matched a spiderX configured on the inbound and two
exports of the same client disagreed with each other. Read the value from
realitySettings.settings like pbk/fp/pqv and keep the random value only as
a fallback when none is configured.

Closes #5718
2026-07-01 23:07:05 +02:00
MHSanaei 49773c18de fix(xray): force full restart for inbounds with a VLESS reverse client
Hot-applying an inbound change swaps it via DelInbound+AddInbound on
the running core. That unregisters any client's reverse.tag handler
on the xray-core side without closing the bridge's already-established
connection, so the reverse tunnel is silently orphaned until someone
manually restarts xray. diffInbounds now bails out of the hot-apply
path whenever the old or new inbound carries a reverse-tagged client,
falling back to a full restart, which actually drops the socket and
lets the bridge redial on its own.

Also scope the .claude ignore rule to its contents (.claude/*) instead
of the whole directory, so individual files under .claude/ can be
tracked selectively.
2026-07-01 14:02:13 +02:00
MHSanaei 427613b308 chore(ci): upgrade claude-bot to Sonnet 5 and set explicit effort levels
Sonnet 5 reaches near-Opus quality on coding/agentic work at lower cost;
pin effort explicitly (xhigh/max) instead of relying on model defaults.
2026-07-01 00:43:27 +02:00
120 changed files with 5731 additions and 826 deletions
+6 -3
View File
@@ -30,7 +30,8 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_non_write_users: "*" allowed_non_write_users: "*"
claude_args: | claude_args: |
--model claude-sonnet-4-6 --model claude-sonnet-5
--effort xhigh
--max-turns 300 --max-turns 300
--allowedTools "Bash(gh:*),Read,Glob,Grep" --allowedTools "Bash(gh:*),Read,Glob,Grep"
prompt: | prompt: |
@@ -282,7 +283,8 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_non_write_users: "*" allowed_non_write_users: "*"
claude_args: | claude_args: |
--model claude-opus-4-8 --model claude-sonnet-5
--effort max
--max-turns 250 --max-turns 250
--allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep" --allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep"
prompt: | prompt: |
@@ -500,7 +502,8 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: | claude_args: |
--model claude-opus-4-8 --model claude-sonnet-5
--effort max
--max-turns 250 --max-turns 250
--allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep,Edit,Write" --allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep,Edit,Write"
--append-system-prompt "You are replying to an @claude mention in the MHSanaei/3x-ui repository, an open-source web panel for managing Xray-core servers. The full repo source is checked out in the working directory; use Read, Glob and Grep to open and verify the relevant files before stating any default, path, flag, option name, or behavior. --append-system-prompt "You are replying to an @claude mention in the MHSanaei/3x-ui repository, an open-source web panel for managing Xray-core servers. The full repo source is checked out in the working directory; use Read, Glob and Grep to open and verify the relevant files before stating any default, path, flag, option name, or behavior.
+1 -1
View File
@@ -2,7 +2,7 @@
.idea/ .idea/
.vscode/ .vscode/
.cursor/ .cursor/
.claude/ .claude/*
.cache/ .cache/
.sync* .sync*
+3
View File
@@ -3,6 +3,9 @@
Operational guide for AI agents working in this repo. Long-form human docs: Operational guide for AI agents working in this repo. Long-form human docs:
`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`. `CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
Read those before large changes. This file is the short, must-follow version. Read those before large changes. This file is the short, must-follow version.
For a deep navigation map (request lifecycle, cron-job table, symptom → file
index, layering rules), read `docs/architecture.md` on demand — do not guess
file locations when it can answer in one hop.
## Stack ## Stack
- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM. - Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
+581
View File
@@ -0,0 +1,581 @@
# 3x-ui — Architecture & Code Map
> Navigation map for contributors and AI coding agents (referenced from `CLAUDE.md`).
> Goal: jump to the right file in one hop instead of grepping the whole tree.
> Tracks the `main` branch — paths reflect the latest changes, so verify against the live
> tree rather than a pinned release (Go module `github.com/mhsanaei/3x-ui/v3`).
>
> **How to use this file:** read "Mental model" + "Request lifecycle" first, then
> use the **Symptom → File index** to locate work. Respect the **Layering rules**
> when adding code. Verify with the commands in **Build / Test / Lint**.
---
## 1. Mental model (the 30-second version)
3x-ui is a **web control panel for [Xray-core](https://github.com/XTLS/Xray-core)**. The Go
backend is the source of truth: it stores inbounds/clients/settings in a DB, renders an
Xray JSON config from that state, supervises the Xray child process, and exposes a REST +
WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the UI. A second,
separate HTTP server serves **subscription links** to end users.
The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
inbounds exist — the `mtg` Telegram-proxy binary (`internal/mtproto/`).
Servers and processes, all launched from `main.go`:
| Server / process | Package | Purpose | Default port |
|---|---|---|---|
| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 |
| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` |
| **mtg** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds | per inbound |
Two key ideas that explain most of the complexity:
1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the
backend regenerates the Xray config and applies it — preferring a *hot diff* (live gRPC
API mutation) over a full process restart. See §5.1.
2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui
instances). Every state-changing inbound/client operation is dispatched through a
`runtime.Runtime` interface that is either **`Local`** (this box's Xray gRPC API) or
**`Remote`** (HTTPS call to a child node, with `verify`/`skip`/`pin`/`mtls` TLS modes).
This is the single most important abstraction in the project. See §5.2.
---
## 2. Tech stack
**Backend (Go 1.26):**
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
- Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs.
- Xray: **xtls/xray-core** vendored as a library; the panel talks to the running core over
its **gRPC API** and also shells out to manage the process.
- Telegram bot: **mymmrac/telego**. i18n: **nicksnyder/go-i18n**.
- Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP).
**Frontend (`frontend/`):**
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
- Data layer: **TanStack Query** (`@tanstack/react-query`) over **axios**; **Zod 4** schemas.
- Router: **react-router-dom 7**. Charts: **recharts**. Editor: **CodeMirror 6**.
- **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
deployment.
**Important:** the legacy Go-template UI and `web/assets/` are **gone**. All HTML/JS comes
from the embedded Vite `dist/`. Don't look for `.html` templates in `internal/web`.
---
## 3. Request lifecycle (follow the data)
### 3.1 Admin API request (e.g. "add a client")
```
Browser (React, axios)
→ POST {basePath}/panel/api/...
→ Gin engine (internal/web/web.go: initRouter)
→ middleware chain: SecurityHeaders → MaxBodyBytes (10 MiB; importDB exempt)
→ [DomainValidator, if webDomain set] → gzip → sessions("3x-ui")
→ base-path/cache-control context → Localizer
→ API routes add: ConfigEnvelope (zstd + SHA-256) → CSRF
→ Controller (internal/web/controller/*.go) // HTTP concerns only: bind, validate, respond
→ Service (internal/web/service/*.go) // business logic + transactions
→ GORM → DB (internal/database) // persistence
→ runtime.Runtime dispatch // apply to Xray (Local) or node (Remote)
→ Local: internal/xray (gRPC API or config regen + restart)
→ Remote: internal/web/runtime/remote.go → HTTPS → child node's API
```
The controller layer is thin. **Business logic lives in services.** When something is wrong
with *behavior*, the bug is almost always in a service file, not a controller.
### 3.2 Subscription request (end-user fetching their config)
```
End user → GET {subPath}/{subId} (separate server, internal/sub)
→ internal/sub/controller.go (routes: raw / JSON / Clash variants, feature-flagged)
→ internal/sub/service.go (~2.5k lines — the link/config builder)
→ reads inbounds+clients+hosts from DB, renders per-protocol share links /
Clash YAML / JSON (Host rows can override address/SNI/path per inbound)
```
### 3.3 Background work (cron jobs)
Scheduled in `internal/web/web.go` → `startTask()`. Each job is a struct in
`internal/web/job/`. Examples: poll Xray traffic every 5s, check client IP limits every 10s,
node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly). See §5.4.
---
## 4. Directory map (what lives where)
```
3x-ui/
├── main.go # Entry point: CLI (run / migrate / migrate-db / setting / cert),
│ # bootstrap, signal handling, restart loop
├── go.mod / go.sum # Go deps (module path ends in /v3)
│
├── internal/ # ALL backend Go code (private packages)
│ ├── config/ # Env-var config: paths, DB kind/DSN, log level, version
│ │ # Every XUI_* env var is read here (config.go)
│ ├── database/
│ │ ├── db.go # InitDB: connect, AutoMigrate, seeders (~1.4k lines). DB hotspot.
│ │ ├── migrate_data.go # Data migrations (seeders/normalizers beyond AutoMigrate)
│ │ ├── dialect.go # SQLite vs Postgres SQL differences
│ │ ├── dump_sqlite.go # DB export/backup
│ │ └── model/ # **ALL GORM models** (model.go ~1.1k lines + siblings:
│ │ # node_client_traffic.go, node_client_ip.go,
│ │ # client_global_traffic.go). ⭐ Start here for data shape.
│ ├── eventbus/ # In-process pub/sub (buffered channel): outbound.down|up,
│ │ # xray.crash, node.down|up, cpu.high, memory.high, login.attempt
│ ├── tunnelmonitor/ # Optional tunnel health probe (XUI_TUNNEL_HEALTH_* env vars):
│ │ # HTTP probe (default Cloudflare trace); repeated failures
│ │ # trigger an Xray restart hook. Independent of panel settings.
│ ├── xray/ # Xray-core integration (the proxy engine wrapper)
│ │ ├── process.go # Spawn/supervise the Xray child process (~750 lines)
│ │ ├── api.go # gRPC client to a running Xray (add/remove user, stats) (~800 lines)
│ │ ├── hot_diff.go # ⭐ Compute minimal live changes to avoid full restart (~500 lines)
│ │ ├── config.go # Xray config object model
│ │ ├── inbound.go # Inbound JSON shaping
│ │ ├── client_traffic.go # ClientTraffic model (persisted as client_traffics)
│ │ ├── traffic.go # Traffic type helpers
│ │ └── log_writer.go # Pipe Xray stdout/stderr into the panel logger
│ │
│ ├── web/ # The panel server
│ │ ├── web.go # ⭐ Server bootstrap: initRouter (all routes) + startTask (all cron jobs)
│ │ ├── controller/ # HTTP handlers (thin). One file per resource:
│ │ │ ├── inbound.go # /panel/api/inbounds
│ │ │ ├── client.go # /panel/api/clients (CRUD + bulk + ips + onlines)
│ │ │ ├── group.go # client-group endpoints
│ │ │ ├── node.go # /panel/api/nodes (multi-node management)
│ │ │ ├── host.go # /panel/api/hosts (per-inbound subscription host overrides)
│ │ │ ├── server.go # /panel/api/server (status, xray version, certs, logs, DB import/export)
│ │ │ ├── setting.go # /panel/api/setting (settings + API tokens)
│ │ │ ├── xray_setting.go # /panel/api/xray (raw Xray config editor, WARP/Nord)
│ │ │ ├── api.go # /panel/api gateway (token auth, envelope + CSRF wiring)
│ │ │ ├── index.go # login/logout/csrf/2FA
│ │ │ ├── spa.go # SPA fallback for /panel UI routes
│ │ │ └── websocket.go # WS upgrade endpoint
│ │ ├── service/ # ⭐⭐ Business logic. This is where most real work happens.
│ │ │ ├── inbound.go # Inbound CRUD core (~1.4k lines)
│ │ │ ├── inbound_node.go # ⭐ Node sync for inbounds: reconcile, traffic merge (~1.1k lines)
│ │ │ ├── inbound_traffic.go # Per-client traffic accounting (~1.1k lines)
│ │ │ ├── inbound_clients.go # Client-within-inbound operations
│ │ │ ├── inbound_sublink.go # Inbound-level subscription link helpers
│ │ │ ├── inbound_migration.go # Inbound schema/format migrations
│ │ │ ├── client_crud.go # Client create/read/update/delete
│ │ │ ├── client_bulk.go # Bulk client ops (~1.6k lines)
│ │ │ ├── client_inbound_apply.go # ⭐ Apply client changes to runtime (Local/Remote) (~1.2k lines)
│ │ │ ├── client_groups.go # Client grouping
│ │ │ ├── client_link.go # Per-client share-link generation
│ │ │ ├── client_external_link.go # External links attached to clients
│ │ │ ├── client_wireguard.go # WireGuard client specifics
│ │ │ ├── client_paging.go # Server-side pagination/sort/filter for client lists
│ │ │ ├── node.go # ⭐ NodeService: CRUD, probe, heartbeat, dirty-tracking (~1.1k lines)
│ │ │ ├── node_mtls.go # Node mTLS certificate management (master side)
│ │ │ ├── node_tree.go # Node hierarchy / descendants
│ │ │ ├── host.go # Host rows (subscription output overrides)
│ │ │ ├── server.go # ServerService: status, certs, xray install, DB ops (~2.2k lines)
│ │ │ ├── setting.go # SettingService: all panel settings + defaults (~1.3k lines)
│ │ │ ├── setting_mtls.go # mTLS settings (node hardening)
│ │ │ ├── traffic_writer.go # Batched persistence of traffic deltas to the DB
│ │ │ ├── xray.go # ⭐ XrayService: config gen + restart/hot-apply (~1.2k lines)
│ │ │ ├── xray_setting.go # Raw Xray config persistence
│ │ │ ├── xray_metrics.go # Xray observability metrics
│ │ │ ├── metric_history.go # Historical system/xray metrics
│ │ │ ├── reality_scan.go # REALITY target scanner
│ │ │ ├── url_safety.go # Outbound URL validation (SSRF guards)
│ │ │ ├── outbound_subscription.go# Outbound subscription (e.g. Warp/Nord provider configs)
│ │ │ ├── port_conflict.go # Detect inbound port collisions
│ │ │ ├── fallback.go # Xray fallback (SNI/ALPN routing on shared port)
│ │ │ ├── email/ # Email notification service (SMTP)
│ │ │ ├── integration/ # External providers: warp.go (Cloudflare WARP), nord.go (NordVPN)
│ │ │ ├── outbound/ # Outbound config service
│ │ │ ├── panel/ # Cross-cutting panel services:
│ │ │ │ ├── panel.go # panel-level helpers
│ │ │ │ ├── user.go # admin user auth (bcrypt)
│ │ │ │ ├── api_token.go # API token CRUD (SHA-256 hashed)
│ │ │ │ └── websocket.go # WS hub / push service
│ │ │ └── tgbot/ # Telegram bot command handlers
│ │ ├── runtime/ # ⭐⭐ The Local/Remote node abstraction (see §5.2)
│ │ │ ├── runtime.go # the Runtime interface (the contract)
│ │ │ ├── local.go # Local impl → this box's Xray gRPC API
│ │ │ ├── remote.go # Remote impl → HTTPS calls to a child node
│ │ │ ├── tls_client.go # per-node HTTP client: verify / skip / pin / mtls
│ │ │ └── manager.go # RuntimeFor(nodeID) → picks Local or Remote
│ │ ├── job/ # Cron job structs (one file per job — see §5.4)
│ │ ├── middleware/ # Gin middleware: security.go (headers/HSTS), bodylimit.go,
│ │ │ # domainValidator.go, validate.go (CSRF), config_envelope.go
│ │ ├── global/ # Global singletons: web server + sub server handles, restart hook
│ │ ├── network/ # Custom net listeners (e.g. proxy-protocol aware)
│ │ ├── session/ # Session/cookie helpers
│ │ ├── websocket/ # WS hub implementation
│ │ ├── locale/ + translation/ # i18n middleware + 13 locale JSON catalogs
│ │ ├── entity/ # Shared request/response DTOs
│ │ └── dist/ # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
│ │
│ ├── sub/ # The subscription server (separate from panel)
│ │ ├── sub.go # server bootstrap
│ │ ├── controller.go # routes for raw / JSON / Clash subscription formats
│ │ ├── service.go # ⭐ The link/config builder (~2.5k lines — share-link logic lives here)
│ │ ├── json_service.go # JSON subscription format
│ │ ├── clash_service.go # Clash/Mihomo YAML format
│ │ ├── clash_external.go # external Clash config integration
│ │ ├── external_subscription.go / external_config.go # external sub import/aggregation
│ │ ├── host_sub.go # Host-row overrides applied to subscription output
│ │ ├── endpoint.go # subscription endpoint configuration
│ │ ├── vless_route.go # VLESS route shaping
│ │ ├── remark_vars.go # remark variable expansion
│ │ └── links.go # link helpers
│ │
│ ├── mtproto/ # Embedded MTProto (Telegram) proxy: manager.go + per-OS
│ │ # process supervision + orphan cleanup
│ ├── logger/ # App logger (op/go-logging + lumberjack rotation)
│ └── util/ # Leaf helpers (no business logic):
│ ├── common/ # errors, misc
│ ├── crypto/ # key/cert generation (x25519, ML-KEM/ML-DSA, ECH)
│ ├── link/ # outbound share-link building primitives
│ ├── wirecodec/ + wireguard/ # WireGuard codec + integration helpers
│ └── random/, json_util/, reflect_util/, sys/, netproxy/, netsafe/, ldap/
│
├── frontend/ # React SPA (built into internal/web/dist)
│ ├── vite.config.js # ⭐ Build config: outDir → ../internal/web/dist, dev on :5173
│ │ # (strict) proxying to :2053, entries index/login/subpage.html
│ ├── package.json # scripts: dev / build / preview / lint / typecheck / test / gen
│ └── src/
│ ├── main.tsx / routes.tsx / queryClient.ts # SPA entry, router, query client
│ ├── entries/ # Extra HTML entry points: login.tsx, subpage.tsx
│ ├── pages/ # ⭐ Route screens. Mirrors the panel's feature areas:
│ │ ├── inbounds/ # inbound list + the big inbound form (protocols/security/transport)
│ │ ├── clients/ # client management screens
│ │ ├── nodes/ # multi-node UI
│ │ ├── hosts/ # subscription host-override UI
│ │ ├── xray/ # raw Xray config UI (routing, dns, outbounds, balancers, overrides)
│ │ ├── index/ # dashboard/home
│ │ └── settings/, groups/, sub/, login/, api-docs/
│ ├── api/ # ⭐ Data layer: axios-init, QueryProvider, queryKeys, websocket bridge
│ │ └── queries/ # TanStack Query hooks (useNodesQuery, useStatusQuery, …)
│ ├── schemas/ # Zod schemas: protocols, forms, api, primitives
│ ├── generated/ # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts
│ ├── components/ # Reusable UI (clients/ form/ ui/ viz/ feedback/ utility/)
│ ├── lib/ # Frontend domain logic (xray/ inbounds/ clients/)
│ ├── hooks/, models/, layouts/, i18n/, utils/, styles/
│ └── test/ # Vitest + golden fixtures (config-generation snapshot tests)
│
├── tools/openapigen/ # ⭐ Go program that emits frontend/src/generated/* from Go types (§5.5)
├── docs/ # Markdown docs (this file, custom-subscription-templates.md, …)
├── media/ # README images
│
├── Dockerfile / docker-compose.yml / DockerEntrypoint.sh / DockerInit.sh # Container build/run
├── install.sh / update.sh / x-ui.sh # VPS install + management CLI
├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script
├── windows_files/ # Windows service support
└── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml,
# mutation.yml, cleanup_caches.yml, claude-bot.yml
```
---
## 5. Cross-cutting subsystems (the parts that span many files)
### 5.1 DB → Xray config pipeline (config generation & application)
The panel never edits Xray's running config directly from controllers. The flow is:
1. A service mutates DB state (inbound/client/setting).
2. `XrayService` (`service/xray.go`) builds a fresh `xray.Config` from DB state
(`GetXrayConfig`).
3. It tries a **hot apply** (`tryHotApply` → `xray/hot_diff.go`): diff old vs new config and
push only the deltas over the Xray gRPC API (add/remove inbound, add/remove user) — **no
process restart**, so live connections survive.
4. If the diff isn't hot-applicable (structural change), it falls back to a **full restart**
of the Xray process (`xray/process.go`).
Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
`IsNeedRestartAndSetFalse`), consumed by a `@every 30s` cron task registered in `startTask()`
— any number of mutations inside the window causes at most one restart.
**Key files:** `service/xray.go` (orchestration), `xray/hot_diff.go` (the diff algorithm),
`xray/process.go` (process lifecycle), `xray/api.go` (gRPC calls), `xray/config.go` (config model).
### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
inbound/client operation goes through the `runtime.Runtime` interface so the *same service
code* works whether the target is the local Xray or a remote node.
- **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`,
`UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`,
`RestartXray`, `ResetClientTraffic`, `ResetInboundTraffic`, `ResetAllTraffics`.
- **`Local`** (`local.go`): calls this box's Xray gRPC API directly.
- **`Remote`** (`remote.go`): serializes the operation and sends it over HTTPS to the child
node's API.
- **TLS modes** (`tls_client.go`, per-node `TlsVerifyMode`):
`verify` (system CAs, default) / `skip` (no validation) / `pin` (leaf cert SHA-256 must
match `PinnedCertSha256`) / `mtls` (master presents a client certificate; node cert checked
against system roots; API token optional). Master-side cert management:
`service/node_mtls.go` + `service/setting_mtls.go`.
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` *and* an
`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
online clients back to the originating panel using **stable GUIDs** rather than local IDs.
Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge,
`synthNodeGuid`, `panelGuid`) and `service/node.go` (`effectiveNodeGuid`, heartbeat, dirty
tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an offline node's
inbound edits converge once it reconnects.
**Where to look for node bugs:**
- Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`.
- Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths).
- Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`).
- Edits to an offline node not applying on reconnect → dirty/reconcile logic in `service/inbound_node.go` + `service/node.go` (`MarkNodeDirty`/`ClearNodeDirty`/`NodeSyncState`).
- TLS/mTLS handshake failures → `runtime/tls_client.go`, `service/node_mtls.go`, `service/node.go` (`FetchCertFingerprint`).
### 5.3 Traffic accounting
Per-client and per-inbound up/down counters originate from Xray's stats API and are persisted
to the DB. The Xray traffic job polls the core; node traffic is pulled from child nodes and
merged with GUID-based baselines to avoid double counting after resets.
**Key files:** `service/inbound_traffic.go`, `service/traffic_writer.go`,
`job/xray_traffic_job.go`, `job/node_traffic_sync_job.go`, `service/inbound_node.go`
(`SetRemoteTraffic` / `upsertNodeBaseline`), models `xray.ClientTraffic`,
`model.NodeClientTraffic`, `model.ClientGlobalTraffic` (cross-master totals).
Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
### 5.4 Background jobs (cron)
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
| Schedule | Job | Purpose / condition |
|---|---|---|
| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) |
| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) |
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | Log cleanup; resets |
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` |
| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` |
| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS |
To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file.
### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files
The Go backend is the schema source of truth. `tools/openapigen` (a Go program, with a
`StructAllow` allowlist of exported types) emits
`frontend/src/generated/{schemas,types,zod,examples}.ts`. The frontend build runs this first:
- `npm run gen:zod` → `go run ./tools/openapigen` (regenerate from Go)
- `npm run gen:api` → builds the OpenAPI doc (`scripts/build-openapi.mjs`, driven by the
hand-maintained endpoint registry `src/pages/api-docs/endpoints.ts`)
- `npm run build` runs `gen:api` then `vite build`.
**Implication:** if you change a Go model/DTO that crosses the API boundary, regenerate the
frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/` by hand.
### 5.6 Share-link / subscription generation
Two distinct code paths produce client configs:
- **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go`
+ `util/link/outbound.go`.
- **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links),
`internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML).
**`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/
security per inbound in subscription output — applied in `sub/host_sub.go`.
Both paths must agree per protocol. A malformed link for a specific protocol/transport combo
(e.g. XHTTP + Reality) is usually a field-lookup mismatch in **`internal/sub/service.go`** (and
its tests `service_test.go` / golden fixtures), or in `util/link/outbound.go`. The frontend
also has protocol schemas under `frontend/src/schemas/protocols/` and `frontend/src/lib/xray/`.
### 5.7 Event bus (in-process pub/sub)
`internal/eventbus/` is a minimal buffered-channel pub/sub. Producers call a non-blocking
`Publish(Event)`; all subscribers receive every event. Event types: `outbound.down|up`,
`xray.crash`, `node.down|up`, `cpu.high`, `memory.high`, `login.attempt`, with structured
payloads (OutboundHealthData, NodeHealthData, LoginEventData, SystemMetricData). Producers
include the CPU/memory jobs, node heartbeat, and login handling; consumers include the
Telegram bot and the email notifier (`service/email/`). Use it for cross-cutting
notifications instead of importing notification services into producers.
### 5.8 Tunnel health monitor
`internal/tunnelmonitor/` is an optional watchdog configured **only via env vars**
(`XUI_TUNNEL_HEALTH_*`, read in `internal/config/`), deliberately independent of panel
settings so it can be enabled from a systemd `EnvironmentFile` even when the panel is
unreachable. It periodically probes an HTTP URL (default: Cloudflare trace endpoint) through
the tunnel; after N successive failures (default 3) it fires a recovery callback wired to an
Xray restart.
---
## 6. Data model cheat-sheet
GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered
for AutoMigrate in `internal/database/db.go`.
| Model | Table role | Notable fields |
|---|---|---|
| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) |
| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) |
| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) |
| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` |
| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` |
| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` |
| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags |
| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields |
| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) |
| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` |
| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` |
| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` |
| `InboundClientIps` | IP set per client email | drives IP-limit enforcement |
| `OutboundTraffics` | Outbound counters | per outbound tag |
| `OutboundSubscription` | External provider subs | Warp/Nord style |
| `Setting` | Key/value panel settings | everything configurable |
| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) |
| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest |
| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations |
---
## 7. Symptom → File index (start here when debugging)
| Symptom / task | Primary file(s) | Then check |
|---|---|---|
| Add/modify an **API endpoint** | `controller/<resource>.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` |
| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` |
| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` |
| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` |
| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` |
| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` |
| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` |
| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` |
| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` |
| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) |
| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) |
| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) |
| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` |
| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests |
| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` |
| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` |
| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` |
| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` |
| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) |
| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` |
| **Frontend route / screen** | `frontend/src/pages/<area>/`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` |
| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` |
| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil |
---
## 8. Layering rules (where new code belongs)
1. **Controllers are thin.** Only: bind/validate input, call one service, shape the HTTP
response. No DB queries, no Xray calls, no business rules in `controller/`.
2. **Services own the logic and transactions.** All business rules, DB access, and decisions
about applying changes live in `service/`. If you're tempted to query GORM from a
controller, move it to a service.
3. **Never touch Xray's running state from a controller or job directly.** Go through
`XrayService` / the `runtime.Runtime` interface so local vs node dispatch stays correct.
4. **Any state-changing inbound/client op must dispatch through `runtime.Runtime`**, not
straight to `xray/api.go` — otherwise node deployments silently break.
5. **`internal/util/*` is leaf-only** (no imports of `service`/`controller`/`database`). Keep
helpers pure.
6. **Don't hand-edit generated files:** `frontend/src/generated/*` and `internal/web/dist/*`.
Regenerate instead.
7. **Models are the contract.** Changing a model field that crosses the API boundary means:
update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types.
8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an *end user*
fetches goes in `internal/sub`. Don't blur them.
9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead
of importing the Telegram/email services into producers.
---
## 9. Build / Test / Lint (verify your changes)
The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make gen`
(regenerate Zod/OpenAPI), `make lint` (Go + frontend), `make test` (Go `-shuffle=on` +
frontend), `make race`, `make build`. Run `make help` for everything. Raw commands:
**Backend (Go):**
```bash
go build ./... # compile everything
go test ./... # run all Go tests (many *_test.go alongside sources)
go test ./internal/web/service/... # focused: service-layer tests
go test ./internal/xray/... # hot-diff / process / api tests
go test ./internal/sub/... # subscription + golden link tests
go vet ./... # static checks
golangci-lint run # full lint (gofumpt + goimports formatting)
go run main.go # run the panel locally (serves embedded dist if built)
```
**Frontend (`cd frontend`, Node ≥ 22):**
```bash
npm install
npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
npm run typecheck # tsc --noEmit
npm run lint # eslint src
npm run test # vitest (incl. golden config-generation snapshots)
npm run gen # regenerate src/generated/* from Go (gen:zod + gen:api)
npm run build # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed)
```
**Full local loop:** `cd frontend && npm run build` (refresh embedded `dist/`) → back to repo
root → `go build ./...` / `go run main.go`.
**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
(multi-arch image + release builds), `cleanup_caches.yml`, `claude-bot.yml` (issue bot).
---
## 10. Gotchas & conventions
- **Module path is `.../v3`.** Internal imports use `github.com/mhsanaei/3x-ui/v3/internal/...`.
- **SQLite vs Postgres.** Default is SQLite at `{XUI_DB_FOLDER}/x-ui.db`. Postgres via
`XUI_DB_TYPE=postgres` + `XUI_DB_DSN`. Some SQL paths are dialect-aware (`database/dialect.go`);
test both when touching raw queries (there are `*_scale_postgres_test.go` suites).
- **`Inbound.Settings` / `StreamSettings` / `Sniffing` are raw JSON strings**, not structured
columns. Parsing/validation happens in services and the `xray` package, not in GORM.
- **Hot-reload is the default; full restart is the fallback.** Changes that look config-only
but cause a restart usually mean the diff in `xray/hot_diff.go` didn't recognize them as hot.
- **Node TLS:** remote calls honor `TlsVerifyMode` (`verify`/`skip`/`pin`/`mtls`). "Works on
skip, fails on verify/pin/mtls" → cert/fingerprint handling in `service/node.go`
(`FetchCertFingerprint`), `service/node_mtls.go`, and `runtime/tls_client.go`.
- **Restart is signal-driven.** `main.go` traps SIGHUP to restart panel+sub servers; the
in-process restart hook (`global.SetRestartHook`) funnels into the same path.
- **i18n:** backend catalogs in `internal/web/translation/` (13 locales, shared with the
frontend); frontend wiring in `frontend/src/i18n/`. Persian (`fa_IR`) is a first-class
locale (Jalali calendar via `persian-calendar-suite`).
- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
`frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
not blindly, when output changes.
+168 -177
View File
@@ -11,21 +11,22 @@
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2",
"antd": "^6.5.0", "antd": "^6.5.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.3", "i18next": "^26.3.4",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.3", "qs": "^6.15.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.1",
"recharts": "^3.9.0", "recharts": "^3.9.1",
"swagger-ui-react": "^5.32.8", "swagger-ui-react": "^5.32.8",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -44,8 +45,8 @@
"globals": "^17.7.0", "globals": "^17.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.1",
"vite": "8.1.0", "vite": "8.1.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
}, },
"engines": { "engines": {
@@ -1105,9 +1106,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.137.0", "version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -1843,20 +1844,10 @@
} }
} }
}, },
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1871,9 +1862,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1888,9 +1879,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1905,9 +1896,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1922,9 +1913,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1939,9 +1930,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1959,9 +1950,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1979,9 +1970,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1999,9 +1990,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -2019,9 +2010,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2039,9 +2030,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2059,9 +2050,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2076,9 +2067,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -2095,9 +2086,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2112,9 +2103,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3108,17 +3099,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
"integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/type-utils": "8.62.0", "@typescript-eslint/type-utils": "8.62.1",
"@typescript-eslint/utils": "8.62.0", "@typescript-eslint/utils": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@@ -3131,7 +3122,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.62.0", "@typescript-eslint/parser": "^8.62.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
@@ -3147,16 +3138,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.0", "@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -3172,14 +3163,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
"integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.0", "@typescript-eslint/tsconfig-utils": "^8.62.1",
"@typescript-eslint/types": "^8.62.0", "@typescript-eslint/types": "^8.62.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -3194,14 +3185,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.62.0", "@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.0" "@typescript-eslint/visitor-keys": "8.62.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3212,9 +3203,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
"integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3229,15 +3220,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
"integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.62.0", "@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.0", "@typescript-eslint/utils": "8.62.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@@ -3254,9 +3245,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3268,16 +3259,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
"integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.62.0", "@typescript-eslint/project-service": "8.62.1",
"@typescript-eslint/tsconfig-utils": "8.62.0", "@typescript-eslint/tsconfig-utils": "8.62.1",
"@typescript-eslint/types": "8.62.0", "@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -3309,16 +3300,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
"integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.0", "@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.0" "@typescript-eslint/typescript-estree": "8.62.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3333,13 +3324,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.62.0", "@typescript-eslint/types": "8.62.1",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@@ -3918,9 +3909,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.41",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -4058,9 +4049,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001799", "version": "1.0.30001800",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4644,9 +4635,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.381", "version": "1.5.384",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4777,9 +4768,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.2.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -5700,9 +5691,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.3", "version": "26.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
"integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==", "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -5758,9 +5749,9 @@
} }
}, },
"node_modules/immer": { "node_modules/immer": {
"version": "10.2.0", "version": "11.1.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -7690,9 +7681,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.0", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -7712,12 +7703,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.0", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.0" "react-router": "7.18.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -7748,9 +7739,9 @@
} }
}, },
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.9.0", "version": "3.9.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz",
"integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", "integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"www" "www"
@@ -7761,7 +7752,7 @@
"decimal.js-light": "^2.5.1", "decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3", "es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"immer": "^10.1.1", "immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x", "react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0", "reselect": "5.2.0",
"tiny-invariant": "^1.3.3", "tiny-invariant": "^1.3.3",
@@ -7927,13 +7918,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.137.0", "@oxc-project/types": "=0.138.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -7943,21 +7934,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-android-arm64": "1.1.4",
"@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.4",
"@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.4",
"@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
"@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.4",
"@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.4",
"@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
"@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.4",
"@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.4",
"@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.4",
"@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.4",
"@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.4",
"@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.4",
"@rolldown/binding-win32-x64-msvc": "1.1.3" "@rolldown/binding-win32-x64-msvc": "1.1.4"
} }
}, },
"node_modules/safe-array-concat": { "node_modules/safe-array-concat": {
@@ -8830,16 +8821,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.62.0", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
"integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.62.0", "@typescript-eslint/eslint-plugin": "8.62.1",
"@typescript-eslint/parser": "8.62.0", "@typescript-eslint/parser": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.0" "@typescript-eslint/utils": "8.62.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8971,16 +8962,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.0", "version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.15", "postcss": "^8.5.16",
"rolldown": "~1.1.2", "rolldown": "~1.1.3",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
+6 -5
View File
@@ -24,21 +24,22 @@
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2",
"antd": "^6.5.0", "antd": "^6.5.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.3", "i18next": "^26.3.4",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.3", "qs": "^6.15.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.1",
"recharts": "^3.9.0", "recharts": "^3.9.1",
"swagger-ui-react": "^5.32.8", "swagger-ui-react": "^5.32.8",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -57,8 +58,8 @@
"globals": "^17.7.0", "globals": "^17.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.1",
"vite": "8.1.0", "vite": "8.1.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
}, },
"overrides": { "overrides": {
+75
View File
@@ -2147,6 +2147,34 @@
], ],
"type": "object" "type": "object"
}, },
"PanelUpdateStatus": {
"description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.",
"properties": {
"exitCode": {
"example": 0,
"type": "integer"
},
"finishedAt": {
"example": 1735689612,
"type": "integer"
},
"runId": {
"example": "1735689600123456789",
"type": "string"
},
"state": {
"example": "success",
"type": "string"
}
},
"required": [
"exitCode",
"finishedAt",
"runId",
"state"
],
"type": "object"
},
"ProbeResultUI": { "ProbeResultUI": {
"properties": { "properties": {
"cpuPct": { "cpuPct": {
@@ -3901,6 +3929,47 @@
} }
} }
}, },
"/panel/api/server/getUpdateStatus": {
"get": {
"tags": [
"Server"
],
"summary": "Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.",
"operationId": "get_panel_api_server_getUpdateStatus",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/PanelUpdateStatus"
}
}
},
"example": {
"success": true,
"obj": {
"exitCode": 0,
"finishedAt": 1735689612,
"runId": "1735689600123456789",
"state": "success"
}
}
}
}
}
}
}
},
"/panel/api/server/getConfigJson": { "/panel/api/server/getConfigJson": {
"get": { "get": {
"tags": [ "tags": [
@@ -4427,6 +4496,12 @@
}, },
"obj": {} "obj": {}
} }
},
"example": {
"success": true,
"obj": {
"runId": "1735689600123456789"
}
} }
} }
} }
+6
View File
@@ -468,6 +468,12 @@ export const EXAMPLES: Record<string, unknown> = {
"total": 0, "total": 0,
"up": 0 "up": 0
}, },
"PanelUpdateStatus": {
"exitCode": 0,
"finishedAt": 1735689612,
"runId": "1735689600123456789",
"state": "success"
},
"ProbeResultUI": { "ProbeResultUI": {
"cpuPct": 12.5, "cpuPct": 12.5,
"error": "", "error": "",
+28
View File
@@ -2121,6 +2121,34 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"PanelUpdateStatus": {
"description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.",
"properties": {
"exitCode": {
"example": 0,
"type": "integer"
},
"finishedAt": {
"example": 1735689612,
"type": "integer"
},
"runId": {
"example": "1735689600123456789",
"type": "string"
},
"state": {
"example": "success",
"type": "string"
}
},
"required": [
"exitCode",
"finishedAt",
"runId",
"state"
],
"type": "object"
},
"ProbeResultUI": { "ProbeResultUI": {
"properties": { "properties": {
"cpuPct": { "cpuPct": {
+7
View File
@@ -464,6 +464,13 @@ export interface OutboundTraffics {
up: number; up: number;
} }
export interface PanelUpdateStatus {
exitCode: number;
finishedAt: number;
runId: string;
state: string;
}
export interface ProbeResultUI { export interface ProbeResultUI {
cpuPct: number; cpuPct: number;
error: string; error: string;
+8
View File
@@ -495,6 +495,14 @@ export const OutboundTrafficsSchema = z.object({
}); });
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>; export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
export const PanelUpdateStatusSchema = z.object({
exitCode: z.number().int(),
finishedAt: z.number().int(),
runId: z.string(),
state: z.string(),
});
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
export const ProbeResultUISchema = z.object({ export const ProbeResultUISchema = z.object({
cpuPct: z.number(), cpuPct: z.number(),
error: z.string(), error: z.string(),
+2 -2
View File
@@ -551,9 +551,9 @@ export function useClients() {
const applyClientStatsEvent = useCallback((payload: unknown) => { const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientStatRow[] }; const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
if (!Array.isArray(p.clients) || p.clients.length === 0) return; if (!Array.isArray(p.clients) || p.clients.length === 0) return;
setAllClientStats(p.clients); if (p.snapshot !== false) setAllClientStats(p.clients);
const byEmail = new Map<string, ClientTraffic>(); const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients) { for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row); if (row && row.email) byEmail.set(row.email, row);
@@ -13,7 +13,7 @@ import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod'; import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities'; import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -164,7 +164,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
const streamRecord = streamSettings as unknown as Record<string, unknown>; const streamRecord = streamSettings as unknown as Record<string, unknown>;
const xh = streamRecord.xhttpSettings; const xh = streamRecord.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) { if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const xhttp = xh as Record<string, unknown>; const parsed = XHttpStreamSettingsSchema.safeParse(xh);
const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
streamRecord.xhttpSettings = xhttp;
const xmux = xhttp.xmux; const xmux = xhttp.xmux;
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) { if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
xhttp.enableXmux = true; xhttp.enableXmux = true;
+20 -5
View File
@@ -13,6 +13,7 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers'; import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities'; import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
// Share-link generators. Each per-protocol fn takes a typed inbound plus // Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't // client overrides and returns a URL (or '' when the protocol doesn't
@@ -322,6 +323,7 @@ export interface GenVlessLinkInput {
forceTls?: ForceTls; forceTls?: ForceTls;
remark?: string; remark?: string;
clientId: string; clientId: string;
clientKey?: string;
flow?: VlessClient['flow']; flow?: VlessClient['flow'];
externalProxy?: ExternalProxyEntry | null; externalProxy?: ExternalProxyEntry | null;
} }
@@ -350,6 +352,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
forceTls = 'same', forceTls = 'same',
remark = '', remark = '',
clientId, clientId,
clientKey = '',
flow = '', flow = '',
externalProxy = null, externalProxy = null,
} = input; } = input;
@@ -430,7 +433,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (sni && sni.length > 0) params.set('sni', sni); if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX); const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
if (spx.length > 0) params.set('spx', spx);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
} }
} else { } else {
@@ -512,7 +516,7 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
// Reality query-string writer shared by VLESS and Trojan. Preserves the // Reality query-string writer shared by VLESS and Trojan. Preserves the
// legacy SNI-omission quirk (see genVlessLink for the full story). // legacy SNI-omission quirk (see genVlessLink for the full story).
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void { function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams, clientKey: string): void {
if (stream.security !== 'reality') return; if (stream.security !== 'reality') return;
const reality = stream.realitySettings; const reality = stream.realitySettings;
params.set('pbk', reality.settings.publicKey); params.set('pbk', reality.settings.publicKey);
@@ -526,7 +530,8 @@ function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, para
if (sni && sni.length > 0) params.set('sni', sni); if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX); const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
if (spx.length > 0) params.set('spx', spx);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
} }
@@ -537,6 +542,7 @@ export interface GenTrojanLinkInput {
forceTls?: ForceTls; forceTls?: ForceTls;
remark?: string; remark?: string;
clientPassword: string; clientPassword: string;
clientKey?: string;
externalProxy?: ExternalProxyEntry | null; externalProxy?: ExternalProxyEntry | null;
} }
@@ -551,6 +557,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
forceTls = 'same', forceTls = 'same',
remark = '', remark = '',
clientPassword, clientPassword,
clientKey = '',
externalProxy = null, externalProxy = null,
} = input; } = input;
@@ -571,7 +578,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
applyExternalProxyTLSParams(externalProxy, params, security); applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') { } else if (security === 'reality') {
params.set('security', 'reality'); params.set('security', 'reality');
writeRealityParams(stream, params); writeRealityParams(stream, params, clientKey);
} else { } else {
params.set('security', 'none'); params.set('security', 'none');
} }
@@ -1017,7 +1024,13 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
// `this.clients` getter, which used isSSMultiUser to gate). Returns null // `this.clients` getter, which used isSSMultiUser to gate). Returns null
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without- // for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
// clients, and any protocol without a clients array. // clients, and any protocol without a clients array.
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string }; type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string; subId?: string };
// Mirror of the Go subKey: the stable per-client identity spx derivation
// keys on — subscription id first, unique email as the fallback.
function clientSubKey(client: ClientShape): string {
return client.subId || client.email || '';
}
export function getInboundClients(inbound: Inbound): ClientShape[] | null { export function getInboundClients(inbound: Inbound): ClientShape[] | null {
switch (inbound.protocol) { switch (inbound.protocol) {
@@ -1066,6 +1079,7 @@ export function genLink(input: GenLinkInput): string {
return genVlessLink({ return genVlessLink({
inbound, address, port, forceTls, remark, inbound, address, port, forceTls, remark,
clientId: client.id ?? '', clientId: client.id ?? '',
clientKey: clientSubKey(client),
flow: client.flow, flow: client.flow,
externalProxy, externalProxy,
}); });
@@ -1081,6 +1095,7 @@ export function genLink(input: GenLinkInput): string {
return genTrojanLink({ return genTrojanLink({
inbound, address, port, forceTls, remark, inbound, address, port, forceTls, remark,
clientPassword: client.password ?? '', clientPassword: client.password ?? '',
clientKey: clientSubKey(client),
externalProxy, externalProxy,
}); });
case 'hysteria': case 'hysteria':
+10
View File
@@ -0,0 +1,10 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
// links and subscription links agree; returns '' when there is no seed and
// no client key (the caller then omits spx, as the legacy builder did).
export function deriveSpiderX(seed: string, clientKey: string): string {
if (!seed && !clientKey) return '';
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
}
@@ -108,6 +108,18 @@ export function validateRealityTarget(target: string): string | undefined {
return undefined; return undefined;
} }
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
const lift = (legacy: string, renamed: string) => {
const v = obj[legacy];
if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
obj[renamed] = v;
}
delete obj[legacy];
};
lift('sessionPlacement', 'sessionIDPlacement');
lift('sessionKey', 'sessionIDKey');
}
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void { function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) { for (const key of keys) {
const v = obj[key]; const v = obj[key];
@@ -160,6 +172,7 @@ export function normalizeXhttpForWire(
side: StreamWireSide, side: StreamWireSide,
): Record<string, unknown> { ): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw }; const out: Record<string, unknown> = { ...raw };
liftLegacyXhttpSessionKeys(out);
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto'; const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
const enableXmux = out.enableXmux === true; const enableXmux = out.enableXmux === true;
delete out.enableXmux; delete out.enableXmux;
+29
View File
@@ -0,0 +1,29 @@
export type VlessAuthKind =
| 'x25519'
| 'x25519_xorpub'
| 'x25519_random'
| 'mlkem768'
| 'mlkem768_xorpub'
| 'mlkem768_random';
export const VLESS_AUTH_LABEL_KEYS: Record<VlessAuthKind, string> = {
x25519: 'pages.inbounds.vlessAuthX25519',
x25519_xorpub: 'pages.inbounds.vlessAuthX25519Xorpub',
x25519_random: 'pages.inbounds.vlessAuthX25519Random',
mlkem768: 'pages.inbounds.vlessAuthMlkem768',
mlkem768_xorpub: 'pages.inbounds.vlessAuthMlkem768Xorpub',
mlkem768_random: 'pages.inbounds.vlessAuthMlkem768Random',
};
const MLKEM768_MIN_KEY_LENGTH = 300;
export function vlessEncryptionAuthKind(encryption: string): VlessAuthKind | null {
if (!encryption || encryption === 'none') return null;
const parts = encryption.split('.').filter(Boolean);
const authKey = parts[parts.length - 1] || '';
if (!authKey) return null;
const mode = parts[1] || 'native';
const keyType = authKey.length > MLKEM768_MIN_KEY_LENGTH ? 'mlkem768' : 'x25519';
if (mode === 'xorpub' || mode === 'random') return `${keyType}_${mode}`;
return keyType;
}
+3
View File
@@ -103,6 +103,9 @@ export class AllSetting {
hasWarpSecret = false; hasWarpSecret = false;
hasNordSecret = false; hasNordSecret = false;
hasSmtpPassword = false; hasSmtpPassword = false;
clearTgBotToken = false;
clearLdapPassword = false;
clearSmtpPassword = false;
constructor(data?: unknown) { constructor(data?: unknown) {
if (data != null) { if (data != null) {
+7
View File
@@ -323,6 +323,12 @@ export const sections: readonly Section[] = [
path: '/panel/api/server/getPanelUpdateInfo', path: '/panel/api/server/getPanelUpdateInfo',
summary: 'Check whether a newer 3x-ui release is available on GitHub.', summary: 'Check whether a newer 3x-ui release is available on GitHub.',
}, },
{
method: 'GET',
path: '/panel/api/server/getUpdateStatus',
summary: 'Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.',
responseSchema: 'PanelUpdateStatus',
},
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getConfigJson', path: '/panel/api/server/getConfigJson',
@@ -407,6 +413,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/server/updatePanel', path: '/panel/api/server/updatePanel',
summary: 'Self-update the panel to the latest version. The server restarts on success.', summary: 'Self-update the panel to the latest version. The server restarts on success.',
response: '{\n "success": true,\n "obj": {\n "runId": "1735689600123456789"\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
+17 -5
View File
@@ -492,6 +492,13 @@ export default function ClientFormModal({
if (form.wgPreSharedKey) { if (form.wgPreSharedKey) {
clientPayload.preSharedKey = form.wgPreSharedKey; clientPayload.preSharedKey = form.wgPreSharedKey;
} }
const allowedIPs = form.wgAllowedIPs
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
if (allowedIPs.length > 0) {
clientPayload.allowedIPs = allowedIPs;
}
} }
const externalLinks: ExternalLinkInput[] = form.externalLinks const externalLinks: ExternalLinkInput[] = form.externalLinks
@@ -802,11 +809,16 @@ export default function ClientFormModal({
onChange={(e) => update('wgPreSharedKey', e.target.value)} onChange={(e) => update('wgPreSharedKey', e.target.value)}
/> />
</Form.Item> </Form.Item>
{isEdit && form.wgAllowedIPs && ( <Form.Item
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}> label={t('pages.clients.wireguardAllowedIPs')}
<Input value={form.wgAllowedIPs} disabled /> extra={t('pages.clients.wireguardAllowedIPsHint')}
</Form.Item> >
)} <Input
value={form.wgAllowedIPs}
placeholder="10.0.0.2/32"
onChange={(e) => update('wgAllowedIPs', e.target.value)}
/>
</Form.Item>
</> </>
)} )}
</> </>
@@ -41,6 +41,7 @@ import { Protocols } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
import { SniffingSchema } from '@/schemas/primitives/sniffing'; import { SniffingSchema } from '@/schemas/primitives/sniffing';
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp'; import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp'; import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
@@ -248,6 +249,7 @@ export default function InboundFormModal({
scanRealityCandidates, scanRealityCandidates,
applyRealityScanResult, applyRealityScanResult,
randomizeShortIds, randomizeShortIds,
randomizeSpiderX,
getNewEchCert, getNewEchCert,
clearEchCert, clearEchCert,
pinFromCert, pinFromCert,
@@ -316,27 +318,14 @@ export default function InboundFormModal({
form.setFieldValue(['settings', 'encryption'], 'none'); form.setFieldValue(['settings', 'encryption'], 'none');
}; };
const vlessAuthKind = vlessEncryptionAuthKind(
typeof vlessEncryption === 'string' ? vlessEncryption : '',
);
const selectedVlessAuth = (() => { const selectedVlessAuth = (() => {
const enc = typeof vlessEncryption === 'string' ? vlessEncryption : ''; const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
if (!enc || enc === 'none') return 'None'; if (!enc || enc === 'none') return 'None';
const parts = enc.split('.').filter(Boolean); if (!vlessAuthKind) return t('pages.inbounds.vlessAuthCustom');
const authKey = parts[parts.length - 1] || ''; return t(VLESS_AUTH_LABEL_KEYS[vlessAuthKind]);
if (!authKey) return t('pages.inbounds.vlessAuthCustom');
const mode = parts[1] || 'native';
const keyType = authKey.length > 300 ? 'mlkem768' : 'x25519';
if (mode === 'xorpub') {
return keyType === 'mlkem768'
? t('pages.inbounds.vlessAuthMlkem768Xorpub')
: t('pages.inbounds.vlessAuthX25519Xorpub');
}
if (mode === 'random') {
return keyType === 'mlkem768'
? t('pages.inbounds.vlessAuthMlkem768Random')
: t('pages.inbounds.vlessAuthX25519Random');
}
return keyType === 'mlkem768'
? t('pages.inbounds.vlessAuthMlkem768')
: t('pages.inbounds.vlessAuthX25519');
})(); })();
useEffect(() => { useEffect(() => {
@@ -702,7 +691,7 @@ export default function InboundFormModal({
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />} {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />} {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
{isFallbackHost && fallbacksCard} {isFallbackHost && fallbacksCard}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
@@ -896,6 +885,7 @@ export default function InboundFormModal({
scanRealityCandidates={scanRealityCandidates} scanRealityCandidates={scanRealityCandidates}
applyRealityScanResult={applyRealityScanResult} applyRealityScanResult={applyRealityScanResult}
randomizeShortIds={randomizeShortIds} randomizeShortIds={randomizeShortIds}
randomizeSpiderX={randomizeSpiderX}
genRealityKeypair={genRealityKeypair} genRealityKeypair={genRealityKeypair}
clearRealityKeypair={clearRealityKeypair} clearRealityKeypair={clearRealityKeypair}
genMldsa65={genMldsa65} genMldsa65={genMldsa65}
@@ -1,18 +1,13 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd'; import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
type VlessAuthKind = import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
| 'x25519'
| 'x25519_xorpub'
| 'x25519_random'
| 'mlkem768'
| 'mlkem768_xorpub'
| 'mlkem768_random';
interface VlessFieldsProps { interface VlessFieldsProps {
saving: boolean; saving: boolean;
selectedVlessAuth: string; selectedVlessAuth: string;
vlessAuthKind: VlessAuthKind | null;
network: string; network: string;
security: string; security: string;
getNewVlessEnc: (kind: VlessAuthKind) => void; getNewVlessEnc: (kind: VlessAuthKind) => void;
@@ -22,22 +17,22 @@ interface VlessFieldsProps {
export default function VlessFields({ export default function VlessFields({
saving, saving,
selectedVlessAuth, selectedVlessAuth,
vlessAuthKind,
network, network,
security, security,
getNewVlessEnc, getNewVlessEnc,
clearVlessEnc, clearVlessEnc,
}: VlessFieldsProps) { }: VlessFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519'); const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
const authOptions = [ useEffect(() => {
{ value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') }, setAuthKind(vlessAuthKind ?? 'x25519');
{ value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') }, }, [vlessAuthKind]);
{ value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
{ value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') }, const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map(
{ value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') }, ([value, labelKey]) => ({ value, label: t(labelKey) }),
{ value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') }, );
];
return ( return (
<> <>
@@ -16,6 +16,7 @@ interface RealityFormProps {
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>; scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
applyRealityScanResult: (result: RealityScanResult) => void; applyRealityScanResult: (result: RealityScanResult) => void;
randomizeShortIds: () => void; randomizeShortIds: () => void;
randomizeSpiderX: () => void;
genRealityKeypair: () => void; genRealityKeypair: () => void;
clearRealityKeypair: () => void; clearRealityKeypair: () => void;
genMldsa65: () => void; genMldsa65: () => void;
@@ -30,6 +31,7 @@ export default function RealityForm({
scanRealityCandidates, scanRealityCandidates,
applyRealityScanResult, applyRealityScanResult,
randomizeShortIds, randomizeShortIds,
randomizeSpiderX,
genRealityKeypair, genRealityKeypair,
clearRealityKeypair, clearRealityKeypair,
genMldsa65, genMldsa65,
@@ -147,10 +149,18 @@ export default function RealityForm({
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
label={t('pages.inbounds.form.spiderX')} label={t('pages.inbounds.form.spiderX')}
tooltip={t('pages.inbounds.form.spiderXHint')}
> >
<Input /> <Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
noStyle
>
<Input style={{ flex: 1 }} />
</Form.Item>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
</Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']} name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
@@ -124,6 +124,13 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
); );
}; };
const randomizeSpiderX = () => {
form.setFieldValue(
['streamSettings', 'realitySettings', 'settings', 'spiderX'],
`/${RandomUtil.randomSeq(15)}`,
);
};
const getNewEchCert = async () => { const getNewEchCert = async () => {
const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']); const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
setSaving(true); setSaving(true);
@@ -270,6 +277,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
} }
form.setFieldValue('streamSettings', cleaned); form.setFieldValue('streamSettings', cleaned);
if (next === 'reality') { if (next === 'reality') {
randomizeSpiderX();
try { try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) { if (msg?.success) {
@@ -292,6 +300,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
scanRealityCandidates, scanRealityCandidates,
applyRealityScanResult, applyRealityScanResult,
randomizeShortIds, randomizeShortIds,
randomizeSpiderX,
getNewEchCert, getNewEchCert,
clearEchCert, clearEchCert,
pinFromCert, pinFromCert,
+25 -8
View File
@@ -6,8 +6,11 @@ import axios from 'axios';
import { HttpUtil, PromiseUtil } from '@/utils'; import { HttpUtil, PromiseUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version'; import { formatPanelVersion } from '@/lib/panel-version';
import type { PanelUpdateStatus } from '@/generated/types';
import './PanelUpdateModal.css'; import './PanelUpdateModal.css';
type UpdateOutcome = 'success' | 'failed' | 'timeout';
export interface PanelUpdateInfo { export interface PanelUpdateInfo {
channel?: string; channel?: string;
currentVersion: string; currentVersion: string;
@@ -45,19 +48,23 @@ export default function PanelUpdateModal({
const isDev = info.channel === 'dev'; const isDev = info.channel === 'dev';
async function pollUntilBack(): Promise<boolean> { async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
await PromiseUtil.sleep(5000); await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000; const deadline = Date.now() + 90_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const r = await axios.get('/panel/api/server/status', { timeout: 2000 }); const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 });
if (r?.data?.success) return true; const status = r?.data?.obj as PanelUpdateStatus | undefined;
if (status?.runId === expectedRunId) {
if (status.state === 'success') return 'success';
if (status.state === 'failed') return 'failed';
}
} catch { } catch {
/* still restarting */ /* still restarting */
} }
await PromiseUtil.sleep(2000); await PromiseUtil.sleep(2000);
} }
return false; return 'timeout';
} }
async function handleChannel(checked: boolean) { async function handleChannel(checked: boolean) {
@@ -81,14 +88,24 @@ export default function PanelUpdateModal({
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip; const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
onClose(); onClose();
onBusy({ busy: true, tip }); onBusy({ busy: true, tip });
const result = await HttpUtil.post('/panel/api/server/updatePanel'); const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
if (!result?.success) { if (!result?.success) {
onBusy({ busy: false }); onBusy({ busy: false });
return; return;
} }
const back = await pollUntilBack(); const outcome = await pollUpdateStatus(result.obj?.runId ?? '');
if (back) await PromiseUtil.sleep(800); onBusy({ busy: false });
window.location.reload(); if (outcome === 'success') {
await PromiseUtil.sleep(800);
window.location.reload();
return;
}
modal[outcome === 'failed' ? 'error' : 'warning']({
title: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedTitle' : 'pages.index.panelUpdateUnknownTitle'),
content: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedDesc' : 'pages.index.panelUpdateUnknownDesc'),
okText: t('refresh'),
onOk: () => window.location.reload(),
});
}, },
}); });
} }
+8 -4
View File
@@ -8,6 +8,7 @@ import { SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications'; import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface EmailTabProps { interface EmailTabProps {
allSetting: AllSetting; allSetting: AllSetting;
@@ -72,10 +73,13 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')} <SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}> description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
<Input.Password value={allSetting.smtpPassword} <SecretInput value={allSetting.smtpPassword}
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''} configured={allSetting.hasSmtpPassword}
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} /> clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}> <SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
+8 -4
View File
@@ -21,6 +21,7 @@ import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath'; import { sanitizePath } from './uriPath';
import SecretInput from './SecretInput';
interface ApiMsg<T = unknown> { interface ApiMsg<T = unknown> {
success?: boolean; success?: boolean;
@@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('password')} title={t('password')}
description={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')} description={allSetting.hasLdapPassword && !allSetting.clearLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
> >
<Input.Password <SecretInput
value={allSetting.ldapPassword} value={allSetting.ldapPassword}
placeholder={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordPlaceholder') : ''} configured={allSetting.hasLdapPassword}
onChange={(e) => updateSetting({ ldapPassword: e.target.value })} clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/> />
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}> <SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
@@ -0,0 +1,45 @@
import { Button, Input, Space } from 'antd';
import { useTranslation } from 'react-i18next';
interface SecretInputProps {
value: string;
configured: boolean;
clearArmed: boolean;
placeholder: string;
onChange: (value: string) => void;
onClearArmedChange: (armed: boolean) => void;
}
export default function SecretInput({
value,
configured,
clearArmed,
placeholder,
onChange,
onClearArmedChange,
}: SecretInputProps) {
const { t } = useTranslation();
return (
<Space.Compact style={{ width: '100%' }}>
<Input.Password
value={value}
placeholder={configured && !clearArmed ? placeholder : ''}
onChange={(e) => {
onChange(e.target.value);
if (clearArmed) onClearArmedChange(false);
}}
/>
{configured && (
<Button
danger={clearArmed}
onClick={() => {
onChange('');
onClearArmedChange(!clearArmed);
}}
>
{clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
</Button>
)}
</Space.Compact>
);
}
+1 -1
View File
@@ -204,7 +204,7 @@ export default function SettingsPage() {
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />; case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />; default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
} }
}, [activeSlug, allSetting, updateSetting]); }, [activeSlug, allSetting, updateSetting, savePayload]);
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
+8 -4
View File
@@ -9,6 +9,7 @@ import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications'; import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface TelegramTabProps { interface TelegramTabProps {
allSetting: AllSetting; allSetting: AllSetting;
@@ -193,12 +194,15 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.telegramToken')} title={t('pages.settings.telegramToken')}
description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')} description={allSetting.hasTgBotToken && !allSetting.clearTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
> >
<Input.Password <SecretInput
value={allSetting.tgBotToken} value={allSetting.tgBotToken}
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''} configured={allSetting.hasTgBotToken}
onChange={(e) => updateSetting({ tgBotToken: e.target.value })} clearArmed={allSetting.clearTgBotToken}
placeholder={t('pages.settings.telegramTokenPlaceholder')}
onChange={(v) => updateSetting({ tgBotToken: v })}
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
/> />
</SettingListItem> </SettingListItem>
@@ -367,7 +367,6 @@ export default function BalancersTab({
<ObservatorySettingsTab <ObservatorySettingsTab
templateSettings={templateSettings} templateSettings={templateSettings}
mutate={mutate} mutate={mutate}
isMobile={isMobile}
/> />
), ),
}, },
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd'; import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { import {
@@ -13,11 +13,11 @@ import {
type PingConfigObject, type PingConfigObject,
} from '@/schemas/observatory'; } from '@/schemas/observatory';
import type { XraySettingsValue } from '@/hooks/useXraySetting'; import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { settingsRequireBurstObservatory } from './balancer-helpers';
interface ObservatorySettingsTabProps { interface ObservatorySettingsTabProps {
templateSettings: XraySettingsValue | null; templateSettings: XraySettingsValue | null;
mutate: (mutator: (next: XraySettingsValue) => void) => void; mutate: (mutator: (next: XraySettingsValue) => void) => void;
isMobile: boolean;
} }
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({}); const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
@@ -43,7 +43,6 @@ function SelectorTags({ tags }: { tags: string[] }) {
export default function ObservatorySettingsTab({ export default function ObservatorySettingsTab({
templateSettings, templateSettings,
mutate, mutate,
isMobile,
}: ObservatorySettingsTabProps) { }: ObservatorySettingsTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,13 +62,10 @@ export default function ObservatorySettingsTab({
const hasObservatory = observatory != null; const hasObservatory = observatory != null;
const hasBurst = burst != null; const hasBurst = burst != null;
const hasMixedObservers = hasObservatory && hasBurst;
const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory'); const activeView = hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
const effectiveView = !hasObservatory && hasBurst
? 'burstObservatory' ? 'burstObservatory'
: !hasBurst && hasObservatory : 'observatory';
? 'observatory'
: view;
function patchObservatory(patch: Partial<ObservatoryObject>) { function patchObservatory(patch: Partial<ObservatoryObject>) {
mutate((tt) => { mutate((tt) => {
@@ -220,19 +216,15 @@ export default function ObservatorySettingsTab({
return ( return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}> <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} /> <Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
{hasObservatory && hasBurst && ( {hasMixedObservers && (
<Segmented <Alert
block={isMobile} type="warning"
value={effectiveView} showIcon
onChange={(v) => setView(v as 'observatory' | 'burstObservatory')} title={t('pages.xray.observatory.mixedLegacy')}
options={[
{ label: t('pages.xray.observatory.title'), value: 'observatory' },
{ label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
]}
/> />
)} )}
<div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div> <div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
</Space> </Space>
); );
} }
@@ -26,6 +26,16 @@ export function collectSelectors(list: BalancerObject[]): string[] {
return [...out]; return [...out];
} }
export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
const type = b.strategy?.type || 'random';
return type === 'leastLoad' || ((type === 'random' || type === 'roundRobin') && (b.fallbackTag ?? '').length > 0);
}
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
return balancers.some(balancerRequiresBurstObservatory);
}
// syncObservatories keeps the (burst)observatory sections aligned with the // syncObservatories keeps the (burst)observatory sections aligned with the
// balancer strategies that actually require them. Observatories have no runtime // balancer strategies that actually require them. Observatories have no runtime
// reload API in xray-core, so creating OR removing one forces a full process // reload API in xray-core, so creating OR removing one forces a full process
@@ -36,39 +46,35 @@ export function collectSelectors(list: BalancerObject[]): string[] {
// when its fallbackTag is set (issue #5605): with a fallbackTag the strategy // when its fallbackTag is set (issue #5605): with a fallbackTag the strategy
// calls RequireFeatures(Observatory) and the core aborts startup with "not all // calls RequireFeatures(Observatory) and the core aborts startup with "not all
// dependencies are resolved" if none exists; without a fallbackTag it never even // dependencies are resolved" if none exists; without a fallbackTag it never even
// consults an observatory. leastLoad always needs the burst observer, leastPing // consults an observatory. leastLoad needs the burst observer, while leastPing
// the regular one. // can use any extension.Observatory result with Alive/Delay. When a burst
// observer is required, keep all observer-backed balancers on burstObservatory
// to avoid xray-core resolving the earlier regular observatory feature instead.
// //
// So each observer lives exactly as long as something requires it, and is // So each observer lives exactly as long as something requires it, and is
// dropped the moment nothing does — clearing the last fallbackTag (or deleting // dropped the moment nothing does — clearing the last fallbackTag (or deleting
// the last leastLoad) removes the burst observer again. A no-fallback balancer's // the last leastLoad) removes the burst observer again. A no-fallback
// selector is still probed while the observer exists for another reason, but // Random/RoundRobin balancer never expands the observer either, because those
// never keeps it alive on its own. // strategies do not consume observer data.
export function syncObservatories(t: XraySettingsValue) { export function syncObservatories(t: XraySettingsValue) {
const balancers = (t.routing?.balancers || []) as BalancerObject[]; const balancers = (t.routing?.balancers || []) as BalancerObject[];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing'); const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
if (leastPings.length > 0) { const required = balancers.filter(balancerRequiresBurstObservatory);
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
(t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
} else {
delete t.observatory;
}
const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0;
const required = balancers.filter((b) => {
const type = b.strategy?.type || 'random';
if (type === 'leastLoad') return true;
return (type === 'random' || type === 'roundRobin') && hasFallback(b);
});
const optional = balancers.filter((b) => {
const type = b.strategy?.type || 'random';
return (type === 'random' || type === 'roundRobin') && !hasFallback(b);
});
if (required.length > 0) { if (required.length > 0) {
delete t.observatory;
if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY)); if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]); (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([
...required,
...leastPings,
]);
} else { } else {
delete t.burstObservatory; delete t.burstObservatory;
if (leastPings.length > 0) {
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
(t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
} else {
delete t.observatory;
}
} }
} }
@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`; exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`; exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`; exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`; exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
@@ -7,12 +7,208 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
return { routing, ...extra } as unknown as XraySettingsValue; return { routing, ...extra } as unknown as XraySettingsValue;
} }
type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, selectors: string[] = []) {
if (expected === 'none') {
expect(t.observatory).toBeUndefined();
expect(t.burstObservatory).toBeUndefined();
return;
}
if (expected === 'observatory') {
expect(t.observatory).toBeDefined();
expect(t.burstObservatory).toBeUndefined();
expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
return;
}
expect(t.observatory).toBeUndefined();
expect(t.burstObservatory).toBeDefined();
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
}
// Observatory sections have no reload API in xray-core, so creating one turns // Observatory sections have no reload API in xray-core, so creating one turns
// a balancer save from a live (hot-applied) routing change into a full // a balancer save from a live (hot-applied) routing change into a full
// restart. These tests pin the rule: only strategies that genuinely need an // restart. These tests pin the rule: only strategies that genuinely need an
// observer may create one — which, for random/roundRobin, means a fallbackTag // observer may create one — which, for random/roundRobin, means a fallbackTag
// is set (xray-core then requires the Observatory feature; see #5605). // is set (xray-core then requires the Observatory feature; see #5605).
describe('syncObservatories', () => { describe('syncObservatories', () => {
it.each([
{
name: 'random without fallback',
balancers: [{ tag: 'random', selector: ['random-out'] }],
expected: 'none' as const,
selectors: [],
},
{
name: 'random with fallback',
balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
expected: 'burstObservatory' as const,
selectors: ['random-out'],
},
{
name: 'roundRobin without fallback',
balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
expected: 'none' as const,
selectors: [],
},
{
name: 'roundRobin with fallback',
balancers: [{ tag: 'rr', selector: ['rr-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }],
expected: 'burstObservatory' as const,
selectors: ['rr-out'],
},
{
name: 'leastPing without fallback',
balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'leastPing with fallback',
balancers: [{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } }],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'leastLoad without fallback',
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
{
name: 'leastLoad with fallback',
balancers: [{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } }],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
const t = tpl({ balancers });
syncObservatories(t);
expectObserver(t, expected, selectors);
});
it.each([
{
name: 'random + roundRobin without fallbacks',
balancers: [
{ tag: 'random', selector: ['random-out'] },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'none' as const,
selectors: [],
},
{
name: 'leastPing + random without fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'random', selector: ['random-out'] },
],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'leastPing + roundRobin without fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'random fallback + random without fallback',
balancers: [
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
{ tag: 'random', selector: ['random-out'] },
],
expected: 'burstObservatory' as const,
selectors: ['random-fallback-out'],
},
{
name: 'roundRobin fallback + roundRobin without fallback',
balancers: [
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'burstObservatory' as const,
selectors: ['rr-fallback-out'],
},
{
name: 'leastLoad + random without fallback',
balancers: [
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
{ tag: 'random', selector: ['random-out'] },
],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
{
name: 'leastLoad + roundRobin without fallback',
balancers: [
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
{
name: 'leastPing + leastLoad with disjoint selectors',
balancers: [
{ tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
],
expected: 'burstObservatory' as const,
selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
},
{
name: 'leastPing + random fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
],
expected: 'burstObservatory' as const,
selectors: ['lp-out', 'random-fallback-out'],
},
{
name: 'leastPing + roundRobin fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
],
expected: 'burstObservatory' as const,
selectors: ['lp-out', 'rr-fallback-out'],
},
{
name: 'all strategies mixed',
balancers: [
{ tag: 'random', selector: ['random-out'] },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
],
expected: 'burstObservatory' as const,
selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
},
{
name: 'shared selectors are de-duplicated',
balancers: [
{ tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
{ tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
],
expected: 'burstObservatory' as const,
selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
},
])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
const t = tpl({ balancers });
syncObservatories(t);
expectObserver(t, expected, selectors);
});
it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => { it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] }); const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
syncObservatories(t); syncObservatories(t);
@@ -64,7 +260,7 @@ describe('syncObservatories', () => {
expect(t.burstObservatory).toBeUndefined(); expect(t.burstObservatory).toBeUndefined();
}); });
it('keeps burstObservatory while another fallback balancer still needs it', () => { it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
const t = tpl( const t = tpl(
{ {
balancers: [ balancers: [
@@ -76,7 +272,7 @@ describe('syncObservatories', () => {
); );
syncObservatories(t); syncObservatories(t);
expect(t.burstObservatory).toBeDefined(); expect(t.burstObservatory).toBeDefined();
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']); expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
}); });
it('creates burstObservatory for leastLoad (required by the strategy)', () => { it('creates burstObservatory for leastLoad (required by the strategy)', () => {
@@ -86,20 +282,55 @@ describe('syncObservatories', () => {
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']); expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
}); });
it('creates observatory for leastPing (required by the strategy)', () => { it('creates observatory for leastPing when no burst observer is required', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] }); const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
syncObservatories(t); syncObservatories(t);
expect(t.observatory).toBeDefined(); expect(t.observatory).toBeDefined();
expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']); expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
}); });
it('keeps an existing burstObservatory in sync for random balancers (legacy setups)', () => { it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
const t = tpl(
{
balancers: [
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
],
},
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
);
syncObservatories(t);
expect(t.observatory).toBeUndefined();
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(['least-load-out', 'least-ping-out']),
);
});
it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
const t = tpl(
{
balancers: [
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
{ tag: 'rr', selector: ['round-robin-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
],
},
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
);
syncObservatories(t);
expect(t.observatory).toBeUndefined();
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
);
});
it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
const t = tpl( const t = tpl(
{ balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] }, { balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
{ burstObservatory: { subjectSelector: ['stale'] } }, { burstObservatory: { subjectSelector: ['stale'] } },
); );
syncObservatories(t); syncObservatories(t);
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']); expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
}); });
it('removes observatories when no balancer can use them', () => { it('removes observatories when no balancer can use them', () => {
@@ -112,6 +343,30 @@ describe('syncObservatories', () => {
expect(t.burstObservatory).toBeUndefined(); expect(t.burstObservatory).toBeUndefined();
}); });
it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
const t = tpl(
{ balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
{
observatory: { subjectSelector: ['stale-observatory-out'] },
burstObservatory: { subjectSelector: ['stale-burst-out'] },
},
);
syncObservatories(t);
expectObserver(t, 'observatory', ['lp-out']);
});
it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
const t = tpl(
{ balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
{
observatory: { subjectSelector: ['stale-observatory-out'] },
burstObservatory: { subjectSelector: ['stale-burst-out'] },
},
);
syncObservatories(t);
expectObserver(t, 'burstObservatory', ['ll-out']);
});
it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => { it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] }); const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
syncObservatories(t); syncObservatories(t);
@@ -7,6 +7,7 @@ import {
type RawInboundRow, type RawInboundRow,
} from '@/lib/xray/inbound-form-adapter'; } from '@/lib/xray/inbound-form-adapter';
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form'; import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -304,3 +305,51 @@ describe('subSortIndex', () => {
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1); expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
}); });
}); });
describe('legacy xhttp session keys on edit (#5621)', () => {
const legacyXhttpRow: RawInboundRow = {
...vlessRow,
streamSettings: {
network: 'xhttp',
security: 'none',
xhttpSettings: {
path: '/xh',
mode: 'packet-up',
sessionPlacement: 'cookie',
sessionKey: 'x_session',
},
},
};
it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => {
const values = rawInboundToFormValues(legacyXhttpRow);
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
expect(xhttp.sessionIDPlacement).toBe('cookie');
expect(xhttp.sessionIDKey).toBe('x_session');
expect(xhttp.sessionPlacement).toBeUndefined();
expect(xhttp.sessionKey).toBeUndefined();
expect(xhttp.path).toBe('/xh');
expect(xhttp.xPaddingBytes).toBe('100-1000');
});
it('formValuesToWirePayload never emits the legacy key names', () => {
const values = rawInboundToFormValues(legacyXhttpRow);
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
expect(stream.xhttpSettings.sessionPlacement).toBeUndefined();
expect(stream.xhttpSettings.sessionKey).toBeUndefined();
expect(stream.xhttpSettings.sessionIDPlacement).toBe('cookie');
expect(stream.xhttpSettings.sessionIDKey).toBe('x_session');
});
it('normalizeXhttpForWire lifts stale legacy keys that bypassed the schema', () => {
const out = normalizeXhttpForWire(
{ sessionPlacement: 'header', sessionKey: 'x_raw' },
'inbound',
);
expect(out.sessionIDPlacement).toBe('header');
expect(out.sessionIDKey).toBe('x_raw');
expect(out.sessionPlacement).toBeUndefined();
expect(out.sessionKey).toBeUndefined();
});
});
@@ -104,6 +104,7 @@ describe('inbound security forms', () => {
scanRealityCandidates={async () => []} scanRealityCandidates={async () => []}
applyRealityScanResult={noop} applyRealityScanResult={noop}
randomizeShortIds={noop} randomizeShortIds={noop}
randomizeSpiderX={noop}
genRealityKeypair={noop} genRealityKeypair={noop}
clearRealityKeypair={noop} clearRealityKeypair={noop}
genMldsa65={noop} genMldsa65={noop}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import ObservatorySettingsTab from '@/pages/xray/balancers/ObservatorySettingsTab';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function renderTab(templateSettings: XraySettingsValue) {
renderWithProviders(
<ObservatorySettingsTab
templateSettings={templateSettings}
mutate={vi.fn()}
/>,
);
}
describe('ObservatorySettingsTab', () => {
it('shows one burst settings panel and warns for legacy mixed observers', () => {
renderTab({
routing: {
balancers: [{ tag: 'll', selector: ['proxy-a'], strategy: { type: 'leastLoad' } }],
},
observatory: { subjectSelector: ['stale-regular'] },
burstObservatory: { subjectSelector: ['proxy-a'] },
} as unknown as XraySettingsValue);
expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
expect(document.querySelector('.ant-segmented')).toBeFalsy();
expect(screen.getByText('Probe Destination')).toBeTruthy();
expect(screen.queryByText('Probe URL')).toBeFalsy();
});
it('shows regular observatory settings for mixed legacy configs that normalize back to leastPing only', () => {
renderTab({
routing: {
balancers: [{ tag: 'lp', selector: ['proxy-b'], strategy: { type: 'leastPing' } }],
},
observatory: { subjectSelector: ['proxy-b'] },
burstObservatory: { subjectSelector: ['stale-burst'] },
} as unknown as XraySettingsValue);
expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
expect(document.querySelector('.ant-segmented')).toBeFalsy();
expect(screen.getByText('Probe URL')).toBeTruthy();
expect(screen.queryByText('Probe Destination')).toBeFalsy();
});
});
@@ -83,6 +83,65 @@ describe('outbound deletion', () => {
expect(tt.routing!.rules![0].balancerTag).toBeUndefined(); expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
}); });
it('cascade-removes the burst observer when deleting an outbound removes the last leastLoad balancer', () => {
const tt = tpl({
outbounds: [{ tag: 'll-out' }],
routing: {
rules: [],
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
},
burstObservatory: { subjectSelector: ['ll-out'] },
});
const impact = planOutboundDeletion(tt, 0);
expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
expect(impact.burst).toBe(true);
applyOutboundDeletion(tt, 0);
expect(tt.burstObservatory).toBeUndefined();
expect(tt.routing!.balancers).toEqual([]);
});
it('cascade-switches from burst to regular observer when only leastPing remains', () => {
const tt = tpl({
outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
routing: {
rules: [],
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
],
},
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
});
const impact = planOutboundDeletion(tt, 1);
expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
expect(impact.burst).toBe(true);
applyOutboundDeletion(tt, 1);
expect(tt.burstObservatory).toBeUndefined();
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
});
it('cascade-keeps burst observer when leastPing is removed but leastLoad remains', () => {
const tt = tpl({
outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
routing: {
rules: [],
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
],
},
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
});
const impact = planOutboundDeletion(tt, 0);
expect(impact.balancers).toEqual([{ tag: 'lp', reason: 'selectorEmptied' }]);
expect(impact.burst).toBe(false);
applyOutboundDeletion(tt, 0);
expect(tt.observatory).toBeUndefined();
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
});
it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => { it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
const tt = tpl({ const tt = tpl({
outbounds: [ outbounds: [
@@ -253,6 +312,68 @@ describe('balancer deletion', () => {
expect(tt.routing!.balancers).toEqual([]); expect(tt.routing!.balancers).toEqual([]);
}); });
it('reports and removes the burst observer when deleting the last leastLoad balancer', () => {
const tt = tpl({
routing: { rules: [], balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }] },
burstObservatory: { subjectSelector: ['a'] },
});
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
applyBalancerDeletion(tt, 0);
expect(tt.burstObservatory).toBeUndefined();
expect(tt.routing!.balancers).toEqual([]);
});
it('reports and removes the burst observer when deleting the last fallback balancer', () => {
const tt = tpl({
routing: { rules: [], balancers: [{ tag: 'rf', selector: ['a'], fallbackTag: 'direct' }] },
burstObservatory: { subjectSelector: ['a'] },
});
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
applyBalancerDeletion(tt, 0);
expect(tt.burstObservatory).toBeUndefined();
expect(tt.routing!.balancers).toEqual([]);
});
it('switches from burst to regular observer when the deleted balancer was the last burst-required one', () => {
const tt = tpl({
routing: {
rules: [],
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
],
},
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
});
const impact = planBalancerDeletion(tt, 1);
expect(impact.burst).toBe(true);
expect(impact.observatory).toBe(false);
applyBalancerDeletion(tt, 1);
expect(tt.burstObservatory).toBeUndefined();
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
});
it('keeps burst observer when deleting leastPing but a burst-required balancer remains', () => {
const tt = tpl({
routing: {
rules: [],
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
],
},
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
});
const impact = planBalancerDeletion(tt, 0);
expect(impact.burst).toBe(false);
expect(impact.observatory).toBe(false);
applyBalancerDeletion(tt, 0);
expect(tt.observatory).toBeUndefined();
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
});
it('does not report rules when the deleted balancer is unreferenced', () => { it('does not report rules when the deleted balancer is unreferenced', () => {
const tt = tpl({ const tt = tpl({
routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] }, routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { deriveSpiderX } from '@/lib/xray/spider-x';
// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors
// in internal/sub/service_sharelink_test.go: subscription links come from Go,
// panel links from this module, and the two must agree byte-for-byte.
describe('deriveSpiderX', () => {
it('matches the Go deriveSpiderX vectors', () => {
expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
});
it('is stable per client, distinct across clients, and rotates with the seed', () => {
expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice'));
expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob'));
expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice'));
});
it('returns empty when there is nothing to derive from', () => {
expect(deriveSpiderX('', '')).toBe('');
});
it('emits a /-prefixed 15-hex-char path', () => {
expect(deriveSpiderX('/some-seed', 'client@example.com')).toMatch(/^\/[0-9a-f]{15}$/);
});
});
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
const x25519Key = 'kO9pIKKPtoUCzo3ZWfWfp0lQoWCyJC1TqL8oz1hpsFM';
const mlkem768Key = 'A'.repeat(1590);
describe('vlessEncryptionAuthKind', () => {
const cases: { name: string; encryption: string; want: ReturnType<typeof vlessEncryptionAuthKind> }[] = [
{ name: 'empty string', encryption: '', want: null },
{ name: 'none', encryption: 'none', want: null },
{ name: 'only dots', encryption: '...', want: null },
{ name: 'x25519 native', encryption: `mlkem768x25519plus.native.600s.${x25519Key}`, want: 'x25519' },
{ name: 'x25519 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${x25519Key}`, want: 'x25519_xorpub' },
{ name: 'x25519 random', encryption: `mlkem768x25519plus.random.600s.${x25519Key}`, want: 'x25519_random' },
{ name: 'mlkem768 native', encryption: `mlkem768x25519plus.native.600s.${mlkem768Key}`, want: 'mlkem768' },
{ name: 'mlkem768 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${mlkem768Key}`, want: 'mlkem768_xorpub' },
{ name: 'mlkem768 random', encryption: `mlkem768x25519plus.random.600s.${mlkem768Key}`, want: 'mlkem768_random' },
{ name: 'two-segment value treated as native', encryption: `mlkem768x25519plus.${x25519Key}`, want: 'x25519' },
];
for (const c of cases) {
it(c.name, () => {
expect(vlessEncryptionAuthKind(c.encryption)).toBe(c.want);
});
}
});
+3 -3
View File
@@ -17,7 +17,7 @@ require (
github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.5 github.com/shirou/gopsutil/v4 v4.26.6
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.72.0 github.com/valyala/fasthttp v1.72.0
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
@@ -26,7 +26,7 @@ require (
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
golang.org/x/text v0.38.0 golang.org/x/text v0.38.0
google.golang.org/grpc v1.81.1 google.golang.org/grpc v1.82.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@@ -63,7 +63,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/ratelimit v1.0.2 // indirect github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.18.6 github.com/klauspost/compress v1.19.0
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
+6 -6
View File
@@ -115,8 +115,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -178,8 +178,8 @@ github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM= github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8= github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -281,8 +281,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+96 -18
View File
@@ -373,7 +373,7 @@ setup_ssl_certificate() {
fi fi
# Install certificate # Install certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \ ~/.acme.sh/acme.sh --installcert --force -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \ --key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem \ --fullchain-file /root/cert/${domain}/fullchain.pem \
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1 --reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
@@ -517,7 +517,7 @@ setup_ip_certificate() {
# Install certificate # Install certificate
# Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails, # Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
# but the cert files are still installed. We check for files instead of exit code. # but the cert files are still installed. We check for files instead of exit code.
~/.acme.sh/acme.sh --installcert -d ${ipv4} \ ~/.acme.sh/acme.sh --installcert --force -d ${ipv4} \
--key-file "${certDir}/privkey.pem" \ --key-file "${certDir}/privkey.pem" \
--fullchain-file "${certDir}/fullchain.pem" \ --fullchain-file "${certDir}/fullchain.pem" \
--reloadcmd "${reloadCmd}" 2>&1 || true --reloadcmd "${reloadCmd}" 2>&1 || true
@@ -705,7 +705,7 @@ ssl_cert_issue() {
# install the certificate # install the certificate
local installOutput="" local installOutput=""
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \ installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \ --key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1) --fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$? local installRc=$?
@@ -1326,6 +1326,40 @@ setup_fail2ban() {
return 0 return 0
} }
# 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
# truncated unit file at the live path -- systemd would then fail to parse
# it on the next daemon-reload/start. Same pattern already used for
# /usr/bin/x-ui elsewhere in this script. source_is_url picks cp (from a
# file already extracted from the release tarball) vs curl (GitHub fallback).
_install_xui_service_unit() {
local source="$1"
local source_is_url="$2"
local dest="${xui_service}/x-ui.service"
local temp_file="${dest}.tmp.$$"
rm -f "$temp_file"
if [[ "$source_is_url" == "true" ]]; then
curl -fLRo "$temp_file" "$source" > /dev/null 2>&1
else
cp -f "$source" "$temp_file" > /dev/null 2>&1
fi
if [[ $? -ne 0 ]]; then
rm -f "$temp_file"
return 1
fi
if [[ ! -s "$temp_file" ]]; then
rm -f "$temp_file"
return 1
fi
mv -f "$temp_file" "$dest"
if [[ $? -ne 0 ]]; then
rm -f "$temp_file"
return 1
fi
return 0
}
install_x-ui() { install_x-ui() {
cd ${xui_folder%/x-ui}/ cd ${xui_folder%/x-ui}/
@@ -1342,6 +1376,11 @@ install_x-ui() {
echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}" echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
exit 1 exit 1
fi fi
if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
rm ${xui_folder}-linux-$(arch).tar.gz -f
echo -e "${red}Downloaded x-ui release archive is empty${plain}"
exit 1
fi
else else
tag_version=$1 tag_version=$1
# The rolling dev channel ships under a fixed, non-semver tag that is # The rolling dev channel ships under a fixed, non-semver tag that is
@@ -1367,12 +1406,25 @@ install_x-ui() {
echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}" echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
exit 1 exit 1
fi fi
if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
rm ${xui_folder}-linux-$(arch).tar.gz -f
echo -e "${red}Downloaded x-ui release archive is empty${plain}"
exit 1
fi
fi fi
curl -fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh local xui_script_temp="/usr/bin/x-ui-temp.$$"
rm -f "${xui_script_temp}"
curl -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then
rm -f "${xui_script_temp}"
echo -e "${red}Failed to download x-ui.sh${plain}" echo -e "${red}Failed to download x-ui.sh${plain}"
exit 1 exit 1
fi fi
if [[ ! -s "${xui_script_temp}" ]]; then
rm -f "${xui_script_temp}"
echo -e "${red}Downloaded x-ui.sh is empty${plain}"
exit 1
fi
# Stop x-ui service and remove old resources # Stop x-ui service and remove old resources
if [[ -e ${xui_folder}/ ]]; then if [[ -e ${xui_folder}/ ]]; then
@@ -1391,9 +1443,20 @@ install_x-ui() {
# Extract resources and set permissions # Extract resources and set permissions
tar zxvf x-ui-linux-$(arch).tar.gz tar zxvf x-ui-linux-$(arch).tar.gz
if [[ $? -ne 0 ]]; then
rm x-ui-linux-$(arch).tar.gz -f
rm -f "${xui_script_temp}"
echo -e "${red}Failed to extract the x-ui release archive -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}"
exit 1
fi
rm x-ui-linux-$(arch).tar.gz -f rm x-ui-linux-$(arch).tar.gz -f
cd x-ui cd x-ui
if [[ $? -ne 0 || ! -s x-ui ]]; then
rm -f "${xui_script_temp}"
echo -e "${red}Extracted x-ui archive is missing the x-ui binary -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}"
exit 1
fi
chmod +x x-ui chmod +x x-ui
chmod +x x-ui.sh chmod +x x-ui.sh
@@ -1414,7 +1477,12 @@ install_x-ui() {
fi fi
# Update x-ui cli and se set permission # Update x-ui cli and se set permission
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui mv -f "${xui_script_temp}" /usr/bin/x-ui
if [[ $? -ne 0 ]]; then
rm -f "${xui_script_temp}"
echo -e "${red}Failed to install x-ui.sh${plain}"
exit 1
fi
chmod +x /usr/bin/x-ui chmod +x /usr/bin/x-ui
mkdir -p /var/log/x-ui mkdir -p /var/log/x-ui
config_after_install config_after_install
@@ -1434,11 +1502,25 @@ install_x-ui() {
fi fi
if [[ $release == "alpine" ]]; then if [[ $release == "alpine" ]]; then
curl -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
rm -f "${xui_rc_temp}"
curl -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then
rm -f "${xui_rc_temp}"
echo -e "${red}Failed to download x-ui.rc${plain}" echo -e "${red}Failed to download x-ui.rc${plain}"
exit 1 exit 1
fi fi
if [[ ! -s "${xui_rc_temp}" ]]; then
rm -f "${xui_rc_temp}"
echo -e "${red}Downloaded x-ui.rc is empty${plain}"
exit 1
fi
mv -f "${xui_rc_temp}" /etc/init.d/x-ui
if [[ $? -ne 0 ]]; then
rm -f "${xui_rc_temp}"
echo -e "${red}Failed to install x-ui.rc${plain}"
exit 1
fi
chmod +x /etc/init.d/x-ui chmod +x /etc/init.d/x-ui
rc-update add x-ui rc-update add x-ui
rc-service x-ui start rc-service x-ui start
@@ -1448,8 +1530,7 @@ install_x-ui() {
if [ -f "x-ui.service" ]; then if [ -f "x-ui.service" ]; then
echo -e "${green}Found x-ui.service in extracted files, installing...${plain}" echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1 if _install_xui_service_unit "x-ui.service" "false"; then
if [[ $? -eq 0 ]]; then
service_installed=true service_installed=true
fi fi
fi fi
@@ -1459,8 +1540,7 @@ install_x-ui() {
ubuntu | debian | armbian) ubuntu | debian | armbian)
if [ -f "x-ui.service.debian" ]; then if [ -f "x-ui.service.debian" ]; then
echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}" echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}"
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1 if _install_xui_service_unit "x-ui.service.debian" "false"; then
if [[ $? -eq 0 ]]; then
service_installed=true service_installed=true
fi fi
fi fi
@@ -1468,8 +1548,7 @@ install_x-ui() {
arch | manjaro | parch) arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then if [ -f "x-ui.service.arch" ]; then
echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}" echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1 if _install_xui_service_unit "x-ui.service.arch" "false"; then
if [[ $? -eq 0 ]]; then
service_installed=true service_installed=true
fi fi
fi fi
@@ -1477,8 +1556,7 @@ install_x-ui() {
*) *)
if [ -f "x-ui.service.rhel" ]; then if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}" echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1 if _install_xui_service_unit "x-ui.service.rhel" "false"; then
if [[ $? -eq 0 ]]; then
service_installed=true service_installed=true
fi fi
fi fi
@@ -1491,17 +1569,17 @@ install_x-ui() {
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}" echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
case "${release}" in case "${release}" in
ubuntu | debian | armbian) ubuntu | debian | armbian)
curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1 service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian"
;; ;;
arch | manjaro | parch) arch | manjaro | parch)
curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1 service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch"
;; ;;
*) *)
curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1 service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel"
;; ;;
esac esac
if [[ $? -ne 0 ]]; then if ! _install_xui_service_unit "$service_unit_url" "true"; then
echo -e "${red}Failed to install x-ui.service from GitHub${plain}" echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
exit 1 exit 1
fi fi
+8
View File
@@ -169,6 +169,14 @@ func GetDBPath() string {
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName()) return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
} }
// GetUpdateStatusFilePath returns the path to the panel self-update status
// file update.sh writes on completion. It lives beside the database, outside
// XUI_MAIN_FOLDER, so it survives an update regardless of what happens to
// that folder.
func GetUpdateStatusFilePath() string {
return filepath.Join(GetDBFolderPath(), "update-status.json")
}
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres". // GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
func GetDBKind() string { func GetDBKind() string {
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE"))) v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
+62 -1
View File
@@ -83,6 +83,9 @@ func initModels() error {
&model.OutboundSubscription{}, &model.OutboundSubscription{},
} }
for _, mdl := range models { for _, mdl := range models {
if IsPostgres() && postgresModelSettled(mdl) {
continue
}
if err := db.AutoMigrate(mdl); err != nil { if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(err, mdl) { if isIgnorableDuplicateColumnErr(err, mdl) {
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err) log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
@@ -119,6 +122,30 @@ func initModels() error {
return nil return nil
} }
// postgresModelSettled skips AutoMigrate when table, columns, and indexes all exist:
// its catalog-filtered column probe misdetects on some setups and re-ADDs columns forever (#5665).
func postgresModelSettled(mdl any) bool {
migrator := db.Migrator()
if !migrator.HasTable(mdl) {
return false
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(mdl); err != nil || stmt.Schema == nil {
return false
}
for _, dbName := range stmt.Schema.DBNames {
if !migrator.HasColumn(mdl, dbName) {
return false
}
}
for _, idx := range stmt.Schema.ParseIndexes() {
if !migrator.HasIndex(mdl, idx.Name) {
return false
}
}
return true
}
func dropLegacyForeignKeys() error { func dropLegacyForeignKeys() error {
if !IsPostgres() { if !IsPostgres() {
return nil return nil
@@ -637,7 +664,10 @@ func runSeeders(isUsersEmpty bool) error {
if err := seedWireguardPeersToClients(); err != nil { if err := seedWireguardPeersToClients(); err != nil {
return err return err
} }
return nil
// Idempotent, not seeder-gated: bad values can re-enter via a restored
// backup, so re-check on every start.
return normalizeSettingPaths()
} }
// resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where // resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where
@@ -742,6 +772,37 @@ func clearLegacyProxySettings() error {
}) })
} }
// normalizeSettingPaths repairs URI-path settings persisted before the
// leading/trailing-slash rules existed (or restored from an old backup),
// mirroring entity.AllSetting.CheckValid. CheckValid self-heals these on save,
// but the frontend rejects the whole Settings form on the bad stored value
// before a save can ever reach it (#5726), so the stored rows themselves must
// be fixed. Idempotent; runs on every start.
func normalizeSettingPaths() error {
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
var rows []model.Setting
if err := db.Where("key IN ?", pathKeys).Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
fixed := row.Value
if !strings.HasPrefix(fixed, "/") {
fixed = "/" + fixed
}
if !strings.HasSuffix(fixed, "/") {
fixed += "/"
}
if fixed == row.Value {
continue
}
if err := db.Model(&model.Setting{}).Where("id = ?", row.Id).
Update("value", fixed).Error; err != nil {
return err
}
}
return nil
}
func normalizeInboundClientTgId() error { func normalizeInboundClientTgId() error {
var inbounds []model.Inbound var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil { if err := db.Find(&inbounds).Error; err != nil {
+41
View File
@@ -153,3 +153,44 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount) t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
} }
} }
func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
seed := []model.Setting{
{Key: "subJsonPath", Value: "YIrCXJOOOL"},
{Key: "subPath", Value: "/sub"},
{Key: "subClashPath", Value: "clash/"},
{Key: "webBasePath", Value: "/panel/"},
}
for i := range seed {
if err := db.Create(&seed[i]).Error; err != nil {
t.Fatalf("seed setting %s: %v", seed[i].Key, err)
}
}
if err := normalizeSettingPaths(); err != nil {
t.Fatalf("normalizeSettingPaths: %v", err)
}
want := map[string]string{
"subJsonPath": "/YIrCXJOOOL/",
"subPath": "/sub/",
"subClashPath": "/clash/",
"webBasePath": "/panel/",
}
for key, expected := range want {
var row model.Setting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
t.Fatalf("read %s: %v", key, err)
}
if row.Value != expected {
t.Errorf("%s = %q, want %q", key, row.Value, expected)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package database
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Locks the #5665 guard: composite-PK client_inbounds has no id column, so the
// sequence-reset SQL must never be issued for it.
func TestTableWithIdColumn_SkipsCompositeKeyModels(t *testing.T) {
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if table, ok := tableWithIdColumn(db, &model.ClientInbound{}); ok {
t.Errorf("ClientInbound (table %q) has no id column but was not skipped", table)
}
table, ok := tableWithIdColumn(db, &model.Inbound{})
if !ok {
t.Fatal("Inbound has an id column but was reported as skippable")
}
if table != "inbounds" {
t.Errorf("Inbound table = %q, want inbounds", table)
}
}
// Exercises the #5665 AutoMigrate skip on SQLite (the check is dialect-agnostic):
// settled after InitDB, not settled with a missing column or table.
func TestPostgresModelSettled_TracksSchemaPresence(t *testing.T) {
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
for _, mdl := range []any{&model.ClientRecord{}, &model.ClientGroup{}, &model.ClientInbound{}} {
if !postgresModelSettled(mdl) {
t.Errorf("%T not settled right after InitDB", mdl)
}
}
if err := db.Migrator().DropColumn(&model.ClientGroup{}, "reset_up"); err != nil {
t.Fatalf("drop column: %v", err)
}
if postgresModelSettled(&model.ClientGroup{}) {
t.Error("ClientGroup settled despite missing reset_up column")
}
if err := db.Migrator().DropTable(&model.ClientGroup{}); err != nil {
t.Fatalf("drop table: %v", err)
}
if postgresModelSettled(&model.ClientGroup{}) {
t.Error("ClientGroup settled despite missing table")
}
}
+17 -9
View File
@@ -270,19 +270,14 @@ func resetPostgresSequences(dst *gorm.DB) error {
return resyncPostgresSequences(dst, migrationModels()) return resyncPostgresSequences(dst, migrationModels())
} }
// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next // resyncPostgresSequences sets each model's id sequence to MAX(id); idempotent. Id-less
// auto-increment INSERT won't collide with an existing row. Table names are // composite-PK tables are skipped — Postgres rejects MAX(id) at parse time and logs it (#5665).
// resolved from the models themselves (not hardcoded), so they always match the
// migrated tables. The statement is a no-op for tables without an id sequence
// (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
// run both after migration and on every Postgres startup.
func resyncPostgresSequences(db *gorm.DB, models []any) error { func resyncPostgresSequences(db *gorm.DB, models []any) error {
for _, m := range models { for _, m := range models {
stmt := &gorm.Statement{DB: db} t, ok := tableWithIdColumn(db, m)
if err := stmt.Parse(m); err != nil { if !ok {
continue continue
} }
t := stmt.Table
// t comes from the trusted model set parsed by GORM, not user input, so // t comes from the trusted model set parsed by GORM, not user input, so
// interpolating it as an identifier is safe. We ignore errors per-table. // interpolating it as an identifier is safe. We ignore errors per-table.
_ = db.Exec( _ = db.Exec(
@@ -293,3 +288,16 @@ func resyncPostgresSequences(db *gorm.DB, models []any) error {
} }
return nil return nil
} }
// tableWithIdColumn resolves a model's table name and reports whether its GORM
// schema maps an "id" database column.
func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", false
}
if stmt.Schema == nil || stmt.Schema.LookUpField("id") == nil {
return "", false
}
return stmt.Table, true
}
+3 -6
View File
@@ -240,8 +240,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
case model.VLESS: case model.VLESS:
proxy["type"] = "vless" proxy["type"] = "vless"
proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep)) proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
var inboundSettings map[string]any inboundSettings := subReq.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
streamSecurity, _ := stream["security"].(string) streamSecurity, _ := stream["security"].(string)
if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) { if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
proxy["flow"] = client.Flow proxy["flow"] = client.Flow
@@ -258,8 +257,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
case model.Shadowsocks: case model.Shadowsocks:
proxy["type"] = "ss" proxy["type"] = "ss"
proxy["password"] = client.Password proxy["password"] = client.Password
var inboundSettings map[string]any inboundSettings := subReq.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
method, _ := inboundSettings["method"].(string) method, _ := inboundSettings["method"].(string)
if method == "" { if method == "" {
return nil return nil
@@ -288,8 +286,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
// helpers prune fields (like `allowInsecure` / the salamander obfs // helpers prune fields (like `allowInsecure` / the salamander obfs
// block) that the hysteria proxy wants preserved. // block) that the hysteria proxy wants preserved.
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any { func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
var inboundSettings map[string]any inboundSettings := subReq.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
proxyType := "hysteria2" proxyType := "hysteria2"
authKey := "password" authKey := "password"
+26 -18
View File
@@ -138,7 +138,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage { func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
var newJsonArray []json_util.RawMessage var newJsonArray []json_util.RawMessage
stream := s.streamData(inbound.StreamSettings) stream := s.streamData(inbound.StreamSettings, subKey(client))
// When externalProxy is empty the JSON config falls back to a // When externalProxy is empty the JSON config falls back to a
// synthetic one whose `dest` is the host the client connects to. // synthetic one whose `dest` is the host the client connects to.
@@ -212,11 +212,11 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
case "vless": case "vless":
vc := client vc := client
vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy)) vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, vc, jsonMux(mux, hostMux))) newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
case "trojan", "shadowsocks": case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux))) newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
case "hysteria": case "hysteria":
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux))) newOutbounds = append(newOutbounds, s.genHy(subReq, inbound, newStream, client, jsonMux(mux, hostMux)))
} }
newOutbounds = append(newOutbounds, s.defaultOutbounds...) newOutbounds = append(newOutbounds, s.defaultOutbounds...)
@@ -234,15 +234,25 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
return newJsonArray return newJsonArray
} }
func (s *SubJsonService) streamData(stream string) map[string]any { func (s *SubJsonService) streamData(stream string, clientKey string) map[string]any {
var streamSettings map[string]any var streamSettings map[string]any
_ = json.Unmarshal([]byte(stream), &streamSettings) if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
streamSettings = map[string]any{}
}
security, _ := streamSettings["security"].(string) security, _ := streamSettings["security"].(string)
switch security { switch security {
case "tls": case "tls":
streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any)) if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
} else {
delete(streamSettings, "tlsSettings")
}
case "reality": case "reality":
streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any)) if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
streamSettings["realitySettings"] = s.realityData(realitySettings, clientKey)
} else {
delete(streamSettings, "realitySettings")
}
} }
delete(streamSettings, "sockopt") delete(streamSettings, "sockopt")
@@ -322,7 +332,7 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
return tlsData return tlsData
} }
func (s *SubJsonService) realityData(rData map[string]any) map[string]any { func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {
rltyData := make(map[string]any, 1) rltyData := make(map[string]any, 1)
rltyClientSettings, _ := rData["settings"].(map[string]any) rltyClientSettings, _ := rData["settings"].(map[string]any)
@@ -331,8 +341,8 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
rltyData["fingerprint"] = rltyClientSettings["fingerprint"] rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"] rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
// Set random data seed, _ := rltyClientSettings["spiderX"].(string)
rltyData["spiderX"] = "/" + random.Seq(15) rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
shortIds, ok := rData["shortIds"].([]any) shortIds, ok := rData["shortIds"].([]any)
if ok && len(shortIds) > 0 { if ok && len(shortIds) > 0 {
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string) rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
@@ -383,7 +393,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
return result return result
} }
func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage { func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{} outbound := Outbound{}
outbound.Protocol = string(inbound.Protocol) outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy" outbound.Tag = "proxy"
@@ -393,8 +403,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
// Add encryption for VLESS outbound from inbound settings // Add encryption for VLESS outbound from inbound settings
var inboundSettings map[string]any inboundSettings := subReq.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
encryption, _ := inboundSettings["encryption"].(string) encryption, _ := inboundSettings["encryption"].(string)
settings := map[string]any{ settings := map[string]any{
@@ -412,7 +421,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
return result return result
} }
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage { func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{} outbound := Outbound{}
serverData := make([]ServerSetting, 1) serverData := make([]ServerSetting, 1)
@@ -424,8 +433,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
} }
if inbound.Protocol == model.Shadowsocks { if inbound.Protocol == model.Shadowsocks {
var inboundSettings map[string]any inboundSettings := subReq.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
method, _ := inboundSettings["method"].(string) method, _ := inboundSettings["method"].(string)
serverData[0].Method = method serverData[0].Method = method
@@ -465,7 +473,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
return result return result
} }
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage { func (s *SubJsonService) genHy(subReq *SubService, inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{} outbound := Outbound{}
outbound.Protocol = string(inbound.Protocol) outbound.Protocol = string(inbound.Protocol)
+88 -11
View File
@@ -41,7 +41,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
t.Fatal("direct_out outbound must never be emitted") t.Fatal("direct_out outbound must never be emitted")
} }
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`) stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["sockopt"]; ok { if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set") t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
} }
@@ -77,7 +77,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
stream := svc.streamData(`{ stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}, "network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]} "finalmask":{"tcp":[{"type":"sudoku"}]}
}`) }`, "")
finalmask, _ := stream["finalmask"].(map[string]any) finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any) tcp, _ := finalmask["tcp"].([]any)
@@ -93,7 +93,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) { func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`) stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok { if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty") t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
} }
@@ -107,7 +107,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
// to import the config (#5401). // to import the config (#5401).
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) { func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`) stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any) tls, _ := stream["tlsSettings"].(map[string]any)
if tls == nil { if tls == nil {
@@ -122,7 +122,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"} client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client, "")) settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok { if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext") t.Fatal("vless outbound must not use vnext")
} }
@@ -151,7 +151,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`} trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"} client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, "")) settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
server := firstServer(settings) server := firstServer(settings)
if server == nil { if server == nil {
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings) t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
@@ -164,7 +164,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
} }
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`} ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, "")) ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssServer := firstServer(ssSettings) ssServer := firstServer(ssSettings)
if ssServer == nil { if ssServer == nil {
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings) t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
@@ -181,7 +181,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
// When xmux is present in xhttpSettings, the per-inbound xmux handles // When xmux is present in xhttpSettings, the per-inbound xmux handles
// multiplexing and the legacy outbound.Mux must NOT be set. // multiplexing and the legacy outbound.Mux must NOT be set.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}` stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
parsed := svc.streamData(stream) parsed := svc.streamData(stream, "")
mux := globalMux mux := globalMux
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok { if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
@@ -194,7 +194,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1"} client := model.Client{ID: "uuid-1"}
raw := svc.genVless(inbound, streamSettings, client, mux) raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
var ob map[string]any var ob map[string]any
if err := json.Unmarshal(raw, &ob); err != nil { if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err) t.Fatalf("unmarshal outbound: %v", err)
@@ -227,7 +227,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
// When no xmux is present, the global subJsonMux should be used. // When no xmux is present, the global subJsonMux should be used.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}` stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
parsed := svc.streamData(stream) parsed := svc.streamData(stream, "")
mux := globalMux mux := globalMux
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok { if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
@@ -240,7 +240,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1"} client := model.Client{ID: "uuid-1"}
raw := svc.genVless(inbound, streamSettings, client, mux) raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
var ob map[string]any var ob map[string]any
if err := json.Unmarshal(raw, &ob); err != nil { if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err) t.Fatalf("unmarshal outbound: %v", err)
@@ -254,3 +254,80 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
t.Fatalf("mux payload wrong: %#v", m) t.Fatalf("mux payload wrong: %#v", m)
} }
} }
func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
t.Helper()
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`, clientKey)
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
}
return spx
}
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
alice := realitySpiderXFromStream(t, svc, "subAlice")
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
}
if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
}
}
// streamData must tolerate malformed stored inbounds: unparseable stream JSON
// (with a finalMask configured, which writes into the map) and tls/reality
// security whose settings key is missing or null previously panicked the
// subscription request.
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
stream := withMask.streamData("not-json", "clientKey")
if _, ok := stream["finalmask"]; !ok {
t.Fatal("finalMask must still apply when stream settings fail to parse")
}
svc := NewSubJsonService("", "", "", nil)
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
if v, ok := noReality["realitySettings"]; ok {
t.Fatalf("missing realitySettings must stay absent, got %v", v)
}
nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
if v, ok := nullTls["tlsSettings"]; ok {
t.Fatalf("null tlsSettings must be dropped, got %v", v)
}
}
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`, "")
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
}
}
+9 -9
View File
@@ -67,11 +67,11 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
protocol model.Protocol protocol model.Protocol
}{ }{
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS}, {"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS}, {"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan}, {"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS}, {"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS}, {"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan}, {"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -95,15 +95,15 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
} }
} }
// --- json_service.go:268 — a non-empty finalMask that merges to nothing must // --- applyGlobalFinalMask — a non-empty finalMask that merges to nothing must
// not add the finalmask key (the `len(merged) > 0` guard). --- // not add the finalmask key (the `len(merged) > 0` guard). ---
func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) { func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// finalMask is non-empty (passes the len(fm)==0 early return) but its only // finalMask is non-empty (passes the len(fm)==0 early return) but its only
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty, // key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask. // so applyGlobalFinalMask must NOT set finalmask.
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil) svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`) stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok { if _, ok := stream["finalmask"]; ok {
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"]) t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
} }
@@ -111,13 +111,13 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// Sanity: a finalMask that DOES merge to something still gets set, so the // Sanity: a finalMask that DOES merge to something still gets set, so the
// guard is the only distinguishing factor. // guard is the only distinguishing factor.
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil) svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`) stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream2["finalmask"]; !ok { if _, ok := stream2["finalmask"]; !ok {
t.Fatal("non-empty finalMask must be set") t.Fatal("non-empty finalMask must be set")
} }
} }
// --- json_service.go:494 — an empty extra tcp slice must not clobber the base --- // --- mergeFinalMask — an empty extra tcp slice must not clobber the base ---
func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) { func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}} base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}
+2 -5
View File
@@ -459,11 +459,8 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
// needed when a global remark template references client-only tokens. Falls back // needed when a global remark template references client-only tokens. Falls back
// to an email-only client if not found. // to an email-only client if not found.
func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client { func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
clients, _ := s.inboundService.GetClients(inbound) if c, ok := s.clientForLink(inbound, email); ok {
for _, c := range clients { return c
if c.Email == email {
return c
}
} }
return model.Client{Email: email} return model.Client{Email: email}
} }
+196 -67
View File
@@ -54,6 +54,17 @@ type SubService struct {
// doesn't own its row (multi-inbound subscriptions). Filled in // doesn't own its row (multi-inbound subscriptions). Filled in
// getInboundsBySubId; reset per request in PrepareForRequest. // getInboundsBySubId; reset per request in PrepareForRequest.
statsByEmail map[string]xray.ClientTraffic statsByEmail map[string]xray.ClientTraffic
// clientsByInbound caches clients resolved for this request keyed by
// inbound id then email, so the per-protocol link generators look a client
// up without re-parsing the inbound's settings JSON per link.
// fullyPrimedInbounds marks inbounds whose complete client list is cached
// (a miss there is authoritative). Reset per request in PrepareForRequest.
clientsByInbound map[int]map[string]model.Client
fullyPrimedInbounds map[int]bool
// settingsByInbound caches each inbound's settings decoded once per request
// with the clients array left out; generators read only inbound-level
// fields (encryption, method, version, …) from it.
settingsByInbound map[int]map[string]any
} }
// NewSubService creates a new subscription service with the given configuration. // NewSubService creates a new subscription service with the given configuration.
@@ -86,10 +97,96 @@ func (s *SubService) PrepareForRequest(host string) {
s.address = host s.address = host
s.usageShown = map[string]bool{} s.usageShown = map[string]bool{}
s.statsByEmail = map[string]xray.ClientTraffic{} s.statsByEmail = map[string]xray.ClientTraffic{}
s.clientsByInbound = map[int]map[string]model.Client{}
s.fullyPrimedInbounds = map[int]bool{}
s.settingsByInbound = map[int]map[string]any{}
s.loadNodes() s.loadNodes()
s.loadRemarkSettings() s.loadRemarkSettings()
} }
// primeLinkClients caches clients (first occurrence per email, matching the
// old settings-JSON iteration order) so clientForLink resolves them without a
// parse. complete marks the inbound's whole client list as cached.
func (s *SubService) primeLinkClients(inboundId int, clients []model.Client, complete bool) {
if inboundId <= 0 {
return
}
if s.clientsByInbound == nil {
s.clientsByInbound = map[int]map[string]model.Client{}
}
m := s.clientsByInbound[inboundId]
if m == nil {
m = make(map[string]model.Client, len(clients))
s.clientsByInbound[inboundId] = m
}
for _, c := range clients {
if _, exists := m[c.Email]; !exists {
m[c.Email] = c
}
}
if complete {
if s.fullyPrimedInbounds == nil {
s.fullyPrimedInbounds = map[int]bool{}
}
s.fullyPrimedInbounds[inboundId] = true
}
}
// clientForLink resolves one client of an inbound by email for link
// generation: from the per-request cache when primed, otherwise by parsing
// the settings JSON once and caching every client from it.
func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.Client, bool) {
if m, ok := s.clientsByInbound[inbound.Id]; ok {
if c, hit := m[email]; hit {
return c, true
}
if s.fullyPrimedInbounds[inbound.Id] {
return model.Client{}, false
}
}
clients, err := s.inboundService.GetClients(inbound)
if err != nil {
return model.Client{}, false
}
s.primeLinkClients(inbound.Id, clients, true)
for i := range clients {
if clients[i].Email == email {
return clients[i], true
}
}
return model.Client{}, false
}
// linkSettings returns the inbound's settings decoded once per request with
// the clients array left out — the link generators read only inbound-level
// fields from it and resolve clients via clientForLink. The shallow
// RawMessage pass skips materializing a huge clients array entirely.
func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
if inbound.Id > 0 {
if cached, ok := s.settingsByInbound[inbound.Id]; ok {
return cached
}
}
shallow := map[string]json.RawMessage{}
_ = json.Unmarshal([]byte(inbound.Settings), &shallow)
out := make(map[string]any, len(shallow))
for key, raw := range shallow {
if key == "clients" {
continue
}
var value any
_ = json.Unmarshal(raw, &value)
out[key] = value
}
if inbound.Id > 0 {
if s.settingsByInbound == nil {
s.settingsByInbound = map[int]map[string]any{}
}
s.settingsByInbound[inbound.Id] = out
}
return out
}
// loadRemarkSettings populates the per-request remark formatting state so // loadRemarkSettings populates the per-request remark formatting state so
// every subscription format — raw, JSON, Clash — renders remarks the same way // every subscription format — raw, JSON, Clash — renders remarks the same way
// (the date formatter reads datepicker). Loading it only in getSubs left // (the date formatter reads datepicker). Loading it only in getSubs left
@@ -144,25 +241,23 @@ func listenIsInternalOnly(listen string) bool {
} }
// matchingClients returns the inbound's clients whose SubID equals subId, // matchingClients returns the inbound's clients whose SubID equals subId,
// deduplicated by email. settings.clients can accumulate duplicate entries // resolved from the normalized clients/client_inbounds tables (both filter
// for the same client (multi-node sync/import drift, old DBs): SyncInbound // columns indexed) instead of parsing the settings JSON — at large client
// dedupes the normalized client_inbounds rows on write but never rewrites // counts that parse made every subscription fetch cost seconds. The
// the legacy JSON, and the subscription builders iterate that JSON — so // case-insensitive email dedupe stays as cheap insurance even though
// without this guard every duplicate became a duplicate profile in the // clients.email is unique, preserving the #5134 guarantee that duplicate
// output (#5134). Link generation keys purely on (inbound, email), so // settings entries never fan out into duplicate profiles. Resolved clients
// same-email entries are pure duplicates and dropping them is lossless. // are primed into the per-request cache so the link generators don't parse
// settings either.
func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client { func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
clients, err := s.inboundService.GetClients(inbound) clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
if err != nil { if err != nil {
logger.Error("SubService - GetClients: Unable to get clients from inbound") logger.Error("SubService - GetClientsBySubId: Unable to get clients from inbound")
return nil return nil
} }
var out []model.Client var out []model.Client
seen := make(map[string]struct{}, len(clients)) seen := make(map[string]struct{}, len(clients))
for _, client := range clients { for _, client := range clients {
if client.SubID != subId {
continue
}
key := strings.ToLower(client.Email) key := strings.ToLower(client.Email)
if _, dup := seen[key]; dup { if _, dup := seen[key]; dup {
continue continue
@@ -170,6 +265,7 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
seen[key] = struct{}{} seen[key] = struct{}{}
out = append(out, client) out = append(out, client)
} }
s.primeLinkClients(inbound.Id, out, false)
return out return out
} }
@@ -253,6 +349,7 @@ func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
if err != nil { if err != nil {
return nil return nil
} }
s.primeLinkClients(inbound.Id, clients, true)
s.projectThroughFallbackMaster(inbound) s.projectThroughFallbackMaster(inbound)
hostEps := s.hostEndpoints(inbound, "raw") hostEps := s.hostEndpoints(inbound, "raw")
var out []string var out []string
@@ -362,7 +459,7 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB() db := database.GetDB()
var inbounds []*model.Inbound var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( err := db.Model(model.Inbound{}).Where(`id in (
SELECT DISTINCT inbounds.id SELECT DISTINCT inbounds.id
FROM inbounds FROM inbounds
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
@@ -374,19 +471,34 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.indexStatsByEmail(inbounds) s.indexStatsBySubId(subId)
return inbounds, nil return inbounds, nil
} }
// indexStatsByEmail records every loaded inbound's client traffic rows keyed by // indexStatsBySubId loads the traffic rows for just this subscriber's clients
// email so statsForClient can resolve a client's usage even on an inbound that // into statsByEmail so statsForClient can resolve a client's usage on any of
// doesn't own its (globally unique) client_traffics row. See statsByEmail. // its inbounds. It replaces preloading every matched inbound's ClientStats,
func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) { // which read the entire client_traffics table on every subscription fetch of
// a large inbound; statsForClient's per-email DB fallback covers any miss.
func (s *SubService) indexStatsBySubId(subId string) {
if s.statsByEmail == nil { if s.statsByEmail == nil {
s.statsByEmail = map[string]xray.ClientTraffic{} s.statsByEmail = map[string]xray.ClientTraffic{}
} }
for _, inbound := range inbounds { db := database.GetDB()
for _, st := range inbound.ClientStats { var emails []string
if err := db.Model(&model.ClientRecord{}).Where("sub_id = ?", subId).Pluck("email", &emails).Error; err != nil {
logger.Error("SubService - indexStatsBySubId: load emails:", err)
return
}
const chunk = 400
for lo := 0; lo < len(emails); lo += chunk {
hi := min(lo+chunk, len(emails))
var rows []xray.ClientTraffic
if err := db.Where("email IN ?", emails[lo:hi]).Find(&rows).Error; err != nil {
logger.Error("SubService - indexStatsBySubId: load traffics:", err)
return
}
for _, st := range rows {
s.statsByEmail[st.Email] = st s.statsByEmail[st.Email] = st
} }
} }
@@ -516,21 +628,14 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
if inbound.Protocol != model.WireGuard { if inbound.Protocol != model.WireGuard {
return "" return ""
} }
settings := map[string]any{} settings := s.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
secretKey, _ := settings["secretKey"].(string) secretKey, _ := settings["secretKey"].(string)
clients, _ := s.inboundService.GetClients(inbound) resolved, ok := s.clientForLink(inbound, email)
var client *model.Client if !ok || resolved.PrivateKey == "" {
for i := range clients {
if clients[i].Email == email {
client = &clients[i]
break
}
}
if client == nil || client.PrivateKey == "" {
return "" return ""
} }
client := &resolved
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port)) link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
params := make(map[string]string) params := make(map[string]string)
@@ -607,10 +712,12 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
applyVmessTLSParams(stream, obj) applyVmessTLSParams(stream, obj)
} }
clients, _ := s.inboundService.GetClients(inbound) client, ok := s.clientForLink(inbound, email)
clientIndex := findClientIndex(clients, email) if !ok {
obj["id"] = clients[clientIndex].ID return ""
obj["scy"] = clients[clientIndex].Security }
obj["id"] = client.ID
obj["scy"] = client.Security
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
@@ -659,17 +766,18 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
} }
address := s.resolveInboundAddress(inbound) address := s.resolveInboundAddress(inbound)
stream := unmarshalStreamSettings(inbound.StreamSettings) stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound) client, ok := s.clientForLink(inbound, email)
clientIndex := findClientIndex(clients, email) if !ok {
uuid := clients[clientIndex].ID return ""
}
uuid := client.ID
port := inbound.Port port := inbound.Port
streamNetwork := stream["network"].(string) streamNetwork := stream["network"].(string)
params := make(map[string]string) params := make(map[string]string)
params["type"] = streamNetwork params["type"] = streamNetwork
// Add encryption parameter for VLESS from inbound settings // Add encryption parameter for VLESS from inbound settings
var settings map[string]any settings := s.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
if encryption, ok := settings["encryption"].(string); ok { if encryption, ok := settings["encryption"].(string); ok {
params["encryption"] = encryption params["encryption"] = encryption
} }
@@ -683,12 +791,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
case "tls": case "tls":
applyShareTLSParams(stream, params) applyShareTLSParams(stream, params)
case "reality": case "reality":
applyShareRealityParams(stream, params) applyShareRealityParams(stream, params, subKey(client))
default: default:
params["security"] = "none" params["security"] = "none"
} }
if len(clients[clientIndex].Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) { if len(client.Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
params["flow"] = clients[clientIndex].Flow params["flow"] = client.Flow
} }
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
@@ -717,9 +825,11 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
} }
address := s.resolveInboundAddress(inbound) address := s.resolveInboundAddress(inbound)
stream := unmarshalStreamSettings(inbound.StreamSettings) stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound) client, ok := s.clientForLink(inbound, email)
clientIndex := findClientIndex(clients, email) if !ok {
password := encodeUserinfo(clients[clientIndex].Password) return ""
}
password := encodeUserinfo(client.Password)
port := inbound.Port port := inbound.Port
streamNetwork := stream["network"].(string) streamNetwork := stream["network"].(string)
params := make(map[string]string) params := make(map[string]string)
@@ -734,9 +844,9 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
case "tls": case "tls":
applyShareTLSParams(stream, params) applyShareTLSParams(stream, params)
case "reality": case "reality":
applyShareRealityParams(stream, params) applyShareRealityParams(stream, params, subKey(client))
if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 { if streamNetwork == "tcp" && len(client.Flow) > 0 {
params["flow"] = clients[clientIndex].Flow params["flow"] = client.Flow
} }
default: default:
params["security"] = "none" params["security"] = "none"
@@ -788,13 +898,14 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
} }
address := s.resolveInboundAddress(inbound) address := s.resolveInboundAddress(inbound)
stream := unmarshalStreamSettings(inbound.StreamSettings) stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound) client, ok := s.clientForLink(inbound, email)
if !ok {
return ""
}
var settings map[string]any settings := s.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
inboundPassword := settings["password"].(string) inboundPassword := settings["password"].(string)
method := settings["method"].(string) method := settings["method"].(string)
clientIndex := findClientIndex(clients, email)
streamNetwork := stream["network"].(string) streamNetwork := stream["network"].(string)
params := make(map[string]string) params := make(map[string]string)
params["type"] = streamNetwork params["type"] = streamNetwork
@@ -828,9 +939,9 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
userInfo = fmt.Sprintf("%s:%s:%s", userInfo = fmt.Sprintf("%s:%s:%s",
url.QueryEscape(method), url.QueryEscape(method),
url.QueryEscape(inboundPassword), url.QueryEscape(inboundPassword),
url.QueryEscape(clients[clientIndex].Password)) url.QueryEscape(client.Password))
} else { } else {
userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, clients[clientIndex].Password)) userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, client.Password))
} }
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
@@ -861,15 +972,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
} }
var stream map[string]any var stream map[string]any
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream) _ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
clients, _ := s.inboundService.GetClients(inbound) client, ok := s.clientForLink(inbound, email)
clientIndex := -1 if !ok {
for i, client := range clients { return ""
if client.Email == email {
clientIndex = i
break
}
} }
auth := encodeUserinfo(clients[clientIndex].Auth) auth := encodeUserinfo(client.Auth)
params := make(map[string]string) params := make(map[string]string)
params["security"] = "tls" params["security"] = "tls"
@@ -928,8 +1035,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
} }
} }
var settings map[string]any settings := s.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
version, _ := settings["version"].(float64) version, _ := settings["version"].(float64)
protocol := "hysteria2" protocol := "hysteria2"
if int(version) == 1 { if int(version) == 1 {
@@ -1330,7 +1436,7 @@ func hysteriaPinHex(pin string) string {
return pin return pin
} }
func applyShareRealityParams(stream map[string]any, params map[string]string) { func applyShareRealityParams(stream map[string]any, params map[string]string, clientKey string) {
params["security"] = "reality" params["security"] = "reality"
realitySetting, _ := stream["realitySettings"].(map[string]any) realitySetting, _ := stream["realitySettings"].(map[string]any)
realitySettings, _ := searchKey(realitySetting, "settings") realitySettings, _ := searchKey(realitySetting, "settings")
@@ -1356,10 +1462,33 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
params["pqv"] = pqv params["pqv"] = pqv
} }
} }
params["spx"] = "/" + random.Seq(15) seed := ""
if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
seed, _ = spxValue.(string)
}
params["spx"] = deriveSpiderX(seed, clientKey)
} }
} }
// subKey returns a stable per-client identity for deterministic derivations,
// preferring the subscription id and falling back to the (unique) email.
func subKey(c model.Client) string {
if c.SubID != "" {
return c.SubID
}
return c.Email
}
// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a
// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it.
func deriveSpiderX(seed, clientKey string) string {
if seed == "" && clientKey == "" {
return "/" + random.Seq(15)
}
sum := sha256.Sum256([]byte(seed + "|" + clientKey))
return "/" + hex.EncodeToString(sum[:])[:15]
}
func buildVmessLink(obj map[string]any) string { func buildVmessLink(obj map[string]any) string {
jsonStr, _ := json.MarshalIndent(obj, "", " ") jsonStr, _ := json.MarshalIndent(obj, "", " ")
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
+26 -10
View File
@@ -69,19 +69,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
} }
} }
// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count: // TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count.
// the two entries differ only by email case, so dropping strings.ToLower (or keying on // clients.email is unique but case-sensitively so: two rows differing only by email case
// another field) yields two clients. The byte-identical dupes above can't catch that. // can coexist (import drift), and dropping strings.ToLower (or keying on another field)
// would emit both. The first row by id must win, matching the old settings-JSON order.
func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) { func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const subId = "s1" const subId = "s1"
const uuid = "11111111-2222-4333-8444-555555555555" const uuid = "11111111-2222-4333-8444-555555555555"
ib := &model.Inbound{ db := database.GetDB()
Protocol: model.VLESS, ib := &model.Inbound{Protocol: model.VLESS, Enable: true, Port: 42002, Tag: "dedup-ci", Settings: `{"clients":[]}`}
Settings: `{"clients":[ if err := db.Create(ib).Error; err != nil {
{"id":"` + uuid + `","email":"Dup@Example.com","subId":"` + subId + `","enable":true}, t.Fatalf("seed inbound: %v", err)
{"id":"` + uuid + `","email":"dup@example.com","subId":"` + subId + `","enable":true}
]}`,
} }
for _, email := range []string{"Dup@Example.com", "dup@example.com"} {
c := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(c).Error; err != nil {
t.Fatalf("seed client %q: %v", email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: c.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %q: %v", email, err)
}
}
s := &SubService{} s := &SubService{}
got := s.matchingClients(ib, subId) got := s.matchingClients(ib, subId)
if len(got) != 1 { if len(got) != 1 {
@@ -90,7 +106,7 @@ func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
if got[0].Email != "Dup@Example.com" { if got[0].Email != "Dup@Example.com" {
t.Fatalf("first occurrence must be kept, got %q", got[0].Email) t.Fatalf("first occurrence must be kept, got %q", got[0].Email)
} }
// A wrong subId must still be excluded (guards the subId filter at service.go:127). // A wrong subId must still be excluded (guards the SQL subId filter).
if other := s.matchingClients(ib, "nope"); len(other) != 0 { if other := s.matchingClients(ib, "nope"); len(other) != 0 {
t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other)) t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
} }
+95 -6
View File
@@ -1,6 +1,7 @@
package sub package sub
import ( import (
"net/url"
"strings" "strings"
"testing" "testing"
@@ -52,10 +53,8 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
} }
} }
// TestGenVlessLink_RealityParamsMapped locks the reality field mapping // Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element // catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
// Distinct pbk/sid values catch a pbk<->sid swap mutant.
func TestGenVlessLink_RealityParamsMapped(t *testing.T) { func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{ stream := `{
"network":"tcp","security":"reality", "network":"tcp","security":"reality",
@@ -63,7 +62,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
"realitySettings":{ "realitySettings":{
"serverNames":["reality.example.com"], "serverNames":["reality.example.com"],
"shortIds":["ab12cd"], "shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"} "settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
} }
}` }`
s := &SubService{} s := &SubService{}
@@ -75,7 +74,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
"pbk=PBKvalue", "pbk=PBKvalue",
"sid=ab12cd", "sid=ab12cd",
"fp=firefox", "fp=firefox",
"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash "spx=%2F",
} }
for _, w := range wants { for _, w := range wants {
if !strings.Contains(link, w) { if !strings.Contains(link, w) {
@@ -87,3 +86,93 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
t.Fatalf("reality pbk/sid mapping crossed: %s", link) t.Fatalf("reality pbk/sid mapping crossed: %s", link)
} }
} }
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
// with distinct subIds so the per-client spx derivation can be exercised.
func realityTwoClientInbound() *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[
{"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
{"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
],"decryption":"none","encryption":"none"}`,
StreamSettings: `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`,
}
}
func spxParam(t *testing.T, link string) string {
t.Helper()
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
spx := u.Query().Get("spx")
if spx == "" || spx[0] != '/' {
t.Fatalf("spx missing or not /-prefixed in %q", link)
}
return spx
}
// spx must be stable for a given client across repeated exports (the #5718
// complaint) yet differ between clients so the value can't be fingerprinted.
func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
s := &SubService{}
inbound := realityTwoClientInbound()
aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
bob := spxParam(t, s.genVlessLink(inbound, "bob"))
if aliceFirst != aliceSecond {
t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
}
if aliceFirst == bob {
t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
}
}
func TestDeriveSpiderX(t *testing.T) {
if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
t.Fatalf("deriveSpiderX not deterministic: %q", got)
}
if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
t.Fatal("deriveSpiderX must differ per client")
}
if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
t.Fatal("rotating the seed must rotate a client's spx")
}
got := deriveSpiderX("seed", "clientA")
if len(got) != 16 || got[0] != '/' {
t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
}
if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
}
}
// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
// panel builds these links in TS, so both derivations must agree byte-for-byte.
func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
vectors := map[string]struct{ seed, clientKey, want string }{
"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
"seed only": {"/", "", "/d08ed99bd9afc60"},
}
for name, v := range vectors {
t.Run(name, func(t *testing.T) {
if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
}
})
}
}
+241
View File
@@ -0,0 +1,241 @@
package sub
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/op/go-logging"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const scaleTargetSubId = "scale-target-sub"
// setupScaleSubDB mirrors the service package's scale gating: Postgres via
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
func setupScaleSubDB(t *testing.T) {
t.Helper()
xuilogger.InitLogger(logging.ERROR)
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB(postgres): %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
return
}
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
case "1", "true", "yes":
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
t.Fatalf("InitDB(sqlite): %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
return
}
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
}
func scaleSubSizes(t *testing.T, def ...int) []int {
t.Helper()
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
if raw == "" {
return def
}
var out []int
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil || n <= 0 {
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
}
out = append(out, n)
}
if len(out) == 0 {
return def
}
return out
}
func resetScaleSubTables(t *testing.T, db *gorm.DB) {
t.Helper()
if config.GetDBKind() == "postgres" {
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
} else {
for _, tbl := range []string{"inbounds", "clients", "client_inbounds", "client_traffics"} {
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
t.Fatalf("delete %s: %v", tbl, err)
}
}
db.Exec("DELETE FROM sqlite_sequence")
}
if err := db.Where("1 = 1").Delete(&model.ClientExternalLink{}).Error; err != nil {
t.Fatalf("clear client_external_links: %v", err)
}
}
// seedScaleSubDataset seeds one VLESS inbound holding n clients (the sub
// server's worst case: matchingClients parses the whole settings blob and
// getInboundsBySubId preloads every ClientStats row). Three clients share
// scaleTargetSubId; everyone else gets a unique subId.
func seedScaleSubDataset(t *testing.T, n int) {
t.Helper()
db := database.GetDB()
resetScaleSubTables(t, db)
clients := make([]model.Client, n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
targets := map[int]bool{n / 4: true, n / 2: true, 3 * n / 4: true}
for i := range n {
subId := fmt.Sprintf("sub-%07d", i)
if targets[i] {
subId = scaleTargetSubId
}
clients[i] = model.Client{
ID: uuid.NewString(),
Email: fmt.Sprintf("user-%07d@subscale", i),
SubID: subId,
Enable: true,
ExpiryTime: exp,
TotalGB: 100 << 30,
}
}
settingsMap := map[string]any{"clients": clients, "decryption": "none"}
settings, err := json.Marshal(settingsMap)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
tx := db.Begin()
if tx.Error != nil {
t.Fatalf("begin seed tx: %v", tx.Error)
}
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
ib := &model.Inbound{
UserId: 1,
Tag: fmt.Sprintf("subscale-%d", n),
Remark: "subscale",
Enable: true,
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Settings: string(settings),
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := tx.Create(ib).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
records := make([]*model.ClientRecord, n)
for i := range clients {
records[i] = clients[i].ToRecord()
}
if err := tx.CreateInBatches(records, 500).Error; err != nil {
t.Fatalf("seed clients: %v", err)
}
links := make([]model.ClientInbound, n)
for i := range records {
links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id}
}
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
t.Fatalf("seed client_inbounds: %v", err)
}
traffics := make([]xray.ClientTraffic, n)
for i := range clients {
traffics[i] = xray.ClientTraffic{
InboundId: ib.Id,
Email: clients[i].Email,
Enable: true,
Total: clients[i].TotalGB,
ExpiryTime: clients[i].ExpiryTime,
}
}
if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if err := tx.Commit().Error; err != nil {
t.Fatalf("commit seed tx: %v", err)
}
committed = true
db.Exec("ANALYZE")
}
// TestGetSubsScale measures one subscription fetch (raw and JSON format) for a
// 3-client subId living inside an n-client inbound, plus a subId miss — the
// per-request cost every subscriber pays.
func TestGetSubsScale(t *testing.T) {
for _, n := range scaleSubSizes(t, 10000, 100000) {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
setupScaleSubDB(t)
seedScaleSubDataset(t, n)
svc := &SubService{}
const reps = 5
start := time.Now()
var links []string
for range reps {
var err error
links, _, _, _, err = svc.GetSubs(scaleTargetSubId, "sub.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
}
rawDur := time.Since(start) / reps
if len(links) != 3 {
t.Fatalf("GetSubs links = %d, want 3", len(links))
}
jsonSvc := NewSubJsonService("", "", "", &SubService{})
start = time.Now()
for range reps {
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com")
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if body == "" {
t.Fatal("GetJson returned empty body")
}
}
jsonDur := time.Since(start) / reps
start = time.Now()
for range reps {
missLinks, _, _, _, err := svc.GetSubs("no-such-sub", "sub.example.com")
if err != nil {
t.Fatalf("GetSubs miss: %v", err)
}
if len(missLinks) != 0 {
t.Fatalf("GetSubs miss links = %d, want 0", len(missLinks))
}
}
missDur := time.Since(start) / reps
t.Logf("N=%-7d raw=%-10v json=%-10v miss=%v",
n, rawDur.Round(time.Millisecond), jsonDur.Round(time.Millisecond), missDur.Round(time.Millisecond))
})
}
}
+21
View File
@@ -0,0 +1,21 @@
package common
import "strings"
// EnsureURLScheme prepends https:// to a URL that carries no scheme, so
// subscription apps and browsers don't resolve it relative to the panel's own
// domain (e.g. "t.me/support" turning into "https://panel.example/t.me/support").
// Values with an explicit scheme (https://, tg://, mailto:, tel:) and empty
// strings pass through untouched.
func EnsureURLScheme(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if strings.Contains(trimmed, "://") ||
strings.HasPrefix(trimmed, "mailto:") ||
strings.HasPrefix(trimmed, "tel:") {
return trimmed
}
return "https://" + trimmed
}
+29
View File
@@ -0,0 +1,29 @@
package common
import "testing"
func TestEnsureURLScheme(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"whitespace only", " ", ""},
{"bare telegram handle", "t.me/xui_support", "https://t.me/xui_support"},
{"bare domain with path", "example.com/help", "https://example.com/help"},
{"already https", "https://t.me/xui_support", "https://t.me/xui_support"},
{"already http", "http://example.com", "http://example.com"},
{"telegram deep link", "tg://resolve?domain=xui_support", "tg://resolve?domain=xui_support"},
{"mailto", "mailto:support@example.com", "mailto:support@example.com"},
{"tel", "tel:+1234567890", "tel:+1234567890"},
{"trims whitespace", " t.me/xui_support ", "https://t.me/xui_support"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := EnsureURLScheme(tt.in); got != tt.want {
t.Errorf("EnsureURLScheme(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
@@ -0,0 +1,152 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/gin-gonic/gin"
)
// newPanelUpdateTestEngine registers only updatePanel/getUpdateStatus directly
// on the controller's zero value, bypassing NewServerController's cron/metrics
// setup (unrelated to these two handlers, and unnecessary weight for a unit
// test). Callers must set up a DB first (newHostTestDB(t)) since StartUpdate
// reads the dev-channel setting before doing anything else.
func newPanelUpdateTestEngine() *gin.Engine {
a := &ServerController{}
engine := gin.New()
engine.GET("/panel/api/server/getUpdateStatus", a.getUpdateStatus)
engine.POST("/panel/api/server/updatePanel", a.updatePanel)
return engine
}
func doPanelUpdateReq(t *testing.T, engine *gin.Engine, method, path string) hostEnvelope {
t.Helper()
req := httptest.NewRequest(method, path, nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
}
var env hostEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
}
return env
}
// TestGetUpdateStatus_NoStatusFileYet exercises the read-only status endpoint
// with no prior update having run: it must report "pending" (not an error),
// since a missing status file is an expected, ordinary state, not a failure.
func TestGetUpdateStatus_NoStatusFileYet(t *testing.T) {
newHostTestDB(t)
engine := newPanelUpdateTestEngine()
env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
if !env.Success {
t.Fatalf("getUpdateStatus should always report success=true (it's a best-effort read): msg=%s", env.Msg)
}
var status struct {
RunID string `json:"runId"`
State string `json:"state"`
}
if err := json.Unmarshal(env.Obj, &status); err != nil {
t.Fatalf("decode status: %v", err)
}
if status.State != "pending" {
t.Fatalf("State = %q, want %q", status.State, "pending")
}
}
// TestGetUpdateStatus_RunIdIsAlwaysAString is the regression test for the
// precision bug found in review: RunID is a 19-digit UnixNano timestamp, so
// it must round-trip over the wire as a JSON string, never a bare number -- a
// bare number would silently lose precision in JavaScript past
// Number.MAX_SAFE_INTEGER, breaking every future runId comparison on the
// frontend. Decoding into a Go string field below only succeeds if the wire
// value is actually a JSON string; a bare number there would fail to decode,
// so this test doubles as the wire-format check.
func TestGetUpdateStatus_RunIdIsAlwaysAString(t *testing.T) {
newHostTestDB(t)
engine := newPanelUpdateTestEngine()
statusPath := config.GetUpdateStatusFilePath()
body := `{"runId":"1735689600123456789","state":"success","exitCode":0,"finishedAt":1735689612}`
if err := os.WriteFile(statusPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
var status struct {
RunID string `json:"runId"`
State string `json:"state"`
}
if err := json.Unmarshal(env.Obj, &status); err != nil {
t.Fatalf("decode status (would fail here if runId were a bare JSON number instead of a string): %v, body=%s", err, env.Obj)
}
if status.RunID != "1735689600123456789" {
t.Fatalf("RunID = %q, want %q", status.RunID, "1735689600123456789")
}
if status.State != "success" {
t.Fatalf("State = %q, want %q", status.State, "success")
}
}
// TestUpdatePanel_UnsupportedPlatformReturnsNoRunId covers the one path of
// updatePanel that's safe to exercise in an automated test on any OS/CI
// runner: the runtime.GOOS != "linux" guard. Actually invoking StartUpdate's
// launch logic on Linux would make a real network call and could launch a
// real update.sh process, so that path is deliberately not covered here --
// see the PR description for why.
func TestUpdatePanel_UnsupportedPlatformReturnsNoRunId(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("this test only exercises the non-Linux guard path; on Linux, updatePanel would attempt a real download/exec")
}
newHostTestDB(t)
engine := newPanelUpdateTestEngine()
env := doPanelUpdateReq(t, engine, http.MethodPost, "/panel/api/server/updatePanel")
if env.Success {
t.Fatal("updatePanel on an unsupported platform: success = true, want false")
}
if len(env.Obj) != 0 && string(env.Obj) != "null" {
t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
}
}
// TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch covers the one branch of
// updatePanel that's both untested and safe to exercise on any OS/CI runner:
// an unparseable "dev" form value is rejected by strconv.ParseBool before
// StartUpdateChannel (and therefore any real exec/network call) is ever
// reached, on Linux or otherwise.
func TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch(t *testing.T) {
newHostTestDB(t)
engine := newPanelUpdateTestEngine()
form := url.Values{"dev": {"notabool"}}
req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d, body=%s", w.Code, w.Body.String())
}
var env hostEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
}
if env.Success {
t.Fatal("updatePanel with dev=notabool: success = true, want false")
}
if len(env.Obj) != 0 && string(env.Obj) != "null" {
t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
}
}
+18 -4
View File
@@ -51,6 +51,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket) g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getUpdateStatus", a.getUpdateStatus)
g.GET("/getConfigJson", a.getConfigJson) g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb) g.GET("/getDb", a.getDb)
g.GET("/getMigration", a.getMigration) g.GET("/getMigration", a.getMigration)
@@ -209,21 +210,34 @@ func (a *ServerController) installXray(c *gin.Context) {
// updatePanel starts a panel self-update. With no "dev" form value it follows // updatePanel starts a panel self-update. With no "dev" form value it follows
// this panel's own channel setting; an explicit "dev" (sent by the master node // this panel's own channel setting; an explicit "dev" (sent by the master node
// updater) overrides it for this run. // updater) overrides it for this run. The response's runId identifies this
// update for a later getUpdateStatus poll.
func (a *ServerController) updatePanel(c *gin.Context) { func (a *ServerController) updatePanel(c *gin.Context) {
devParam := c.PostForm("dev") devParam := c.PostForm("dev")
var runID int64
var err error var err error
if devParam == "" { if devParam == "" {
err = a.panelService.StartUpdate() runID, err = a.panelService.StartUpdate()
} else { } else {
dev, perr := strconv.ParseBool(devParam) dev, perr := strconv.ParseBool(devParam)
if perr != nil { if perr != nil {
jsonMsg(c, "invalid data", perr) jsonMsg(c, "invalid data", perr)
return return
} }
err = a.panelService.StartUpdateChannel(dev) runID, err = a.panelService.StartUpdateChannel(dev)
} }
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err) var obj any
if err == nil {
obj = gin.H{"runId": strconv.FormatInt(runID, 10)}
}
jsonMsgObj(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), obj, err)
}
// getUpdateStatus reports the outcome of the most recently launched panel
// self-update (see updatePanel). Compare the returned runId against the one
// updatePanel returned to tell this run's result apart from a stale one.
func (a *ServerController) getUpdateStatus(c *gin.Context) {
jsonObj(c, a.panelService.GetUpdateStatus(), nil)
} }
// setUpdateChannel toggles whether self-update tracks the rolling dev release. // setUpdateChannel toggles whether self-update tracks the rolling dev release.
+13 -2
View File
@@ -26,9 +26,16 @@ type updateUserForm struct {
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
} }
// updateSettingForm carries the persisted settings plus request-scoped fields
// that must never land in the settings table: the 2FA confirmation code and
// the explicit clear flags for redacted secrets (a blank secret alone means
// "unchanged", so clearing needs its own signal — see #5724).
type updateSettingForm struct { type updateSettingForm struct {
entity.AllSetting entity.AllSetting
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"`
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
} }
// SettingController handles settings and user management operations. // SettingController handles settings and user management operations.
@@ -105,7 +112,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
return return
} }
} }
err := a.settingService.UpdateAllSetting(allSetting) err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
TgBotToken: form.ClearTgBotToken,
LdapPassword: form.ClearLdapPassword,
SmtpPassword: form.ClearSmtpPassword,
})
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil { if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr err = bumpErr
+217 -101
View File
@@ -113,33 +113,129 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
return observed, true return observed, true
} }
// hasLimitIp reports whether any client carries an IP limit. It probes the
// normalized clients table (limit_ip is synced there by SyncInbound and the
// legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that
// loaded and JSON-parsed every inbound's settings blob on each 10s run.
func (j *CheckClientIpJob) hasLimitIp() bool { func (j *CheckClientIpJob) hasLimitIp() bool {
db := database.GetDB() db := database.GetDB()
var inbounds []*model.Inbound var probe int64
err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error
return err == nil && probe > 0
}
err := db.Model(model.Inbound{}).Where("settings LIKE ?", "%limitIp%").Find(&inbounds).Error const ipScanChunk = 400
if err != nil {
return false func chunkEmails(s []string, size int) [][]string {
if len(s) == 0 {
return nil
} }
chunks := make([][]string, 0, (len(s)+size-1)/size)
for size < len(s) {
s, chunks = s[size:], append(chunks, s[:size])
}
return append(chunks, s)
}
for _, inbound := range inbounds { // loadClientLimits maps each observed email to its clients.limit_ip in a few
if inbound.Settings == "" { // chunked queries, replacing the per-email settings-JSON parse that previously
// resolved the limit.
func (j *CheckClientIpJob) loadClientLimits(emails []string) map[string]int {
db := database.GetDB()
out := make(map[string]int, len(emails))
for _, batch := range chunkEmails(emails, ipScanChunk) {
var rows []struct {
Email string
LimitIp int
}
if err := db.Model(&model.ClientRecord{}).
Select("email, limit_ip").
Where("email IN ?", batch).
Scan(&rows).Error; err != nil {
j.checkError(err)
continue continue
} }
for _, r := range rows {
out[r.Email] = r.LimitIp
}
}
return out
}
settings := map[string][]model.Client{} // loadInboundsByEmails resolves each email's owning inbound through the
_ = json.Unmarshal([]byte(inbound.Settings), &settings) // clients/client_inbounds relation in chunked queries. Like the old per-email
clients := settings["clients"] // First() it keeps the lowest inbound id when a client spans several inbounds.
func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound {
for _, client := range clients { db := database.GetDB()
limitIp := client.LimitIP minInboundByEmail := make(map[string]int, len(emails))
if limitIp > 0 { for _, batch := range chunkEmails(emails, ipScanChunk) {
return true var pairs []struct {
Email string
InboundId int
}
if err := db.Table("client_inbounds").
Select("clients.email AS email, client_inbounds.inbound_id AS inbound_id").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN ?", batch).
Scan(&pairs).Error; err != nil {
j.checkError(err)
return nil
}
for _, p := range pairs {
if cur, ok := minInboundByEmail[p.Email]; !ok || p.InboundId < cur {
minInboundByEmail[p.Email] = p.InboundId
} }
} }
} }
if len(minInboundByEmail) == 0 {
return nil
}
return false idSet := make(map[int]struct{}, len(minInboundByEmail))
ids := make([]int, 0, len(minInboundByEmail))
for _, id := range minInboundByEmail {
if _, seen := idSet[id]; !seen {
idSet[id] = struct{}{}
ids = append(ids, id)
}
}
sort.Ints(ids)
inboundsById := make(map[int]*model.Inbound, len(ids))
for lo := 0; lo < len(ids); lo += ipScanChunk {
hi := min(lo+ipScanChunk, len(ids))
var page []*model.Inbound
if err := db.Model(&model.Inbound{}).Where("id IN ?", ids[lo:hi]).Find(&page).Error; err != nil {
j.checkError(err)
return nil
}
for _, ib := range page {
inboundsById[ib.Id] = ib
}
}
out := make(map[string]*model.Inbound, len(minInboundByEmail))
for email, id := range minInboundByEmail {
if ib, ok := inboundsById[id]; ok {
out[email] = ib
}
}
return out
}
func (j *CheckClientIpJob) loadClientIpRows(emails []string) map[string]*model.InboundClientIps {
db := database.GetDB()
out := make(map[string]*model.InboundClientIps, len(emails))
for _, batch := range chunkEmails(emails, ipScanChunk) {
var rows []model.InboundClientIps
if err := db.Where("client_email IN ?", batch).Find(&rows).Error; err != nil {
j.checkError(err)
continue
}
for i := range rows {
out[rows[i].ClientEmail] = &rows[i]
}
}
return out
} }
// processObserved runs collection + enforcement for one scan's observations // processObserved runs collection + enforcement for one scan's observations
@@ -147,27 +243,68 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
// observations as live connections, which bypass the stale cutoff: a connection // observations as live connections, which bypass the stale cutoff: a connection
// that opened hours ago is still live even though its timestamp is old. The // that opened hours ago is still live even though its timestamp is old. The
// online-stats API always reports live connections, so the job passes true. // online-stats API always reports live connections, so the job passes true.
// Lookups are batched up front and all inbound_client_ips writes share one
// transaction, so a scan costs a handful of queries and one fsync instead of
// several per observed email.
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool { func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
shouldCleanLog := false shouldCleanLog := false
now := time.Now().Unix() now := time.Now().Unix()
emails := make([]string, 0, len(observed))
for email := range observed {
emails = append(emails, email)
}
sort.Strings(emails)
limitByEmail := j.loadClientLimits(emails)
inboundByEmail := j.loadInboundsByEmails(emails)
ipRowByEmail := j.loadClientIpRows(emails)
// attribution accumulates this scan's local observations per email so they can // attribution accumulates this scan's local observations per email so they can
// be recorded under this panel's own guid for cross-node IP attribution. // be recorded under this panel's own guid for cross-node IP attribution.
attribution := make(map[string][]model.ClientIpEntry, len(observed)) attribution := make(map[string][]model.ClientIpEntry, len(observed))
for email, ipTimestamps := range observed {
type pendingDisconnect struct {
inbound *model.Inbound
email string
}
var disconnects []pendingDisconnect
db := database.GetDB()
tx := db.Begin()
if tx.Error != nil {
j.checkError(tx.Error)
return false
}
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
for _, email := range emails {
ipTimestamps := observed[email]
// The observations can still reference a client that was just renamed // The observations can still reference a client that was just renamed
// or deleted; its email no longer matches any inbound. Skip it (and // or deleted; its email no longer matches any inbound. Skip it (and
// drop any orphaned tracking row) instead of recreating a row and // drop any orphaned tracking row) instead of recreating a row and
// logging an ERROR every run (#4963). // logging an ERROR every run (#4963). The batch map resolves through
inbound, err := j.getInboundByEmail(email) // the clients relation; the per-email fallback keeps its settings LIKE
if err != nil { // net for clients not yet present there.
if errors.Is(err, gorm.ErrRecordNotFound) { inbound, ok := inboundByEmail[email]
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email) if !ok {
j.delInboundClientIps(email) var err error
} else { inbound, err = j.getInboundByEmail(email)
j.checkError(err) if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
j.delInboundClientIps(tx, email)
} else {
j.checkError(err)
}
continue
} }
continue
} }
// Convert to IPWithTimestamp slice // Convert to IPWithTimestamp slice
@@ -188,13 +325,44 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
attribution[email] = attrEntries attribution[email] = attrEntries
} }
clientIpsRecord, err := j.getInboundClientIps(email) clientIpsRecord, ok := ipRowByEmail[email]
if err != nil { if !ok {
_ = j.addInboundClientIps(email, ipsWithTime) jsonIps, err := json.Marshal(ipsWithTime)
if err != nil {
j.checkError(err)
continue
}
if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
j.checkError(err)
}
continue continue
} }
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog cleaned, banned := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
shouldCleanLog = cleaned || shouldCleanLog
if banned {
disconnects = append(disconnects, pendingDisconnect{inbound: inbound, email: email})
}
}
if err := tx.Commit().Error; err != nil {
j.checkError(err)
return shouldCleanLog
}
committed = true
// Xray disconnects run after the commit so their network round-trips never
// extend the scan's write transaction (node syncs upsert the same table).
clientsCache := make(map[int][]model.Client)
for _, d := range disconnects {
clients, cached := clientsCache[d.inbound.Id]
if !cached {
settings := map[string][]model.Client{}
_ = json.Unmarshal([]byte(d.inbound.Settings), &settings)
clients = settings["clients"]
clientsCache[d.inbound.Id] = clients
}
j.disconnectClientTemporarily(d.inbound, d.email, clients)
} }
j.recordLocalAttribution(attribution) j.recordLocalAttribution(attribution)
@@ -293,81 +461,34 @@ func (j *CheckClientIpJob) checkError(e error) {
} }
} }
func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.InboundClientIps, error) {
db := database.GetDB()
InboundClientIps := &model.InboundClientIps{}
err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
if err != nil {
return nil, err
}
return InboundClientIps, nil
}
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
inboundClientIps := &model.InboundClientIps{}
jsonIps, err := json.Marshal(ipsWithTime)
j.checkError(err)
inboundClientIps.ClientEmail = clientEmail
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
tx := db.Begin()
defer func() {
if err == nil {
tx.Commit()
} else {
tx.Rollback()
}
}()
err = tx.Save(inboundClientIps).Error
if err != nil {
return err
}
return nil
}
// delInboundClientIps drops the inbound_client_ips tracking row for an email // delInboundClientIps drops the inbound_client_ips tracking row for an email
// that no longer maps to any inbound (a renamed or deleted client), so stale // that no longer maps to any inbound (a renamed or deleted client), so stale
// access-log entries don't keep a ghost row alive (#4963). // access-log entries don't keep a ghost row alive (#4963).
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) { func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
db := database.GetDB() if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
j.checkError(err) j.checkError(err)
} }
} }
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool { // updateInboundClientIps merges one email's observed IPs into its tracking row
// and applies the IP limit. limitIp comes from the caller (the clients table);
// writes go through the caller's transaction. banned=true asks the caller to
// disconnect the client after the transaction commits.
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (shouldCleanLog, banned bool) {
if inbound.Settings == "" { if inbound.Settings == "" {
logger.Debug("wrong data:", inbound) logger.Debug("wrong data:", inbound)
return false return false, false
} }
settings := map[string][]model.Client{} if !enforce || limitIp <= 0 || !inbound.Enable {
_ = json.Unmarshal([]byte(inbound.Settings), &settings) // Nothing to enforce (collection-only run, no limit on the clients row,
clients := settings["clients"] // or inbound disabled): record the observed IPs for the panel and return.
// Find the client's IP limit
var limitIp int
var clientFound bool
for _, client := range clients {
if client.Email == clientEmail {
limitIp = client.LimitIP
clientFound = true
break
}
}
if !enforce || !clientFound || limitIp <= 0 || !inbound.Enable {
// Nothing to enforce (collection-only run, no limit, client missing, or
// inbound disabled): record the observed IPs for the panel and return.
jsonIps, _ := json.Marshal(newIpsWithTime) jsonIps, _ := json.Marshal(newIpsWithTime)
inboundClientIps.Ips = string(jsonIps) inboundClientIps.Ips = string(jsonIps)
db := database.GetDB() if err := tx.Save(inboundClientIps).Error; err != nil {
db.Save(inboundClientIps) logger.Error("failed to save inboundClientIps:", err)
return false }
return false, false
} }
// Parse old IPs from database // Parse old IPs from database
@@ -386,18 +507,18 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
} }
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan) liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
shouldCleanLog := false
j.disAllowedIps = []string{} j.disAllowedIps = []string{}
// historical db-only ips are excluded from this count on purpose. // historical db-only ips are excluded from this count on purpose.
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp) keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
if len(bannedLive) > 0 { if len(bannedLive) > 0 {
shouldCleanLog = true shouldCleanLog = true
banned = true
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
logger.Errorf("failed to open IP limit log file: %s", err) logger.Errorf("failed to open IP limit log file: %s", err)
return false return false, false
} }
defer logIpFile.Close() defer logIpFile.Close()
ipLogger := log.New(logIpFile, "", log.LstdFlags) ipLogger := log.New(logIpFile, "", log.LstdFlags)
@@ -410,9 +531,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP) j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp) ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
} }
// force xray to drop existing connections from banned ips
j.disconnectClientTemporarily(inbound, clientEmail, clients)
} }
// keep kept-live + historical in the blob so the panel keeps showing // keep kept-live + historical in the blob so the panel keeps showing
@@ -424,18 +542,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
jsonIps, _ := json.Marshal(dbIps) jsonIps, _ := json.Marshal(dbIps)
inboundClientIps.Ips = string(jsonIps) inboundClientIps.Ips = string(jsonIps)
db := database.GetDB() if err := tx.Save(inboundClientIps).Error; err != nil {
err := db.Save(inboundClientIps).Error
if err != nil {
logger.Error("failed to save inboundClientIps:", err) logger.Error("failed to save inboundClientIps:", err)
return false return false, banned
} }
if len(j.disAllowedIps) > 0 { if len(j.disAllowedIps) > 0 {
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps)) logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
} }
return shouldCleanLog return shouldCleanLog, banned
} }
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
@@ -95,7 +95,7 @@ func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *mo
func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound { func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
t.Helper() t.Helper()
inbound := seedInboundOnlyWithClient(t, tag, email, limitIp) inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
client := &model.ClientRecord{Email: email} client := &model.ClientRecord{Email: email, LimitIP: limitIp}
if err := database.GetDB().Create(client).Error; err != nil { if err := database.GetDB().Create(client).Error; err != nil {
t.Fatalf("seed client record: %v", err) t.Fatalf("seed client record: %v", err)
} }
@@ -206,11 +206,14 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
if err != nil { if err != nil {
t.Fatalf("getInboundByEmail: %v", err) t.Fatalf("getInboundByEmail: %v", err)
} }
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false) shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 3, live, true, false)
if shouldCleanLog { if shouldCleanLog {
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3") t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
} }
if banned {
t.Fatalf("banned must be false with 1 live ip under limit 3")
}
if len(j.disAllowedIps) != 0 { if len(j.disAllowedIps) != 0 {
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps) t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
} }
@@ -259,11 +262,14 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("getInboundByEmail: %v", err) t.Fatalf("getInboundByEmail: %v", err)
} }
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false) shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
if !shouldCleanLog { if !shouldCleanLog {
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit") t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
} }
if !banned {
t.Fatalf("banned must be true when the live set exceeds the limit")
}
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" { if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps) t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
} }
@@ -388,3 +394,28 @@ func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
t.Fatalf("substring email matched inbound %d; want no exact match", got.Id) t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
} }
} }
// hasLimitIp gates every 10s scan on the normalized clients table: a bare
// "limitIp":0 in settings JSON (which the old LIKE scan matched and parsed)
// must not enable enforcement, while a single clients.limit_ip > 0 row must.
func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
setupIntegrationDB(t)
j := &CheckClientIpJob{}
if j.hasLimitIp() {
t.Fatal("hasLimitIp = true on an empty database")
}
seedLinkedInboundWithClient(t, "no-limit", "nolimit@example.com", 0)
if j.hasLimitIp() {
t.Fatal("hasLimitIp = true with only limit_ip=0 clients")
}
limited := &model.ClientRecord{Email: "limited@example.com", LimitIP: 2}
if err := database.GetDB().Create(limited).Error; err != nil {
t.Fatalf("seed limited client: %v", err)
}
if !j.hasLimitIp() {
t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
}
}
@@ -0,0 +1,229 @@
package job
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/op/go-logging"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
func setupScaleJobDB(t *testing.T) {
t.Helper()
loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB(postgres): %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
return
}
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
case "1", "true", "yes":
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
t.Fatalf("InitDB(sqlite): %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
return
}
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
}
func scaleJobSizes(t *testing.T, def ...int) []int {
t.Helper()
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
if raw == "" {
return def
}
var out []int
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil || n <= 0 {
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
}
out = append(out, n)
}
if len(out) == 0 {
return def
}
return out
}
func resetScaleJobTables(t *testing.T, db *gorm.DB) {
t.Helper()
if config.GetDBKind() == "postgres" {
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
} else {
for _, tbl := range []string{"inbounds", "clients", "client_inbounds"} {
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
t.Fatalf("delete %s: %v", tbl, err)
}
}
db.Exec("DELETE FROM sqlite_sequence")
}
if err := db.Where("1 = 1").Delete(&model.InboundClientIps{}).Error; err != nil {
t.Fatalf("clear inbound client ips: %v", err)
}
if err := db.Where("1 = 1").Delete(&model.NodeClientIp{}).Error; err != nil {
t.Fatalf("clear node client ips: %v", err)
}
}
// seedScaleIPDataset seeds n clients across numInbounds inbounds. Every client
// in the LAST inbound carries limitIp=3 (and 0 elsewhere), so hasLimitIp pays
// its full scan cost before finding a hit, and the returned emails all resolve
// to that last inbound for the processObserved measurement.
func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
t.Helper()
db := database.GetDB()
resetScaleJobTables(t, db)
tx := db.Begin()
if tx.Error != nil {
t.Fatalf("begin seed tx: %v", tx.Error)
}
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
var limitedEmails []string
per := n / numInbounds
for i := range numInbounds {
lo, hi := i*per, (i+1)*per
if i == numInbounds-1 {
hi = n
}
limitIp := 0
if i == numInbounds-1 {
limitIp = 3
}
clients := make([]model.Client, 0, hi-lo)
records := make([]*model.ClientRecord, 0, hi-lo)
for j := lo; j < hi; j++ {
email := fmt.Sprintf("user-%07d@ipscale", j)
clients = append(clients, model.Client{Email: email, LimitIP: limitIp, Enable: true})
records = append(records, &model.ClientRecord{Email: email, LimitIP: limitIp, Enable: true})
if limitIp > 0 {
limitedEmails = append(limitedEmails, email)
}
}
settings, err := json.Marshal(map[string][]model.Client{"clients": clients})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
ib := &model.Inbound{
UserId: 1,
Tag: fmt.Sprintf("ipscale-%d-%d", n, i),
Enable: true,
Port: 42000 + i,
Protocol: model.VLESS,
Settings: string(settings),
}
if err := tx.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %d: %v", i, err)
}
if err := tx.CreateInBatches(records, 500).Error; err != nil {
t.Fatalf("seed clients %d: %v", i, err)
}
links := make([]model.ClientInbound, len(records))
for j := range records {
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
}
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
t.Fatalf("seed client_inbounds %d: %v", i, err)
}
}
if err := tx.Commit().Error; err != nil {
t.Fatalf("commit seed tx: %v", err)
}
committed = true
db.Exec("ANALYZE")
return limitedEmails
}
// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
// inbound) and processObserved with M online users (per-email inbound lookup,
// settings parse and autocommit save). Run twice: first scan half add / half
// update, second scan all update path.
func TestCheckClientIpScale(t *testing.T) {
shapes := []struct {
name string
inbounds int
observed int
}{{"single", 1, 50}, {"spread50", 50, 1000}}
for _, n := range scaleJobSizes(t, 10000, 100000) {
for _, shape := range shapes {
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
setupScaleJobDB(t)
limited := seedScaleIPDataset(t, n, shape.inbounds)
m := min(shape.observed, len(limited))
j := NewCheckClientIpJob()
const reps = 3
start := time.Now()
for range reps {
if !j.hasLimitIp() {
t.Fatal("hasLimitIp = false, want true")
}
}
t.Logf("N=%-7d shape=%-8s hasLimitIp=%v/call", n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
now := time.Now().Unix()
observed := make(map[string]map[string]int64, m)
for i := range m {
observed[limited[i]] = map[string]int64{
fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now,
}
}
for i := range m / 2 {
seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
}
start = time.Now()
j.processObserved(observed, true, true)
firstScan := time.Since(start)
start = time.Now()
j.processObserved(observed, true, true)
secondScan := time.Since(start)
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
float64(secondScan.Milliseconds())/float64(m))
var rows int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&rows).Error; err != nil {
t.Fatalf("count ip rows: %v", err)
}
if rows != int64(m) {
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
}
})
}
}
}
+66 -21
View File
@@ -110,6 +110,8 @@ func (j *NodeTrafficSyncJob) Run() {
sem := make(chan struct{}, nodeTrafficSyncConcurrency) sem := make(chan struct{}, nodeTrafficSyncConcurrency)
var wg sync.WaitGroup var wg sync.WaitGroup
var activeMu sync.Mutex
var activeEmails []string
for _, n := range nodes { for _, n := range nodes {
if !n.Enable || n.Status != "online" { if !n.Enable || n.Status != "online" {
continue continue
@@ -120,7 +122,11 @@ func (j *NodeTrafficSyncJob) Run() {
common.GoRecover("node-traffic-sync:"+n.Name, func() { common.GoRecover("node-traffic-sync:"+n.Name, func() {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
j.syncOne(mgr, n, doIpSync) if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 {
activeMu.Lock()
activeEmails = append(activeEmails, emails...)
activeMu.Unlock()
}
}) })
} }
wg.Wait() wg.Wait()
@@ -143,14 +149,6 @@ func (j *NodeTrafficSyncJob) Run() {
j.maybePushGlobals(mgr, nodes) j.maybePushGlobals(mgr, nodes)
lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err)
}
if lastOnline == nil {
lastOnline = map[string]int64{}
}
// Prune stale local-online entries (no local active emails or inbound tags // Prune stale local-online entries (no local active emails or inbound tags
// to add here — only the local xray poll feeds those) so a stopped local // to add here — only the local xray poll feeds those) so a stopped local
// xray's clients and inbounds still age out between traffic polls. // xray's clients and inbounds still age out between traffic polls.
@@ -164,6 +162,45 @@ func (j *NodeTrafficSyncJob) Run() {
return return
} }
// Same snapshot-vs-delta split as the local traffic job: above the
// threshold a full snapshot would be dropped by the hub's payload cap, so
// send only the rows for clients online on the synced nodes this tick.
snapshot := true
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
logger.Warning("node traffic sync: count client traffics failed:", countErr)
} else if total > clientStatsSnapshotMaxClients {
snapshot = false
}
var stats []*xray.ClientTraffic
var statsErr error
if snapshot {
stats, statsErr = j.inboundService.GetAllClientTraffics()
} else {
stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
}
if statsErr != nil {
logger.Warning("node traffic sync: get client traffics for websocket failed:", statsErr)
}
var lastOnline map[string]int64
if snapshot {
var loErr error
if lastOnline, loErr = j.inboundService.GetClientsLastOnline(); loErr != nil {
logger.Warning("node traffic sync: get last-online failed:", loErr)
}
} else {
lastOnline = make(map[string]int64, len(stats))
for _, ct := range stats {
if ct != nil {
lastOnline[ct.Email] = ct.LastOnline
}
}
}
if lastOnline == nil {
lastOnline = map[string]int64{}
}
online := j.inboundService.GetOnlineClients() online := j.inboundService.GetOnlineClients()
if online == nil { if online == nil {
online = []string{} online = []string{}
@@ -181,10 +218,8 @@ func (j *NodeTrafficSyncJob) Run() {
trafficPayload["nodeTraffics"] = inboundSpeed trafficPayload["nodeTraffics"] = inboundSpeed
websocket.BroadcastTraffic(trafficPayload) websocket.BroadcastTraffic(trafficPayload)
clientStats := map[string]any{} clientStats := map[string]any{"snapshot": snapshot}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil { if len(stats) > 0 {
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStats["clients"] = stats clientStats["clients"] = stats
} }
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil { if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
@@ -192,7 +227,7 @@ func (j *NodeTrafficSyncJob) Run() {
} else if len(summary) > 0 { } else if len(summary) > 0 {
clientStats["inbounds"] = summary clientStats["inbounds"] = summary
} }
if len(clientStats) > 0 { if len(clientStats) > 1 {
websocket.BroadcastClientStats(clientStats) websocket.BroadcastClientStats(clientStats)
} }
@@ -318,11 +353,14 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
wg.Wait() wg.Wait()
} }
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) { // syncOne pulls one node's traffic snapshot and merges it. It returns the
// emails online on that node this tick, feeding the delta broadcast above the
// snapshot threshold; nil on any failure path.
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
rt, err := mgr.RemoteFor(n) rt, err := mgr.RemoteFor(n)
if err != nil { if err != nil {
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err) logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
return return nil
} }
if n.ConfigDirty { if n.ConfigDirty {
@@ -331,7 +369,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
reconcileCancel() reconcileCancel()
if reconcileErr != nil { if reconcileErr != nil {
logger.Warningf("node traffic sync: reconcile for %s failed: %v", n.Name, reconcileErr) logger.Warningf("node traffic sync: reconcile for %s failed: %v", n.Name, reconcileErr)
return return nil
} }
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil { if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr) logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
@@ -346,21 +384,27 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
if err != nil { if err != nil {
logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err) logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
j.inboundService.ClearNodeOnlineClients(n.Id) j.inboundService.ClearNodeOnlineClients(n.Id)
return return nil
} }
service.FilterNodeSnapshot(n, snap) service.FilterNodeSnapshot(n, snap)
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id) _, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty) changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
if err != nil { if err != nil {
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err) logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
return return nil
} }
if changed { if changed {
j.structural.set() j.structural.set()
} }
active := make([]string, 0, len(snap.OnlineEmails))
active = append(active, snap.OnlineEmails...)
for _, emails := range snap.OnlineTree {
active = append(active, emails...)
}
if !doIpSync { if !doIpSync {
return return active
} }
ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout) ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
@@ -378,7 +422,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
masterIps, err := j.inboundService.GetAllInboundClientIps() masterIps, err := j.inboundService.GetAllInboundClientIps()
if err != nil { if err != nil {
logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err) logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
return return active
} }
if len(masterIps) > 0 { if len(masterIps) > 0 {
if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil { if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
@@ -406,4 +450,5 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
} }
} }
} }
return active
} }
+48 -10
View File
@@ -20,6 +20,14 @@ type XrayTrafficJob struct {
outboundService outbound.OutboundService outboundService outbound.OutboundService
} }
// clientStatsSnapshotMaxClients caps how many client_traffics rows the job
// ships as a full websocket snapshot per poll (same spirit as the
// controller's broadcastInboundsUpdateClientLimit). Above it, a snapshot
// would blow past the hub's payload cap and be dropped wholesale, so the job
// broadcasts only this poll's active rows and the UI leans on its 5s REST
// refetch for the rest.
const clientStatsSnapshotMaxClients = 5000
// NewXrayTrafficJob creates a new traffic collection job instance. // NewXrayTrafficJob creates a new traffic collection job instance.
func NewXrayTrafficJob() *XrayTrafficJob { func NewXrayTrafficJob() *XrayTrafficJob {
return new(XrayTrafficJob) return new(XrayTrafficJob)
@@ -50,8 +58,8 @@ func (j *XrayTrafficJob) Run() {
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr) logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
} }
if restartOnDisable { if restartOnDisable {
if err := j.xrayService.RestartXray(true); err != nil { if err := j.xrayService.RestartXray(false); err != nil {
logger.Warning("restart xray after disabling clients failed:", err) logger.Warning("reconcile xray after disabling clients failed:", err)
j.xrayService.SetToNeedRestart() j.xrayService.SetToNeedRestart()
} }
} }
@@ -116,9 +124,41 @@ func (j *XrayTrafficJob) Run() {
return return
} }
lastOnlineMap, err := j.inboundService.GetClientsLastOnline() // Small installs broadcast the full snapshot (see GetAllClientTraffics for
if err != nil { // why deltas alone left UI rows stale). Above the threshold the snapshot
logger.Warning("get clients last online failed:", err) // would be dropped by the hub's payload cap anyway, so ship this poll's
// active rows instead and scope last-online to them; the initial full map
// still arrives over REST.
snapshot := true
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
logger.Warning("count client traffics for websocket failed:", countErr)
} else if total > clientStatsSnapshotMaxClients {
snapshot = false
}
var stats []*xray.ClientTraffic
var statsErr error
if snapshot {
stats, statsErr = j.inboundService.GetAllClientTraffics()
} else {
stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
}
if statsErr != nil {
logger.Warning("get client traffics for websocket failed:", statsErr)
}
var lastOnlineMap map[string]int64
if snapshot {
if lastOnlineMap, err = j.inboundService.GetClientsLastOnline(); err != nil {
logger.Warning("get clients last online failed:", err)
}
} else {
lastOnlineMap = make(map[string]int64, len(stats))
for _, ct := range stats {
if ct != nil {
lastOnlineMap[ct.Email] = ct.LastOnline
}
}
} }
if lastOnlineMap == nil { if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64) lastOnlineMap = make(map[string]int64)
@@ -136,10 +176,8 @@ func (j *XrayTrafficJob) Run() {
"lastOnlineMap": lastOnlineMap, "lastOnlineMap": lastOnlineMap,
}) })
clientStatsPayload := map[string]any{} clientStatsPayload := map[string]any{"snapshot": snapshot}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil { if len(stats) > 0 {
logger.Warning("get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload["clients"] = stats clientStatsPayload["clients"] = stats
} }
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil { if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
@@ -147,7 +185,7 @@ func (j *XrayTrafficJob) Run() {
} else if len(inboundSummary) > 0 { } else if len(inboundSummary) > 0 {
clientStatsPayload["inbounds"] = inboundSummary clientStatsPayload["inbounds"] = inboundSummary
} }
if len(clientStatsPayload) > 0 { if len(clientStatsPayload) > 1 {
websocket.BroadcastClientStats(clientStatsPayload) websocket.BroadcastClientStats(clientStatsPayload)
} }
+3
View File
@@ -834,6 +834,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
// Serialize the row cleanup against the traffic poll to avoid the // Serialize the row cleanup against the traffic poll to avoid the
// cross-transaction lock-order deadlock on client_traffics/inbounds. // cross-transaction lock-order deadlock on client_traffics/inbounds.
if err := runSerializedTx(func(tx *gorm.DB) error { if err := runSerializedTx(func(tx *gorm.DB) error {
if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
return e
}
for _, batch := range chunkInts(successIds, sqlInChunk) { for _, batch := range chunkInts(successIds, sqlInChunk) {
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil { if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
return e return e
+22 -15
View File
@@ -466,24 +466,31 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
} }
db := database.GetDB() db := database.GetDB()
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { if err := db.Transaction(func(tx *gorm.DB) error {
return needRestart, err if existing.Email != "" {
} if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil { return err
return needRestart, err }
}
if !keepTraffic && existing.Email != "" {
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
} }
if err := clearGlobalTraffic(db, existing.Email); err != nil { if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err return err
} }
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
return needRestart, err return err
} }
} if !keepTraffic && existing.Email != "" {
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil { if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := clearGlobalTraffic(tx, existing.Email); err != nil {
return err
}
if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return err
}
}
return tx.Delete(&model.ClientRecord{}, id).Error
}); err != nil {
return needRestart, err return needRestart, err
} }
return needRestart, nil return needRestart, nil
@@ -125,3 +125,105 @@ func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
t.Fatal("ResetGroupTraffic(blank) = nil, want error") t.Fatal("ResetGroupTraffic(blank) = nil, want error")
} }
} }
func TestGroupTotalsSurviveSingleClientReset(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "erin", "gold", 100, 200)
seedGroupedClient(t, "frank", "gold", 40, 60)
if err := isvc.ResetClientTrafficByEmail("erin"); err != nil {
t.Fatalf("ResetClientTrafficByEmail: %v", err)
}
g := groupByName(t, csvc, "gold")
if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 {
t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g)
}
var erin xray.ClientTraffic
if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
t.Fatalf("load erin traffic: %v", err)
}
if erin.Up != 0 || erin.Down != 0 {
t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down)
}
}
func TestGroupTotalsSurviveBulkClientReset(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "gina", "silver", 10, 20)
seedGroupedClient(t, "hank", "silver", 30, 40)
affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"})
if err != nil {
t.Fatalf("BulkResetTraffic: %v", err)
}
if affected != 2 {
t.Fatalf("BulkResetTraffic affected = %d, want 2", affected)
}
g := groupByName(t, csvc, "silver")
if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 {
t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g)
}
}
func TestGroupTotalsSurviveClientDelete(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "iris", "bronze", 70, 30)
seedGroupedClient(t, "jack", "bronze", 5, 5)
var rec model.ClientRecord
if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil {
t.Fatalf("load iris record: %v", err)
}
if _, err := csvc.Delete(isvc, rec.Id, false); err != nil {
t.Fatalf("Delete: %v", err)
}
g := groupByName(t, csvc, "bronze")
if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 {
t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g)
}
if g.ClientCount != 1 {
t.Fatalf("client count = %d, want 1", g.ClientCount)
}
var trafficRows int64
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil {
t.Fatalf("count iris traffic rows: %v", err)
}
if trafficRows != 0 {
t.Fatalf("iris traffic row survived delete")
}
}
func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "kate", "iron", 100, 100)
if err := isvc.ResetClientTrafficByEmail("kate"); err != nil {
t.Fatalf("ResetClientTrafficByEmail: %v", err)
}
if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 {
t.Fatalf("pre group-reset: got %+v, want used=200", g)
}
if err := csvc.ResetGroupTraffic("iron"); err != nil {
t.Fatalf("ResetGroupTraffic: %v", err)
}
if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 {
t.Fatalf("group reset did not zero adjusted baselines: got %+v", g)
}
}
+53
View File
@@ -8,6 +8,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"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/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
"gorm.io/gorm"
) )
type GroupSummary struct { type GroupSummary struct {
@@ -69,6 +71,57 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
return out, nil return out, nil
} }
// adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
// current counters so ListGroups totals survive a traffic reset or client delete (#5675).
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
if len(emails) == 0 {
return nil
}
type groupDelta struct {
Name string
Up int64
Down int64
}
totals := make(map[string]*groupDelta)
for _, batch := range chunkStrings(emails, sqlInChunk) {
var part []groupDelta
if err := tx.Table("clients AS c").
Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
Joins("JOIN client_traffics ct ON ct.email = c.email").
Where("c.group_name <> '' AND c.email IN ?", batch).
Group("c.group_name").
Scan(&part).Error; err != nil {
return err
}
for i := range part {
if agg, ok := totals[part[i].Name]; ok {
agg.Up += part[i].Up
agg.Down += part[i].Down
} else {
totals[part[i].Name] = &part[i]
}
}
}
for name, d := range totals {
if d.Up == 0 && d.Down == 0 {
continue
}
res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
"reset_up": gorm.Expr("reset_up - ?", d.Up),
"reset_down": gorm.Expr("reset_down - ?", d.Down),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil {
return err
}
}
}
return nil
}
func (s *ClientService) EmailsByGroup(name string) ([]string, error) { func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
@@ -528,6 +528,26 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
} }
if len(clients[0].AllowedIPs) == 0 { if len(clients[0].AllowedIPs) == 0 {
clients[0].AllowedIPs = old.AllowedIPs clients[0].AllowedIPs = old.AllowedIPs
} else {
normalized, nErr := normalizeWireguardAllowedIPs(clients[0].AllowedIPs)
if nErr != nil {
return false, nErr
}
if len(normalized) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
} else {
peers := make([]string, 0, len(oldClients))
for i := range oldClients {
if i == clientIndex {
continue
}
peers = append(peers, oldClients[i].AllowedIPs...)
}
if hit := wireguardAllowedIPsCollision(normalized, peers); hit != "" {
return false, common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
clients[0].AllowedIPs = normalized
}
} }
if clients[0].PreSharedKey == "" { if clients[0].PreSharedKey == "" {
clients[0].PreSharedKey = old.PreSharedKey clients[0].PreSharedKey = old.PreSharedKey
+31
View File
@@ -197,3 +197,34 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie
} }
return out, nil return out, nil
} }
// ListForInboundBySubId is ListForInbound narrowed to one subscription id —
// both filter columns are indexed, so the subscription server resolves a
// subscriber's clients without touching the inbound's settings JSON.
func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId string) ([]model.Client, error) {
if tx == nil {
tx = database.GetDB()
}
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ? AND clients.sub_id = ?", inboundId, subId).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]model.Client, 0, len(rows))
for i := range rows {
c := rows[i].ToClient()
c.Flow = rows[i].FlowOverride
out = append(out, *c)
}
return out, nil
}
+37
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"encoding/json"
"sync" "sync"
"time" "time"
@@ -139,3 +140,39 @@ func isClientEmailTombstoned(email string) bool {
} }
return true return true
} }
// stripTombstonedClients drops just-deleted client entries from a node
// snapshot's settings JSON so adopting a stale snapshot can't re-add them to
// the central inbound while the delete tombstone is live. Returns the filtered
// JSON and whether anything was removed.
func stripTombstonedClients(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
clients, _ := parsed["clients"].([]any)
if len(clients) == 0 {
return settings, false
}
kept := make([]any, 0, len(clients))
for _, c := range clients {
if cm, ok := c.(map[string]any); ok {
if email, _ := cm["email"].(string); email != "" && isClientEmailTombstoned(email) {
continue
}
}
kept = append(kept, c)
}
if len(kept) == len(clients) {
return settings, false
}
parsed["clients"] = kept
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
}
+3
View File
@@ -187,6 +187,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
tombstoneClientEmails(emails) tombstoneClientEmails(emails)
if err := runSerializedTx(func(tx *gorm.DB) error { if err := runSerializedTx(func(tx *gorm.DB) error {
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
return e
}
for _, batch := range chunkInts(ids, sqlInChunk) { for _, batch := range chunkInts(ids, sqlInChunk) {
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil { if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
return e return e
+7
View File
@@ -92,6 +92,9 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
db := database.GetDB() db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
return err
}
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) { for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
res := tx.Model(xray.ClientTraffic{}). res := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", batch). Where("email IN ?", batch).
@@ -150,6 +153,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
return nil return nil
} }
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
return err
}
result := tx.Model(xray.ClientTraffic{}). result := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", resetEmails). Where("email IN ?", resetEmails).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+53
View File
@@ -73,6 +73,47 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
return "", common.NewError("wireguard: no free address available in", base) return "", common.NewError("wireguard: no free address available in", base)
} }
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
// canonicalizes them: bare addresses become single-host prefixes, duplicates drop.
func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
p, err := netip.ParsePrefix(v)
if err != nil {
a, aErr := netip.ParseAddr(v)
if aErr != nil {
return nil, common.NewError("wireguard: invalid allowedIPs entry:", v)
}
p = netip.PrefixFrom(a, a.BitLen())
}
norm := p.String()
if _, dup := seen[norm]; dup {
continue
}
seen[norm] = struct{}{}
out = append(out, norm)
}
return out, nil
}
func wireguardAllowedIPsCollision(entries, used []string) string {
taken := make(map[string]struct{}, len(used))
for _, u := range used {
taken[strings.TrimSpace(u)] = struct{}{}
}
for _, e := range entries {
if _, ok := taken[e]; ok {
return e
}
}
return ""
}
// defaultWireguardClients fills in blank WireGuard credentials for newly added // defaultWireguardClients fills in blank WireGuard credentials for newly added
// clients: a generated keypair when none was provided, a derived public key when // clients: a generated keypair when none was provided, a derived public key when
// only a private key was given, and a unique tunnel address allocated from the // only a private key was given, and a unique tunnel address allocated from the
@@ -107,6 +148,18 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
return err return err
} }
c.AllowedIPs = []string{addr} c.AllowedIPs = []string{addr}
} else {
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
return err
}
if len(normalized) == 0 {
return common.NewError("wireguard: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
c.AllowedIPs = normalized
} }
used = append(used, c.AllowedIPs...) used = append(used, c.AllowedIPs...)
@@ -140,3 +140,67 @@ func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs) t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
} }
} }
func TestNormalizeWireguardAllowedIPs(t *testing.T) {
tests := []struct {
name string
in []string
want []string
err bool
}{
{name: "cidr passes through", in: []string{"10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
{name: "bare ipv4 becomes /32", in: []string{"10.0.0.5"}, want: []string{"10.0.0.5/32"}},
{name: "bare ipv6 becomes /128", in: []string{"fd00::5"}, want: []string{"fd00::5/128"}},
{name: "trims and drops empties", in: []string{" 10.0.0.5/32 ", "", " "}, want: []string{"10.0.0.5/32"}},
{name: "dedupes", in: []string{"10.0.0.5/32", "10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
{name: "routed subnet allowed", in: []string{"10.0.0.5/32", "192.168.1.0/24"}, want: []string{"10.0.0.5/32", "192.168.1.0/24"}},
{name: "garbage rejected", in: []string{"not-an-ip"}, err: true},
{name: "bad prefix rejected", in: []string{"10.0.0.5/99"}, err: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeWireguardAllowedIPs(tt.in)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %v", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
t.Fatalf("supplied allowedIPs not normalized: %v", clients[0].AllowedIPs)
}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
err := defaultWireguardClients(existing, dup, []any{map[string]any{"email": "d@wg"}})
if err == nil {
t.Fatal("duplicate allowedIPs across clients must be rejected")
}
bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
if err := defaultWireguardClients(existing, bad, []any{map[string]any{"email": "e@wg"}}); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}
+2 -2
View File
@@ -74,7 +74,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
t.Fatalf("empty globals must use the local-only predicate") t.Fatalf("empty globals must use the local-only predicate")
} }
seedClientRow(t, "local-cap", 1, 600, 600, 1000) seedClientRow(t, "local-cap", 1, 600, 600, 1000)
if _, count, _, err := svc.disableInvalidClients(db); err != nil { if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err) t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 { } else if count != 1 {
t.Fatalf("local over-quota client must be disabled, disabled %d", count) t.Fatalf("local over-quota client must be disabled, disabled %d", count)
@@ -100,7 +100,7 @@ func TestGlobalUsage_DisablesClient(t *testing.T) {
t.Fatalf("AcceptGlobalTraffic: %v", err) t.Fatalf("AcceptGlobalTraffic: %v", err)
} }
if _, count, _, err := svc.disableInvalidClients(db); err != nil { if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err) t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 { } else if count != 1 {
t.Fatalf("expected 1 client disabled, got %d", count) t.Fatalf("expected 1 client disabled, got %d", count)
+7
View File
@@ -409,6 +409,13 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
return clients, nil return clients, nil
} }
// GetClientsBySubId returns the inbound's clients with the given subscription
// id, resolved from the normalized clients tables (the same source the running
// Xray users are built from) instead of parsing the settings JSON blob.
func (s *InboundService) GetClientsBySubId(inboundId int, subId string) ([]model.Client, error) {
return s.clientService.ListForInboundBySubId(nil, inboundId, subId)
}
func (s *InboundService) GetAllEmails() ([]string, error) { func (s *InboundService) GetAllEmails() ([]string, error) {
db := database.GetDB() db := database.GetDB()
var emails []string var emails []string
@@ -79,6 +79,14 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
now := time.Now().Unix() now := time.Now().Unix()
cutoff := now - clientIpStaleAfterSeconds cutoff := now - clientIpStaleAfterSeconds
// Node syncs run concurrently (one goroutine per node) and shared clients
// appear in several nodes' reports. Locking rows in each node's arbitrary
// report order lets two merges grab the same rows in opposite order, which
// Postgres aborts as a deadlock (40P01) — take them in one global order.
sort.Slice(incomingIps, func(i, j int) bool {
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
})
tx := db.Begin() tx := db.Begin()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@@ -157,3 +157,77 @@ func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(t *testing.T)
t.Errorf("inbound settings expiry not converted: %#v", cs) t.Errorf("inbound settings expiry not converted: %#v", cs)
} }
} }
// TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients locks in that the
// delayed-start persistence pass touches only clients adjustTraffics actually
// converted this poll: the delayed client's negative expiry becomes an absolute
// deadline while an already-absolute expiry passes through byte-identical.
// Before the fix every polled row got its own no-op expiry UPDATE.
func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
const delayedEmail = "delayed-mixed-user"
const normalEmail = "normal-mixed-user"
const delayedUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d002"
const normalUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d003"
const sevenDays = int64(7 * 86400000)
normalExpiry := time.Now().AddDate(0, 1, 0).UnixMilli()
clients := []model.Client{
{Email: delayedEmail, ID: delayedUID, Enable: true, ExpiryTime: -sevenDays},
{Email: normalEmail, ID: normalUID, Enable: true, ExpiryTime: normalExpiry},
}
inbound := &model.Inbound{
Tag: "vless-mixed", Enable: true, Port: 45002, Protocol: model.VLESS,
Settings: clientsSettings(t, clients),
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
svc := InboundService{}
if err := svc.clientService.SyncInbound(db, inbound.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: delayedEmail, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
t.Fatalf("create delayed traffic row: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: normalEmail, Enable: true, ExpiryTime: normalExpiry}).Error; err != nil {
t.Fatalf("create normal traffic row: %v", err)
}
before := time.Now().UnixMilli()
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
{Email: delayedEmail, Up: 10, Down: 20},
{Email: normalEmail, Up: 30, Down: 40},
})
if err != nil {
t.Fatalf("addClientTraffic: %v", err)
}
var delayed xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", delayedEmail).First(&delayed).Error; err != nil {
t.Fatalf("reload delayed row: %v", err)
}
if delayed.ExpiryTime < before+sevenDays-5000 || delayed.ExpiryTime > before+sevenDays+5000 {
t.Errorf("delayed expiry = %d, want ~now+7d (%d)", delayed.ExpiryTime, before+sevenDays)
}
var normal xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", normalEmail).First(&normal).Error; err != nil {
t.Fatalf("reload normal row: %v", err)
}
if normal.ExpiryTime != normalExpiry {
t.Errorf("normal expiry changed: %d, want %d", normal.ExpiryTime, normalExpiry)
}
if normal.Up != 30 || normal.Down != 40 {
t.Errorf("normal traffic not applied: up=%d down=%d, want 30/40", normal.Up, normal.Down)
}
}
+27 -24
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -80,7 +81,7 @@ func depletedCond(tx *gorm.DB) string {
return depletedClientsCondLocal return depletedClientsCondLocal
} }
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) { func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
now := time.Now().Unix() * 1000 now := time.Now().Unix() * 1000
needRestart := false needRestart := false
cond := depletedCond(tx) cond := depletedCond(tx)
@@ -90,10 +91,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
Where(cond+" AND enable = ?", now, true). Where(cond+" AND enable = ?", now, true).
Find(&depletedRows).Error Find(&depletedRows).Error
if err != nil { if err != nil {
return false, 0, nil, err return false, 0, err
} }
if len(depletedRows) == 0 { if len(depletedRows) == 0 {
return false, 0, nil, nil return false, 0, nil
} }
depletedEmails := make([]string, 0, len(depletedRows)) depletedEmails := make([]string, 0, len(depletedRows))
@@ -121,7 +122,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
WHERE clients.email IN ? WHERE clients.email IN ?
`, depletedEmails).Scan(&targets).Error `, depletedEmails).Scan(&targets).Error
if err != nil { if err != nil {
return false, 0, nil, err return false, 0, err
} }
} }
@@ -162,13 +163,23 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
} }
} }
result := tx.Model(xray.ClientTraffic{}). // Flip the rows already collected above by primary key instead of
Where(cond+" AND enable = ?", now, true). // re-evaluating the depleted predicate, which was a second full scan of
Update("enable", false) // client_traffics on every poll. Sorted ids keep the lock order stable.
err = result.Error ids := make([]int, 0, len(depletedRows))
count := result.RowsAffected for i := range depletedRows {
if err != nil { ids = append(ids, depletedRows[i].Id)
return needRestart, count, nil, err }
slices.Sort(ids)
var count int64
for _, batch := range chunkInts(ids, sqlInChunk) {
result := tx.Model(xray.ClientTraffic{}).
Where("id IN ? AND enable = ?", batch, true).
Update("enable", false)
if result.Error != nil {
return needRestart, count, result.Error
}
count += result.RowsAffected
} }
if len(depletedEmails) > 0 { if len(depletedEmails) > 0 {
@@ -179,7 +190,6 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
} }
} }
disabledNodeIDs := make(map[int]struct{})
for inboundID, group := range remoteByInbound { for inboundID, group := range remoteByInbound {
emails := make(map[string]struct{}, len(group)) emails := make(map[string]struct{}, len(group))
for _, t := range group { for _, t := range group {
@@ -188,21 +198,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil { if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr) logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
needRestart = true needRestart = true
} else {
for _, t := range group {
if t.NodeID != nil {
disabledNodeIDs[*t.NodeID] = struct{}{}
}
}
} }
} }
nodeIDs := make([]int, 0, len(disabledNodeIDs)) return needRestart, count, nil
for nodeID := range disabledNodeIDs {
nodeIDs = append(nodeIDs, nodeID)
}
return needRestart, count, nodeIDs, nil
} }
// markClientsDisabledInSettings flips client.enable=false in the inbound's // markClientsDisabledInSettings flips client.enable=false in the inbound's
@@ -255,6 +254,10 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
return &snapshot, &ib, nil return &snapshot, &ib, nil
} }
// disableRemoteClients flips the clients off in the inbound's stored settings
// and pushes the updated inbound to its node, which applies it to its own
// running Xray. That push is the whole reconcile — restarting the node's Xray
// afterwards would drop every live connection on the node for nothing (#5740).
func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error { func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error {
oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails) oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
if err != nil { if err != nil {
+35 -25
View File
@@ -191,6 +191,17 @@ func mergeActivationExpiry(existing, node int64) int64 {
return node return node
} }
// liftActivatedClientRecordExpiries copies a node-activated deadline from
// client_traffics onto client records still holding the negative duration (#5714).
func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
return tx.Exec(
`UPDATE clients
SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1)
WHERE clients.expiry_time < 0
AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`,
).Error
}
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) { func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
var structuralChange bool var structuralChange bool
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
@@ -475,6 +486,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
// Adopting the node's settings verbatim would re-add a client the master
// deleted moments ago if this snapshot was fetched before the deletion
// push landed — filter just-deleted emails out while their tombstone lives.
adoptedSettings := snapIb.Settings
if stripped, changed := stripTombstonedClients(adoptedSettings); changed {
adoptedSettings = stripped
}
updates := map[string]any{} updates := map[string]any{}
if !dirty { if !dirty {
updates["enable"] = snapIb.Enable updates["enable"] = snapIb.Enable
@@ -485,7 +504,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["protocol"] = snapIb.Protocol updates["protocol"] = snapIb.Protocol
updates["total"] = snapIb.Total updates["total"] = snapIb.Total
updates["expiry_time"] = snapIb.ExpiryTime updates["expiry_time"] = snapIb.ExpiryTime
updates["settings"] = snapIb.Settings updates["settings"] = adoptedSettings
updates["stream_settings"] = snapIb.StreamSettings updates["stream_settings"] = snapIb.StreamSettings
updates["sniffing"] = snapIb.Sniffing updates["sniffing"] = snapIb.Sniffing
updates["traffic_reset"] = snapIb.TrafficReset updates["traffic_reset"] = snapIb.TrafficReset
@@ -502,7 +521,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["origin_node_guid"] = og updates["origin_node_guid"] = og
} }
if !dirty && (c.Settings != snapIb.Settings || if !dirty && (c.Settings != adoptedSettings ||
c.Remark != snapIb.Remark || c.Remark != snapIb.Remark ||
c.Listen != snapIb.Listen || c.Listen != snapIb.Listen ||
c.Port != snapIb.Port || c.Port != snapIb.Port ||
@@ -623,8 +642,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if dirty { if dirty {
continue continue
} }
_, isNewInbound := newInboundIDs[c.Id]
// On a known inbound a missing row plus a live tombstone means the
// master just deleted this client and the snapshot predates the
// deletion push — recreating the row (at zero) would resurrect the
// client. A freshly adopted inbound still gets its row (seeded at
// zero) so adoption semantics stay intact.
if !isNewInbound && isClientEmailTombstoned(cs.Email) {
continue
}
var seedUp, seedDown int64 var seedUp, seedDown int64
if _, isNewInbound := newInboundIDs[c.Id]; isNewInbound && !isClientEmailTombstoned(cs.Email) { if isNewInbound && !isClientEmailTombstoned(cs.Email) {
seedUp, seedDown = canon.Up, canon.Down seedUp, seedDown = canon.Up, canon.Down
} }
row := &xray.ClientTraffic{ row := &xray.ClientTraffic{
@@ -847,6 +875,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
} }
if err := liftActivatedClientRecordExpiries(tx); err != nil {
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
}
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return false, err return false, err
} }
@@ -880,28 +912,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
return structuralChange, nil return structuralChange, nil
} }
func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
if err != nil {
logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
return
}
if !restartOnDisable {
return
}
for _, nodeID := range nodeIDs {
nodeIDCopy := nodeID
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
if rtErr != nil {
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
continue
}
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
}
}
}
func (s *InboundService) GetOnlineClients() []string { func (s *InboundService) GetOnlineClients() []string {
if p == nil { if p == nil {
return []string{} return []string{}
+12 -4
View File
@@ -64,6 +64,16 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
existingByEmail[existing[i].Email] = &existing[i] existingByEmail[existing[i].Email] = &existing[i]
} }
// Deterministic row order keeps concurrent guid merges from deadlocking on
// Postgres (40P01) — same discipline as MergeInboundClientIps.
emails := make([]string, 0, len(perEmail))
for email := range perEmail {
if email != "" {
emails = append(emails, email)
}
}
sort.Strings(emails)
tx := db.Begin() tx := db.Begin()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@@ -71,10 +81,8 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
} }
}() }()
for email, incoming := range perEmail { for _, email := range emails {
if email == "" { incoming := perEmail[email]
continue
}
var old []model.ClientIpEntry var old []model.ClientIpEntry
if cur, ok := existingByEmail[email]; ok && cur.Ips != "" { if cur, ok := existingByEmail[email]; ok && cur.Ips != "" {
_ = json.Unmarshal([]byte(cur.Ips), &old) _ = json.Unmarshal([]byte(cur.Ips), &old)
+63 -41
View File
@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"slices"
"strings" "strings"
"time" "time"
@@ -19,19 +21,15 @@ import (
) )
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) { func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
var disabledNodeIDs []int
err = submitTrafficWrite(func() error { err = submitTrafficWrite(func() error {
var inner error var inner error
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics) needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
return inner return inner
}) })
if err == nil && len(disabledNodeIDs) > 0 {
s.restartRemoteNodesOnDisable(disabledNodeIDs)
}
return return
} }
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) { func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
var err error var err error
db := database.GetDB() db := database.GetDB()
tx := db.Begin() tx := db.Begin()
@@ -45,11 +43,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
}() }()
err = s.addInboundTraffic(tx, inboundTraffics) err = s.addInboundTraffic(tx, inboundTraffics)
if err != nil { if err != nil {
return false, false, nil, err return false, false, err
} }
err = s.addClientTraffic(tx, clientTraffics) err = s.addClientTraffic(tx, clientTraffics)
if err != nil { if err != nil {
return false, false, nil, err return false, false, err
} }
needRestart0, count, err := s.autoRenewClients(tx) needRestart0, count, err := s.autoRenewClients(tx)
@@ -60,7 +58,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
} }
disabledClientsCount := int64(0) disabledClientsCount := int64(0)
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx) needRestart1, count, err := s.disableInvalidClients(tx)
if err != nil { if err != nil {
logger.Warning("Error in disabling invalid clients:", err) logger.Warning("Error in disabling invalid clients:", err)
} else if count > 0 { } else if count > 0 {
@@ -74,7 +72,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
} else if count > 0 { } else if count > 0 {
logger.Debugf("%v inbounds disabled", count) logger.Debugf("%v inbounds disabled", count)
} }
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
} }
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error { func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -129,7 +127,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
return nil return nil
} }
dbClientTraffics, err = s.adjustTraffics(tx, dbClientTraffics) dbClientTraffics, convertedExpiryByEmail, err := s.adjustTraffics(tx, dbClientTraffics)
if err != nil { if err != nil {
return err return err
} }
@@ -165,22 +163,22 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
// adjustTraffics converts delayed-start rows (negative ExpiryTime → absolute // adjustTraffics converts delayed-start rows (negative ExpiryTime → absolute
// deadline) in-memory. Persist that conversion now since the traffic UPDATE // deadline) in-memory. Persist that conversion now since the traffic UPDATE
// above only touches up/down/last_online. // above only touches up/down/last_online. Only converted emails are written:
for _, ct := range dbClientTraffics { // updating every polled row issued one no-op UPDATE per active client per
if ct.ExpiryTime > 0 { // poll. Sorted order keeps concurrent writers lock-compatible on Postgres.
if err = tx.Exec( for _, email := range slices.Sorted(maps.Keys(convertedExpiryByEmail)) {
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`, if err = tx.Exec(
ct.ExpiryTime, ct.Email, `UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
).Error; err != nil { convertedExpiryByEmail[email], email,
logger.Warning("AddClientTraffic update expiry_time ", err) ).Error; err != nil {
} logger.Warning("AddClientTraffic update expiry_time ", err)
} }
} }
return nil return nil
} }
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) { func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, map[string]int64, error) {
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
// "Start After First Use" stores a negative expiry (the duration). On the // "Start After First Use" stores a negative expiry (the duration). On the
@@ -194,7 +192,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
} }
} }
if len(newExpiryByEmail) == 0 { if len(newExpiryByEmail) == 0 {
return dbClientTraffics, nil return dbClientTraffics, nil, nil
} }
delayedEmails := make([]string, 0, len(newExpiryByEmail)) delayedEmails := make([]string, 0, len(newExpiryByEmail))
@@ -212,16 +210,16 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
Distinct(). Distinct().
Pluck("client_inbounds.inbound_id", &inboundIds).Error Pluck("client_inbounds.inbound_id", &inboundIds).Error
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
if len(inboundIds) == 0 { if len(inboundIds) == 0 {
return dbClientTraffics, nil return dbClientTraffics, nil, nil
} }
var inbounds []*model.Inbound var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
for inbound_index := range inbounds { for inbound_index := range inbounds {
settings := map[string]any{} settings := map[string]any{}
@@ -247,7 +245,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
settings["clients"] = newClients settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ") modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
inbounds[inbound_index].Settings = string(modifiedSettings) inbounds[inbound_index].Settings = string(modifiedSettings)
@@ -280,7 +278,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
} }
} }
return dbClientTraffics, nil return dbClientTraffics, newExpiryByEmail, nil
} }
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) { func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
@@ -474,6 +472,9 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
} }
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error { func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{email}); err != nil {
return err
}
if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil { if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
return err return err
} }
@@ -484,6 +485,9 @@ func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
} }
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error { func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, emails); err != nil {
return err
}
const chunk = 400 const chunk = 400
for start := 0; start < len(emails); start += chunk { for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails)) end := min(start+chunk, len(emails))
@@ -503,16 +507,20 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
return submitTrafficWrite(func() error { return submitTrafficWrite(func() error {
db := database.GetDB() return database.GetDB().Transaction(func(tx *gorm.DB) error {
if err := clearGlobalTraffic(db, clientEmail); err != nil { if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err return err
} }
if err := db.Model(xray.ClientTraffic{}). if err := clearGlobalTraffic(tx, clientEmail); err != nil {
Where("email = ?", clientEmail). return err
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { }
return err if err := tx.Model(xray.ClientTraffic{}).
} Where("email = ?", clientEmail).
return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
return err
}
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
})
}) })
} }
@@ -596,6 +604,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
return false, err return false, err
} }
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err
}
if err := tx.Save(traffic).Error; err != nil { if err := tx.Save(traffic).Error; err != nil {
return err return err
} }
@@ -917,14 +928,18 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
} }
traffics = append(traffics, page...) traffics = append(traffics, page...)
} }
overlayGlobalTraffic(db, traffics)
return traffics, nil return traffics, nil
} }
// GetAllClientTraffics returns the full set of client_traffics rows so the // GetAllClientTraffics returns the full set of client_traffics rows so the
// websocket broadcasters can ship a complete snapshot every cycle. The old // websocket broadcasters can ship a complete snapshot every cycle. A pure
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped // delta path silently dropped the per-client section whenever no client moved
// the per-client section whenever no client moved bytes in the cycle or a // bytes in the cycle or a node sync failed, leaving client rows in the UI
// node sync failed, leaving client rows in the UI stuck at stale numbers. // stuck at stale numbers — so small installs broadcast this snapshot, and only
// above the traffic job's snapshot threshold (where the marshaled snapshot
// would exceed the hub's payload cap and be dropped wholesale) does the job
// fall back to active-row deltas.
func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) { func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
db := database.GetDB() db := database.GetDB()
var traffics []*xray.ClientTraffic var traffics []*xray.ClientTraffic
@@ -935,6 +950,13 @@ func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
return traffics, nil return traffics, nil
} }
func (s *InboundService) CountClientTraffics() (int64, error) {
db := database.GetDB()
var count int64
err := db.Model(xray.ClientTraffic{}).Count(&count).Error
return count, err
}
type InboundTrafficSummary struct { type InboundTrafficSummary struct {
Id int `json:"id"` Id int `json:"id"`
Up int64 `json:"up"` Up int64 `json:"up"`
@@ -3,6 +3,7 @@ package service
import ( import (
"testing" "testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mhsanaei/3x-ui/v3/internal/xray"
) )
@@ -139,3 +140,44 @@ func TestNodeRenewExtendsExpiry(t *testing.T) {
t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed) t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
} }
} }
// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
// the deadline (positive ClientStats) while its settings JSON still carries the
// negative duration, so SyncInbound keeps writing the stale value into the
// client record and the Clients page shows "not started" forever.
func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000)
const activated = int64(1798448344010)
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
t.Fatalf("seed client record: %v", err)
}
readRecordExpiry := func() int64 {
t.Helper()
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("read client record: %v", err)
}
return rec.ExpiryTime
}
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
if got := readRecordExpiry(); got != duration {
t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
}
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
}
if got := readRecordExpiry(); got != activated {
t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
}
}
@@ -3,6 +3,7 @@ package service
import ( import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"gorm.io/gorm" "gorm.io/gorm"
@@ -135,6 +136,47 @@ func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) {
assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count") assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count")
} }
// TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot
// fetched just before a client's deletion still names the email. Applying it
// must neither recreate the client_traffics row (at zero) nor re-add the
// client to the central inbound's settings while the delete tombstone lives.
func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739")
svc := &InboundService{}
const email = "victim-5739"
withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in").
Update("settings", `{"clients": []}`).Error; err != nil {
t.Fatalf("clear central settings: %v", err)
}
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
t.Fatalf("delete stats row: %v", err)
}
tombstoneClientEmail(email)
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true})
var rows int64
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil {
t.Fatalf("count stats rows: %v", err)
}
if rows != 0 {
t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows)
}
var ib model.Inbound
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
if strings.Contains(ib.Settings, email) {
t.Errorf("deleted client re-added to central settings: %s", ib.Settings)
}
}
func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) { func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) {
db := initTrafficTestDB(t) db := initTrafficTestDB(t)
svc := &InboundService{} svc := &InboundService{}
+170 -11
View File
@@ -13,6 +13,7 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -44,10 +45,64 @@ const (
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the // devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
// newest main commit; the dev update channel installs from it. // newest main commit; the dev update channel installs from it.
devReleaseTag = "dev-latest" devReleaseTag = "dev-latest"
updateStatePending = "pending"
updateStateSuccess = "success"
updateStateFailed = "failed"
) )
// PanelUpdateStatus reports the outcome of the most recently launched panel
// self-update. RunID lets the caller confirm this status belongs to the
// update it started rather than a stale result left over from an earlier
// run; State is one of "pending", "success", or "failed". RunID is a decimal
// string, not a JSON number: it's a formatted UnixNano timestamp, and
// JavaScript's number type can't represent that precisely (it exceeds
// Number.MAX_SAFE_INTEGER), which would let two different runs round to the
// same value on the wire and defeat the whole point of this field.
type PanelUpdateStatus struct {
RunID string `json:"runId" example:"1735689600123456789"`
State string `json:"state" example:"success"`
ExitCode int `json:"exitCode" example:"0"`
FinishedAt int64 `json:"finishedAt" example:"1735689612"`
}
var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`) var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
// updateMu guards updateRunning/updateStarted/updateRunID/updatePID, which
// stop a second self-update from launching while one is still in flight (two
// concurrent update.sh runs would race each other extracting the release
// tarball and swapping the service unit). A slot is released as soon as the
// in-flight run's own status file reports success or failure -- checked
// against updateRunID so a stale file from an even earlier run can't be
// mistaken for this one finishing -- so a fast failure doesn't lock out a
// retry.
//
// For a run that never reaches a terminal state at all, staleness is judged
// primarily by whether the process we actually launched is still alive
// (updatePID, via processAlive), not by wall-clock time alone: update.sh
// runs install_base() (a package-manager update+install) before anything
// else, plus several downloads, which can legitimately run past a short
// fixed timeout on a slow or throttled host without anything being wrong.
// updateStaleAfter/updatePID together are only a fallback for the systemd-run
// launch path, where the process we can observe (systemd-run itself) has
// already exited by the time startUpdate returns and the actual update.sh
// unit's PID is never recorded -- for that path this is still a pure
// wall-clock heuristic. updateHardCeiling is an absolute backstop so a
// genuinely wedged run (alive but hung forever) can never lock out retries
// permanently, even on the PID-tracked path.
var (
updateMu sync.Mutex
updateRunning bool
updateStarted time.Time
updateRunID int64
updatePID int
)
const (
updateStaleAfter = 20 * time.Minute
updateHardCeiling = 2 * time.Hour
)
func (s *PanelService) RestartPanel(delay time.Duration) error { func (s *PanelService) RestartPanel(delay time.Duration) error {
go func() { go func() {
time.Sleep(delay) time.Sleep(delay)
@@ -122,39 +177,77 @@ func getDevUpdateInfo() (*PanelUpdateInfo, error) {
}, nil }, nil
} }
// StartUpdate starts the official updater using this panel's own channel setting. // StartUpdate starts the official updater using this panel's own channel
func (s *PanelService) StartUpdate() error { // setting. Returns the run ID to pass to GetUpdateStatus so the caller can
// tell this run's result apart from a stale one.
func (s *PanelService) StartUpdate() (int64, error) {
return s.startUpdate(devChannelActive()) return s.startUpdate(devChannelActive())
} }
// StartUpdateChannel runs the updater against an explicitly chosen channel, // StartUpdateChannel runs the updater against an explicitly chosen channel,
// overriding the local dev-channel setting. Used by the master node updater so // overriding the local dev-channel setting. Used by the master node updater so
// a node can be moved to the dev channel from the central panel. // a node can be moved to the dev channel from the central panel.
func (s *PanelService) StartUpdateChannel(dev bool) error { func (s *PanelService) StartUpdateChannel(dev bool) (int64, error) {
return s.startUpdate(dev) return s.startUpdate(dev)
} }
func (s *PanelService) startUpdate(useDev bool) error { // GetUpdateStatus reports the outcome of the most recently launched panel
// self-update, as recorded by update.sh's EXIT trap (see the script for why
// that covers every exit path, not just the happy one). This is a best-effort
// side channel: a missing or unreadable status file reads as "pending"
// rather than an error, since the update itself is what matters, not this
// status file.
func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus {
data, err := os.ReadFile(config.GetUpdateStatusFilePath())
if err != nil {
return &PanelUpdateStatus{State: updateStatePending}
}
var status PanelUpdateStatus
if err := json.Unmarshal(data, &status); err != nil {
return &PanelUpdateStatus{State: updateStatePending}
}
if status.State != updateStateSuccess && status.State != updateStateFailed {
status.State = updateStatePending
}
return &status
}
func (s *PanelService) startUpdate(useDev bool) (int64, error) {
runID := time.Now().UnixNano()
if !acquireUpdateSlot(runID) {
return 0, fmt.Errorf("a panel update is already in progress")
}
launched := false
defer func() {
if !launched {
releaseUpdateSlot()
}
}()
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
return fmt.Errorf("panel web update is supported only on Linux installations") return 0, fmt.Errorf("panel web update is supported only on Linux installations")
} }
bash, err := exec.LookPath("bash") bash, err := exec.LookPath("bash")
if err != nil { if err != nil {
return fmt.Errorf("bash is required to run the panel updater: %w", err) return 0, fmt.Errorf("bash is required to run the panel updater: %w", err)
} }
scriptPath, err := downloadPanelUpdater() scriptPath, err := downloadPanelUpdater()
if err != nil { if err != nil {
return err return 0, err
} }
statusFile := config.GetUpdateStatusFilePath()
mainFolder, serviceFolder := resolveUpdateFolders() mainFolder, serviceFolder := resolveUpdateFolders()
updateTag := "" updateTag := ""
if useDev { if useDev {
updateTag = devReleaseTag updateTag = devReleaseTag
} }
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath)) updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
runIDEnv := "XUI_UPDATE_RUN_ID=" + strconv.FormatInt(runID, 10)
statusFileEnv := "XUI_UPDATE_STATUS_FILE=" + statusFile
if systemdRun, err := exec.LookPath("systemd-run"); err == nil { if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix()) unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
@@ -163,6 +256,8 @@ func (s *PanelService) startUpdate(useDev bool) error {
"--setenv", "XUI_MAIN_FOLDER="+mainFolder, "--setenv", "XUI_MAIN_FOLDER="+mainFolder,
"--setenv", "XUI_SERVICE="+serviceFolder, "--setenv", "XUI_SERVICE="+serviceFolder,
"--setenv", "XUI_UPDATE_TAG="+updateTag, "--setenv", "XUI_UPDATE_TAG="+updateTag,
"--setenv", runIDEnv,
"--setenv", statusFileEnv,
bash, "-lc", updateScript, bash, "-lc", updateScript,
) )
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
@@ -171,12 +266,13 @@ func (s *PanelService) startUpdate(useDev bool) error {
if !strings.Contains(output, "System has not been booted with systemd") && if !strings.Contains(output, "System has not been booted with systemd") &&
!strings.Contains(output, "Failed to connect to bus") { !strings.Contains(output, "Failed to connect to bus") {
_ = os.Remove(scriptPath) _ = os.Remove(scriptPath)
return fmt.Errorf("failed to start panel update job: %w: %s", err, output) return 0, fmt.Errorf("failed to start panel update job: %w: %s", err, output)
} }
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output) logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
} else { } else {
logger.Infof("started panel update job via systemd-run unit %s", unitName) logger.Infof("started panel update job via systemd-run unit %s", unitName)
return nil launched = true
return runID, nil
} }
} }
@@ -185,17 +281,77 @@ func (s *PanelService) startUpdate(useDev bool) error {
"XUI_MAIN_FOLDER="+mainFolder, "XUI_MAIN_FOLDER="+mainFolder,
"XUI_SERVICE="+serviceFolder, "XUI_SERVICE="+serviceFolder,
"XUI_UPDATE_TAG="+updateTag, "XUI_UPDATE_TAG="+updateTag,
runIDEnv,
statusFileEnv,
) )
setDetachedProcess(cmd) setDetachedProcess(cmd)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
_ = os.Remove(scriptPath) _ = os.Remove(scriptPath)
return fmt.Errorf("failed to start panel update job: %w", err) return 0, fmt.Errorf("failed to start panel update job: %w", err)
} }
if err := cmd.Process.Release(); err != nil { if err := cmd.Process.Release(); err != nil {
logger.Warning("failed to release panel update process:", err) logger.Warning("failed to release panel update process:", err)
} }
logger.Infof("started panel update job with pid %d", cmd.Process.Pid) logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
return nil recordUpdatePID(cmd.Process.Pid)
launched = true
return runID, nil
}
// acquireUpdateSlot claims the single in-flight-update slot for runID. It
// refuses while another run is genuinely still in flight, but grants the
// slot immediately once that run's own status file reports a terminal
// result (success or failure) -- a fast failure shouldn't force the next
// attempt to wait out updateStaleAfter for no reason. Past updateStaleAfter
// with no terminal status yet, it grants the slot anyway UNLESS the process
// we recorded (updatePID) is confirmed still alive, so a merely-slow run
// isn't mistaken for a crashed one; past updateHardCeiling it grants the
// slot unconditionally regardless of liveness, so a truly wedged run can
// never lock out retries forever.
func acquireUpdateSlot(runID int64) bool {
updateMu.Lock()
defer updateMu.Unlock()
if updateRunning && !previousRunIsTerminal() {
elapsed := time.Since(updateStarted)
if elapsed < updateHardCeiling {
stale := elapsed >= updateStaleAfter
alive := updatePID > 0 && processAlive(updatePID)
if !stale || alive {
return false
}
}
}
updateRunning = true
updateStarted = time.Now()
updateRunID = runID
updatePID = 0
return true
}
// recordUpdatePID notes the PID of the detached update.sh process the
// current slot is tracking, so a later acquireUpdateSlot call can check
// whether it is actually still running instead of only how long ago it
// started. Only reachable for the detached-fallback launch path -- the
// systemd-run path never learns update.sh's own PID, since the process it
// directly observes (systemd-run) has already exited by the time it returns.
func recordUpdatePID(pid int) {
updateMu.Lock()
updatePID = pid
updateMu.Unlock()
}
// previousRunIsTerminal reports whether the run currently recorded in
// updateRunID has reached success or failure per its status file. Must be
// called with updateMu held.
func previousRunIsTerminal() bool {
status := (&PanelService{}).GetUpdateStatus()
return status.RunID == strconv.FormatInt(updateRunID, 10) && status.State != updateStatePending
}
func releaseUpdateSlot() {
updateMu.Lock()
updateRunning = false
updateMu.Unlock()
} }
func downloadPanelUpdater() (string, error) { func downloadPanelUpdater() (string, error) {
@@ -230,6 +386,9 @@ func downloadPanelUpdater() (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("write panel updater: %w", err) return "", fmt.Errorf("write panel updater: %w", err)
} }
if n == 0 {
return "", fmt.Errorf("panel updater download is empty")
}
if n > maxPanelUpdaterBytes { if n > maxPanelUpdaterBytes {
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes) return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
} }
@@ -5,3 +5,10 @@ package panel
import "os/exec" import "os/exec"
func setDetachedProcess(cmd *exec.Cmd) {} func setDetachedProcess(cmd *exec.Cmd) {}
// processAlive is never meaningfully consulted outside Linux: startUpdate
// itself is gated to runtime.GOOS == "linux" before any process is ever
// launched, so no real PID is ever recorded on this platform.
func processAlive(pid int) bool {
return false
}
+211
View File
@@ -1,8 +1,15 @@
package panel package panel
import ( import (
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"testing" "testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service"
) )
@@ -107,3 +114,207 @@ func TestShortCommit(t *testing.T) {
t.Fatalf("shortCommit short input = %q, want %q", got, "abc") t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
} }
} }
func resetUpdateSlot(t *testing.T) {
t.Helper()
t.Cleanup(func() {
updateMu.Lock()
updateRunning = false
updateRunID = 0
updatePID = 0
updateMu.Unlock()
})
}
// writeStatusFile hand-writes the status file in the exact wire format
// update.sh itself produces (a bare printf, not Go's json.Marshal), since
// that's the real cross-language contract this package reads in production.
func writeStatusFile(t *testing.T, path string, runID int64, state string) {
t.Helper()
body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix())
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAcquireUpdateSlot(t *testing.T) {
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
if acquireUpdateSlot(2) {
t.Fatal("second acquire while first is held: got true, want false")
}
releaseUpdateSlot()
if !acquireUpdateSlot(3) {
t.Fatal("acquire after release: got false, want true")
}
releaseUpdateSlot()
}
func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) {
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
updateMu.Lock()
updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
updateMu.Unlock()
if !acquireUpdateSlot(2) {
t.Fatal("acquire after stale window elapsed: got false, want true")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression
// test for the concurrency bug an upstream review found: past
// updateStaleAfter, the old logic freed the slot purely on elapsed time, even
// if the process it launched was still genuinely running (not crashed) --
// update.sh's own package-manager step plus several downloads can plausibly
// run long on a slow host with nothing actually wrong. Now a confirmed-alive
// PID keeps the slot held past the stale window.
func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
}
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive
updateMu.Lock()
updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
updateMu.Unlock()
if acquireUpdateSlot(2) {
t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute
// backstop: even a confirmed-alive process can't hold the slot forever, so a
// genuinely wedged run can't lock out retries permanently.
func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
}
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
recordUpdatePID(os.Getpid())
updateMu.Lock()
updateStarted = time.Now().Add(-(updateHardCeiling + time.Second))
updateMu.Unlock()
if !acquireUpdateSlot(2) {
t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the
// bug adversarial review found: a fast failure used to still lock out retries
// for the full updateStaleAfter window, because acquireUpdateSlot only looked
// at the in-memory started-at timestamp, never at the status file's own
// terminal state.
func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
resetUpdateSlot(t)
path := config.GetUpdateStatusFilePath()
if !acquireUpdateSlot(111) {
t.Fatal("first acquire: got false, want true")
}
writeStatusFile(t, path, 111, updateStateFailed)
if !acquireUpdateSlot(222) {
t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state
// check is scoped to the run it actually launched: a status file left behind
// by some earlier, unrelated run (different runID) must not be mistaken for
// this run finishing.
func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
resetUpdateSlot(t)
path := config.GetUpdateStatusFilePath()
writeStatusFile(t, path, 999, updateStateSuccess)
if !acquireUpdateSlot(111) {
t.Fatal("first acquire: got false, want true")
}
if acquireUpdateSlot(222) {
t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotConcurrency proves the check-then-set is actually
// atomic under real concurrent access, not just correct when called
// sequentially. A prior version of this test suite only ever called
// acquireUpdateSlot from a single goroutine, so it gave no signal if the
// mutex's core promise (only one concurrent launch wins) were broken.
func TestAcquireUpdateSlotConcurrency(t *testing.T) {
resetUpdateSlot(t)
const attempts = 200
var wins atomic.Int32
var wg sync.WaitGroup
wg.Add(attempts)
for i := range attempts {
go func(runID int64) {
defer wg.Done()
if acquireUpdateSlot(runID) {
wins.Add(1)
}
}(int64(i))
}
wg.Wait()
if got := wins.Load(); got != 1 {
t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts)
}
releaseUpdateSlot()
}
func TestGetUpdateStatus(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
path := config.GetUpdateStatusFilePath()
svc := &PanelService{}
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending)
}
writeStatusFile(t, path, 1735689600123456789, updateStateSuccess)
got := svc.GetUpdateStatus()
if got.RunID != "1735689600123456789" {
t.Fatalf("RunID = %q, want %q (must round-trip as a decimal string, not a JSON number, or it loses precision past 2^53 in JS)", got.RunID, "1735689600123456789")
}
if got.State != updateStateSuccess {
t.Fatalf("State = %q, want %q", got.State, updateStateSuccess)
}
if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending)
}
writeStatusFile(t, path, 1, "some-unrecognized-state")
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending)
}
}
+14
View File
@@ -10,3 +10,17 @@ import (
func setDetachedProcess(cmd *exec.Cmd) { func setDetachedProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
} }
// processAlive reports whether pid is still a live process, via the standard
// POSIX kill(pid, 0) liveness check: it sends no actual signal, only checking
// whether the target exists and is signalable. ESRCH means the process is
// gone; any other result (including a permission error, which can only mean
// the PID exists and belongs to someone) is treated as alive, since this is
// used to decide whether it is safe to let a second update start.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || err == syscall.EPERM
}
+150 -3
View File
@@ -1,22 +1,28 @@
package service package service
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/op/go-logging" "github.com/op/go-logging"
"gorm.io/gorm" "gorm.io/gorm"
) )
// setupScaleDB initializes the DB for a scale benchmark on either Postgres // setupScaleDB initializes the DB for a scale benchmark on either Postgres
// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file), // (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file;
// and registers cleanup. Skips the test when neither backend is configured. // XUI_SCALE_DB_PATH persists the DB for manual smoke runs), and registers
// cleanup. Skips the test when neither backend is configured.
func setupScaleDB(t *testing.T) { func setupScaleDB(t *testing.T) {
t.Helper() t.Helper()
xuilogger.InitLogger(logging.ERROR) xuilogger.InitLogger(logging.ERROR)
@@ -31,7 +37,10 @@ func setupScaleDB(t *testing.T) {
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) { switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
case "1", "true", "yes": case "1", "true", "yes":
dbPath := filepath.Join(t.TempDir(), "scale.db") dbPath := strings.TrimSpace(os.Getenv("XUI_SCALE_DB_PATH"))
if dbPath == "" {
dbPath = filepath.Join(t.TempDir(), "scale.db")
}
if err := database.InitDB(dbPath); err != nil { if err := database.InitDB(dbPath); err != nil {
t.Fatalf("InitDB(sqlite): %v", err) t.Fatalf("InitDB(sqlite): %v", err)
} }
@@ -42,6 +51,144 @@ func setupScaleDB(t *testing.T) {
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark") t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
} }
// scaleSizes returns the default size ladder unless XUI_SCALE_SIZES overrides
// it with a comma-separated list (e.g. "500000" or "10000,100000,500000").
func scaleSizes(t *testing.T, def ...int) []int {
t.Helper()
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
if raw == "" {
return def
}
var out []int
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil || n <= 0 {
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
}
out = append(out, n)
}
if len(out) == 0 {
return def
}
return out
}
type scaleDataset struct {
inboundIds []int
tags []string
emails []string
perInbound [][]model.Client
}
// seedScaleDataset seeds n healthy clients (future expiry, unfilled quota)
// spread across numInbounds inbounds, writing inbounds, clients,
// client_inbounds and client_traffics directly in one transaction — orders of
// magnitude faster than SyncInbound and one fsync instead of thousands.
func seedScaleDataset(t *testing.T, n, numInbounds int) scaleDataset {
t.Helper()
db := database.GetDB()
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ds := scaleDataset{emails: emailsOf(clients)}
start := time.Now()
tx := db.Begin()
if tx.Error != nil {
t.Fatalf("begin seed tx: %v", tx.Error)
}
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
per := n / numInbounds
for i := range numInbounds {
lo, hi := i*per, (i+1)*per
if i == numInbounds-1 {
hi = n
}
chunk := clients[lo:hi]
ib := &model.Inbound{
UserId: 1,
Tag: fmt.Sprintf("scale-%d-%d", n, i),
Enable: true,
Port: 41000 + i,
Protocol: model.VLESS,
Settings: clientsSettings(t, chunk),
}
if err := tx.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %d: %v", i, err)
}
records := make([]*model.ClientRecord, len(chunk))
for j := range chunk {
records[j] = chunk[j].ToRecord()
}
if err := tx.CreateInBatches(records, 500).Error; err != nil {
t.Fatalf("seed clients %d: %v", i, err)
}
links := make([]model.ClientInbound, len(records))
for j := range records {
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
}
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
t.Fatalf("seed client_inbounds %d: %v", i, err)
}
traffics := make([]xray.ClientTraffic, len(chunk))
for j := range chunk {
traffics[j] = xray.ClientTraffic{
InboundId: ib.Id,
Email: chunk[j].Email,
Enable: true,
Total: chunk[j].TotalGB,
ExpiryTime: chunk[j].ExpiryTime,
}
}
if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
t.Fatalf("seed client_traffics %d: %v", i, err)
}
ds.inboundIds = append(ds.inboundIds, ib.Id)
ds.tags = append(ds.tags, ib.Tag)
ds.perInbound = append(ds.perInbound, chunk)
}
if err := tx.Commit().Error; err != nil {
t.Fatalf("commit seed tx: %v", err)
}
committed = true
db.Exec("ANALYZE")
t.Logf("seeded N=%d across %d inbound(s) in %v", n, numInbounds, time.Since(start).Round(time.Millisecond))
return ds
}
// sampleEmails picks k evenly spaced emails so active clients span the id range.
func sampleEmails(emails []string, k int) []string {
if k >= len(emails) {
return emails
}
out := make([]string, 0, k)
step := len(emails) / k
for i := 0; i < len(emails) && len(out) < k; i += step {
out = append(out, emails[i])
}
return out
}
// resetScaleTables empties the given tables between sub-sizes. Postgres uses a // resetScaleTables empties the given tables between sub-sizes. Postgres uses a
// single TRUNCATE ... CASCADE; SQLite deletes per table and clears the // single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
// autoincrement counters so ids restart like RESTART IDENTITY. // autoincrement counters so ids restart like RESTART IDENTITY.
+25 -8
View File
@@ -710,11 +710,13 @@ func (s *SettingService) GetSubTitle() (string, error) {
} }
func (s *SettingService) GetSubSupportUrl() (string, error) { func (s *SettingService) GetSubSupportUrl() (string, error) {
return s.getString("subSupportUrl") value, err := s.getString("subSupportUrl")
return common.EnsureURLScheme(value), err
} }
func (s *SettingService) GetSubProfileUrl() (string, error) { func (s *SettingService) GetSubProfileUrl() (string, error) {
return s.getString("subProfileUrl") value, err := s.getString("subProfileUrl")
return common.EnsureURLScheme(value), err
} }
func (s *SettingService) GetSubAnnounce() (string, error) { func (s *SettingService) GetSubAnnounce() (string, error) {
@@ -1083,8 +1085,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
return s.setInt("smtpMemory", value) return s.setInt("smtpMemory", value)
} }
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { // SecretClears marks redacted secrets the user explicitly emptied. Without a
if err := s.preserveRedactedSecrets(allSetting); err != nil { // flag, a blank submitted secret means "unchanged" (the field is always served
// blank to the browser) and the stored value is preserved.
type SecretClears struct {
TgBotToken bool
LdapPassword bool
SmtpPassword bool
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
return err return err
} }
if err := validateSettingsURLs(allSetting); err != nil { if err := validateSettingsURLs(allSetting); err != nil {
@@ -1130,15 +1141,15 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
}) })
} }
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error { func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
if strings.TrimSpace(allSetting.TgBotToken) == "" { if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
value, err := s.GetTgBotToken() value, err := s.GetTgBotToken()
if err != nil { if err != nil {
return err return err
} }
allSetting.TgBotToken = value allSetting.TgBotToken = value
} }
if strings.TrimSpace(allSetting.LdapPassword) == "" { if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
value, err := s.GetLdapPassword() value, err := s.GetLdapPassword()
if err != nil { if err != nil {
return err return err
@@ -1152,7 +1163,7 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
} }
allSetting.TwoFactorToken = value allSetting.TwoFactorToken = value
} }
if strings.TrimSpace(allSetting.SmtpPassword) == "" { if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
value, err := s.GetSmtpPassword() value, err := s.GetSmtpPassword()
if err != nil { if err != nil {
return err return err
@@ -1177,6 +1188,12 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
} }
allSetting.TgBotAPIServer = u allSetting.TgBotAPIServer = u
} }
// Support/profile links land in subscription headers and page data, where
// client apps resolve a scheme-less value against the panel's own domain.
// Non-http schemes (tg://, mailto:) are legitimate here, so only default
// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
return nil return nil
} }
+49 -1
View File
@@ -77,7 +77,7 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
settings := &view.AllSetting settings := &view.AllSetting
if err := s.UpdateAllSetting(settings); err != nil { if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, _ := s.GetTgBotToken(); got != "telegram-secret" { if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
@@ -94,6 +94,54 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
} }
} }
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
t.Fatal(err)
}
view, err := s.GetAllSettingView()
if err != nil {
t.Fatal(err)
}
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
t.Fatal(err)
}
if got, _ := s.GetSmtpPassword(); got != "" {
t.Fatalf("smtp password = %q, want cleared", got)
}
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
}
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
}
view, err = s.GetAllSettingView()
if err != nil {
t.Fatal(err)
}
if view.HasSmtpPassword {
t.Fatal("hasSmtpPassword must report false after clearing")
}
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTgBotToken(); got != "" {
t.Fatalf("tg token = %q, want cleared", got)
}
if got, _ := s.GetLdapPassword(); got != "" {
t.Fatalf("ldap password = %q, want cleared", got)
}
}
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil { if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
t.Fatal("expected localhost URL to be blocked") t.Fatal("expected localhost URL to be blocked")
@@ -0,0 +1,106 @@
package service
import (
"fmt"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func pollReport(ds scaleDataset, k int) ([]*xray.Traffic, []*xray.ClientTraffic) {
traffics := make([]*xray.Traffic, 0, len(ds.tags))
for _, tag := range ds.tags {
traffics = append(traffics, &xray.Traffic{IsInbound: true, Tag: tag, Up: 1 << 20, Down: 2 << 20})
}
clientTraffics := make([]*xray.ClientTraffic, 0, k)
for _, email := range sampleEmails(ds.emails, k) {
clientTraffics = append(clientTraffics, &xray.ClientTraffic{Email: email, Up: 1 << 20, Down: 2 << 20})
}
return traffics, clientTraffics
}
// TestAddTrafficPollScale measures one full traffic-poll cycle (the @every 5s
// job): per-client delta UPDATEs, the auto-renew probe and the depleted-client
// scan, in steady state and with 100 clients depleting / renewing.
func TestAddTrafficPollScale(t *testing.T) {
setupScaleDB(t)
svc := &InboundService{}
shapes := []struct {
name string
inbounds int
}{{"single", 1}, {"spread50", 50}}
for _, n := range scaleSizes(t, 10000, 100000) {
for _, shape := range shapes {
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
db := database.GetDB()
ds := seedScaleDataset(t, n, shape.inbounds)
for _, k := range []int{1000, 10000} {
if k > n {
continue
}
traffics, clientTraffics := pollReport(ds, k)
const reps = 3
start := time.Now()
for range reps {
if _, _, err := svc.AddTraffic(traffics, clientTraffics); err != nil {
t.Fatalf("AddTraffic steady: %v", err)
}
}
perPoll := time.Since(start) / reps
t.Logf("N=%-7d shape=%-8s K=%-6d steady=%v/poll", n, shape.name, k, perPoll.Round(time.Millisecond))
var probe xray.ClientTraffic
if err := db.Where("email = ?", clientTraffics[0].Email).First(&probe).Error; err != nil {
t.Fatalf("load probe row: %v", err)
}
if probe.Up == 0 || probe.Down == 0 {
t.Fatalf("steady polls did not accumulate traffic: up=%d down=%d", probe.Up, probe.Down)
}
}
depleted := ds.perInbound[0][:100]
if err := db.Model(&xray.ClientTraffic{}).
Where("email IN ?", emailsOf(depleted)).
Updates(map[string]any{"up": int64(100 << 30), "down": int64(0)}).Error; err != nil {
t.Fatalf("mark depleted: %v", err)
}
start := time.Now()
if _, _, err := svc.AddTraffic(nil, nil); err != nil {
t.Fatalf("AddTraffic disable: %v", err)
}
t.Logf("N=%-7d shape=%-8s disable100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
var disabledCount int64
if err := db.Model(&xray.ClientTraffic{}).Where("enable = ?", false).Count(&disabledCount).Error; err != nil {
t.Fatalf("count disabled: %v", err)
}
if disabledCount != 100 {
t.Fatalf("disable100: got %d disabled rows, want 100", disabledCount)
}
renew := ds.perInbound[0][100:200]
past := time.Now().Add(-time.Hour).UnixMilli()
if err := db.Model(&xray.ClientTraffic{}).
Where("email IN ?", emailsOf(renew)).
Updates(map[string]any{"reset": 30, "expiry_time": past, "up": int64(1 << 30)}).Error; err != nil {
t.Fatalf("mark renewable: %v", err)
}
start = time.Now()
if _, _, err := svc.AddTraffic(nil, nil); err != nil {
t.Fatalf("AddTraffic renew: %v", err)
}
t.Logf("N=%-7d shape=%-8s renew100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
var renewed xray.ClientTraffic
if err := db.Where("email = ?", renew[0].Email).First(&renewed).Error; err != nil {
t.Fatalf("load renewed row: %v", err)
}
if renewed.ExpiryTime <= past || renewed.Up != 0 {
t.Fatalf("renew100 did not renew: expiry=%d up=%d", renewed.ExpiryTime, renewed.Up)
}
})
}
}
}
@@ -0,0 +1,93 @@
package service
import (
"encoding/json"
"fmt"
"testing"
"time"
)
// TestWsPayloadScale compares the websocket broadcast payload paths the
// traffic job runs every 5s while a browser is connected: the full snapshot
// (GetAllClientTraffics + full last-online map) against the delta variant
// (GetActiveClientTraffics on this poll's active emails). Payload sizes are
// logged against the hub's 10MB drop threshold.
func TestWsPayloadScale(t *testing.T) {
setupScaleDB(t)
svc := &InboundService{}
const hubPayloadLimit = 10 * 1024 * 1024
for _, n := range scaleSizes(t, 10000, 100000) {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
ds := seedScaleDataset(t, n, 1)
k := min(10000, n)
activeEmails := sampleEmails(ds.emails, k)
start := time.Now()
all, err := svc.GetAllClientTraffics()
if err != nil {
t.Fatalf("GetAllClientTraffics: %v", err)
}
fetchAll := time.Since(start)
if len(all) != n {
t.Fatalf("GetAllClientTraffics rows = %d, want %d", len(all), n)
}
start = time.Now()
snapshot, err := json.Marshal(map[string]any{"clients": all})
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
marshalAll := time.Since(start)
verdict := "delivered"
if len(snapshot) > hubPayloadLimit {
verdict = "DROPPED by hub (>10MB)"
}
t.Logf("N=%-7d snapshot: fetch=%-9v marshal=%-9v payload=%.1fMB %s",
n, fetchAll.Round(time.Millisecond), marshalAll.Round(time.Millisecond),
float64(len(snapshot))/(1<<20), verdict)
start = time.Now()
active, err := svc.GetActiveClientTraffics(activeEmails)
if err != nil {
t.Fatalf("GetActiveClientTraffics: %v", err)
}
fetchActive := time.Since(start)
if len(active) != k {
t.Fatalf("GetActiveClientTraffics rows = %d, want %d", len(active), k)
}
start = time.Now()
delta, err := json.Marshal(map[string]any{"clients": active})
if err != nil {
t.Fatalf("marshal delta: %v", err)
}
marshalActive := time.Since(start)
t.Logf("N=%-7d delta(K=%d): fetch=%-9v marshal=%-9v payload=%.1fMB",
n, k, fetchActive.Round(time.Millisecond), marshalActive.Round(time.Millisecond),
float64(len(delta))/(1<<20))
start = time.Now()
lastOnline, err := svc.GetClientsLastOnline()
if err != nil {
t.Fatalf("GetClientsLastOnline: %v", err)
}
fullMap := time.Since(start)
if len(lastOnline) != n {
t.Fatalf("GetClientsLastOnline entries = %d, want %d", len(lastOnline), n)
}
start = time.Now()
activeLastOnline := make(map[string]int64, len(active))
for _, ct := range active {
activeLastOnline[ct.Email] = ct.LastOnline
}
activeMap := time.Since(start)
t.Logf("N=%-7d lastOnline: fullMap=%-9v activeMap(K=%d)=%v",
n, fullMap.Round(time.Millisecond), k, activeMap.Round(time.Microsecond))
start = time.Now()
if _, err := svc.GetInboundsTrafficSummary(); err != nil {
t.Fatalf("GetInboundsTrafficSummary: %v", err)
}
t.Logf("N=%-7d inboundsSummary=%v", n, time.Since(start).Round(time.Millisecond))
})
}
}
+25
View File
@@ -1004,6 +1004,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
// Removals first so changed handlers and port swaps never collide with // Removals first so changed handlers and port swaps never collide with
// the additions that follow. // the additions that follow.
for _, u := range diff.RemovedUsers {
if err := hotAPI.RemoveUser(u.Tag, u.Email); err != nil && !xray.IsMissingHandlerErr(err) {
logger.Info("hot apply: remove user [", u.Email, "] from [", u.Tag, "] failed:", err)
return false
}
}
for _, tag := range diff.RemovedInboundTags { for _, tag := range diff.RemovedInboundTags {
if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) { if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
logger.Info("hot apply: remove inbound [", tag, "] failed:", err) logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
@@ -1028,6 +1034,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
return false return false
} }
} }
for _, u := range diff.AddedUsers {
if err := addUserReconciling(&hotAPI, u); err != nil {
logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
return false
}
}
if diff.RoutingConfig != nil { if diff.RoutingConfig != nil {
if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil { if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
logger.Info("hot apply: apply routing config failed:", err) logger.Info("hot apply: apply routing config failed:", err)
@@ -1039,6 +1051,19 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
return true return true
} }
// addUserReconciling adds a user, and on an email conflict (the user was
// already applied through the runtime API) replaces the existing user instead.
func addUserReconciling(api *xray.XrayAPI, u xray.UserOp) error {
err := api.AddUser(u.Protocol, u.Tag, u.User)
if err == nil || !xray.IsUserExistsErr(err) {
return err
}
if delErr := api.RemoveUser(u.Tag, u.Email); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
return delErr
}
return api.AddUser(u.Protocol, u.Tag, u.User)
}
// addInboundReconciling adds an inbound, and on a tag conflict (the handler // addInboundReconciling adds an inbound, and on a tag conflict (the handler
// was already created through the runtime API while the stored snapshot was // was already created through the runtime API while the stored snapshot was
// stale) replaces the existing handler instead. // stale) replaces the existing handler instead.
@@ -0,0 +1,66 @@
package service
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestGetXrayConfigScale measures building the full Xray config (the path
// every restart/reconcile takes) and, separately, how much of the per-inbound
// settings rebuild is spent on indented vs plain JSON marshaling.
func TestGetXrayConfigScale(t *testing.T) {
setupScaleDB(t)
svc := &XrayService{}
shapes := []struct {
name string
inbounds int
}{{"single", 1}, {"spread50", 50}}
for _, n := range scaleSizes(t, 10000, 100000) {
for _, shape := range shapes {
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
ds := seedScaleDataset(t, n, shape.inbounds)
const reps = 3
start := time.Now()
for range reps {
cfg, err := svc.GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
if len(cfg.InboundConfigs) < shape.inbounds {
t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
}
}
t.Logf("N=%-7d shape=%-8s GetXrayConfig=%v/run",
n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
var ib model.Inbound
if err := database.GetDB().First(&ib, ds.inboundIds[0]).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
settings := map[string]any{}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
start = time.Now()
if _, err := json.Marshal(settings); err != nil {
t.Fatalf("marshal settings: %v", err)
}
plain := time.Since(start)
start = time.Now()
if _, err := json.MarshalIndent(settings, "", " "); err != nil {
t.Fatalf("marshal indent settings: %v", err)
}
indented := time.Since(start)
t.Logf("N=%-7d shape=%-8s settingsMarshal plain=%-9v indent=%v",
n, shape.name, plain.Round(time.Millisecond), indented.Round(time.Millisecond))
})
}
}
}

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