Compare commits

..

24 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
98 changed files with 4830 additions and 804 deletions
+3
View File
@@ -3,6 +3,9 @@
Operational guide for AI agents working in this repo. Long-form human docs:
`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
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
- 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",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"antd": "^6.5.0",
"axios": "^1.18.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.3",
"i18next": "^26.3.4",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.18.0",
"recharts": "^3.9.0",
"react-router-dom": "^7.18.1",
"recharts": "^3.9.1",
"swagger-ui-react": "^5.32.8",
"zod": "^4.4.3"
},
@@ -44,8 +45,8 @@
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "8.1.0",
"typescript-eslint": "^8.62.1",
"vite": "8.1.3",
"vitest": "^4.1.9"
},
"engines": {
@@ -1105,9 +1106,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
"version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"dev": true,
"license": "MIT",
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
"cpu": [
"arm64"
],
@@ -1871,9 +1862,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
"cpu": [
"arm64"
],
@@ -1888,9 +1879,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
"cpu": [
"x64"
],
@@ -1905,9 +1896,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
"cpu": [
"x64"
],
@@ -1922,9 +1913,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
"cpu": [
"arm"
],
@@ -1939,9 +1930,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
"cpu": [
"arm64"
],
@@ -1959,9 +1950,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
"cpu": [
"arm64"
],
@@ -1979,9 +1970,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
"cpu": [
"ppc64"
],
@@ -1999,9 +1990,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
"cpu": [
"s390x"
],
@@ -2019,9 +2010,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
"cpu": [
"x64"
],
@@ -2039,9 +2030,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
"cpu": [
"x64"
],
@@ -2059,9 +2050,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
"cpu": [
"arm64"
],
@@ -2076,9 +2067,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
"cpu": [
"wasm32"
],
@@ -2095,9 +2086,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
"cpu": [
"arm64"
],
@@ -2112,9 +2103,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
"cpu": [
"x64"
],
@@ -3108,17 +3099,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
"integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
"integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.62.0",
"@typescript-eslint/type-utils": "8.62.0",
"@typescript-eslint/utils": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/type-utils": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -3131,7 +3122,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/parser": "^8.62.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -3147,16 +3138,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz",
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.0",
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3172,14 +3163,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
"integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
"integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.0",
"@typescript-eslint/types": "^8.62.0",
"@typescript-eslint/tsconfig-utils": "^8.62.1",
"@typescript-eslint/types": "^8.62.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3194,14 +3185,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
"integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0"
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3212,9 +3203,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
"integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
"integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3229,15 +3220,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
"integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
"integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0",
"@typescript-eslint/utils": "8.62.0",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -3254,9 +3245,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3268,16 +3259,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
"integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
"integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.62.0",
"@typescript-eslint/tsconfig-utils": "8.62.0",
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0",
"@typescript-eslint/project-service": "8.62.1",
"@typescript-eslint/tsconfig-utils": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -3309,16 +3300,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
"integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.0",
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0"
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3333,13 +3324,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
"integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/types": "8.62.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3918,9 +3909,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
"version": "2.10.41",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4058,9 +4049,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"version": "1.0.30001800",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
"dev": true,
"funding": [
{
@@ -4644,9 +4635,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.381",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
"version": "1.5.384",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
"dev": true,
"license": "ISC"
},
@@ -4777,9 +4768,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
"dev": true,
"license": "MIT"
},
@@ -5700,9 +5691,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
"integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
"version": "26.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"funding": [
{
"type": "individual",
@@ -5758,9 +5749,9 @@
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"version": "11.1.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
"integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -7690,9 +7681,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -7712,12 +7703,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.0"
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
@@ -7748,9 +7739,9 @@
}
},
"node_modules/recharts": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz",
"integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==",
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz",
"integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==",
"license": "MIT",
"workspaces": [
"www"
@@ -7761,7 +7752,7 @@
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
@@ -7927,13 +7918,13 @@
}
},
"node_modules/rolldown": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.137.0",
"@oxc-project/types": "=0.138.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -7943,21 +7934,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.3",
"@rolldown/binding-darwin-arm64": "1.1.3",
"@rolldown/binding-darwin-x64": "1.1.3",
"@rolldown/binding-freebsd-x64": "1.1.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
"@rolldown/binding-linux-arm64-musl": "1.1.3",
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
"@rolldown/binding-linux-x64-gnu": "1.1.3",
"@rolldown/binding-linux-x64-musl": "1.1.3",
"@rolldown/binding-openharmony-arm64": "1.1.3",
"@rolldown/binding-wasm32-wasi": "1.1.3",
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
"@rolldown/binding-win32-x64-msvc": "1.1.3"
"@rolldown/binding-android-arm64": "1.1.4",
"@rolldown/binding-darwin-arm64": "1.1.4",
"@rolldown/binding-darwin-x64": "1.1.4",
"@rolldown/binding-freebsd-x64": "1.1.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
"@rolldown/binding-linux-arm64-musl": "1.1.4",
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
"@rolldown/binding-linux-x64-gnu": "1.1.4",
"@rolldown/binding-linux-x64-musl": "1.1.4",
"@rolldown/binding-openharmony-arm64": "1.1.4",
"@rolldown/binding-wasm32-wasi": "1.1.4",
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
"@rolldown/binding-win32-x64-msvc": "1.1.4"
}
},
"node_modules/safe-array-concat": {
@@ -8830,16 +8821,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
"integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
"integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0",
"@typescript-eslint/utils": "8.62.0"
"@typescript-eslint/eslint-plugin": "8.62.1",
"@typescript-eslint/parser": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8971,16 +8962,16 @@
}
},
"node_modules/vite": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "~1.1.2",
"postcss": "^8.5.16",
"rolldown": "~1.1.3",
"tinyglobby": "^0.2.17"
},
"bin": {
+6 -5
View File
@@ -24,21 +24,22 @@
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"antd": "^6.5.0",
"axios": "^1.18.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.3",
"i18next": "^26.3.4",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.18.0",
"recharts": "^3.9.0",
"react-router-dom": "^7.18.1",
"recharts": "^3.9.1",
"swagger-ui-react": "^5.32.8",
"zod": "^4.4.3"
},
@@ -57,8 +58,8 @@
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "8.1.0",
"typescript-eslint": "^8.62.1",
"vite": "8.1.3",
"vitest": "^4.1.9"
},
"overrides": {
+75
View File
@@ -2147,6 +2147,34 @@
],
"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": {
"properties": {
"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": {
"get": {
"tags": [
@@ -4427,6 +4496,12 @@
},
"obj": {}
}
},
"example": {
"success": true,
"obj": {
"runId": "1735689600123456789"
}
}
}
}
+6
View File
@@ -468,6 +468,12 @@ export const EXAMPLES: Record<string, unknown> = {
"total": 0,
"up": 0
},
"PanelUpdateStatus": {
"exitCode": 0,
"finishedAt": 1735689612,
"runId": "1735689600123456789",
"state": "success"
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
+28
View File
@@ -2121,6 +2121,34 @@ export const SCHEMAS: Record<string, unknown> = {
],
"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": {
"properties": {
"cpuPct": {
+7
View File
@@ -464,6 +464,13 @@ export interface OutboundTraffics {
up: number;
}
export interface PanelUpdateStatus {
exitCode: number;
finishedAt: number;
runId: string;
state: string;
}
export interface ProbeResultUI {
cpuPct: number;
error: string;
+8
View File
@@ -495,6 +495,14 @@ export const OutboundTrafficsSchema = z.object({
});
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({
cpuPct: z.number(),
error: z.string(),
+2 -2
View File
@@ -551,9 +551,9 @@ export function useClients() {
const applyClientStatsEvent = useCallback((payload: unknown) => {
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;
setAllClientStats(p.clients);
if (p.snapshot !== false) setAllClientStats(p.clients);
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
+20 -5
View File
@@ -13,6 +13,7 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -322,6 +323,7 @@ export interface GenVlessLinkInput {
forceTls?: ForceTls;
remark?: string;
clientId: string;
clientKey?: string;
flow?: VlessClient['flow'];
externalProxy?: ExternalProxyEntry | null;
}
@@ -350,6 +352,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
forceTls = 'same',
remark = '',
clientId,
clientKey = '',
flow = '',
externalProxy = null,
} = input;
@@ -430,7 +433,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (sni && sni.length > 0) params.set('sni', sni);
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);
}
} else {
@@ -512,7 +516,7 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
// Reality query-string writer shared by VLESS and Trojan. Preserves the
// 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;
const reality = stream.realitySettings;
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 (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);
}
@@ -537,6 +542,7 @@ export interface GenTrojanLinkInput {
forceTls?: ForceTls;
remark?: string;
clientPassword: string;
clientKey?: string;
externalProxy?: ExternalProxyEntry | null;
}
@@ -551,6 +557,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
forceTls = 'same',
remark = '',
clientPassword,
clientKey = '',
externalProxy = null,
} = input;
@@ -571,7 +578,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
params.set('security', 'reality');
writeRealityParams(stream, params);
writeRealityParams(stream, params, clientKey);
} else {
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
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
// 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 {
switch (inbound.protocol) {
@@ -1066,6 +1079,7 @@ export function genLink(input: GenLinkInput): string {
return genVlessLink({
inbound, address, port, forceTls, remark,
clientId: client.id ?? '',
clientKey: clientSubKey(client),
flow: client.flow,
externalProxy,
});
@@ -1081,6 +1095,7 @@ export function genLink(input: GenLinkInput): string {
return genTrojanLink({
inbound, address, port, forceTls, remark,
clientPassword: client.password ?? '',
clientKey: clientSubKey(client),
externalProxy,
});
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)}`;
}
+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;
hasNordSecret = false;
hasSmtpPassword = false;
clearTgBotToken = false;
clearLdapPassword = false;
clearSmtpPassword = false;
constructor(data?: unknown) {
if (data != null) {
+7
View File
@@ -323,6 +323,12 @@ export const sections: readonly Section[] = [
path: '/panel/api/server/getPanelUpdateInfo',
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',
path: '/panel/api/server/getConfigJson',
@@ -407,6 +413,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/server/updatePanel',
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',
@@ -41,6 +41,7 @@ import { Protocols } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
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 { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
@@ -248,6 +249,7 @@ export default function InboundFormModal({
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
randomizeSpiderX,
getNewEchCert,
clearEchCert,
pinFromCert,
@@ -316,27 +318,14 @@ export default function InboundFormModal({
form.setFieldValue(['settings', 'encryption'], 'none');
};
const vlessAuthKind = vlessEncryptionAuthKind(
typeof vlessEncryption === 'string' ? vlessEncryption : '',
);
const selectedVlessAuth = (() => {
const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
if (!enc || enc === 'none') return 'None';
const parts = enc.split('.').filter(Boolean);
const authKey = parts[parts.length - 1] || '';
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');
if (!vlessAuthKind) return t('pages.inbounds.vlessAuthCustom');
return t(VLESS_AUTH_LABEL_KEYS[vlessAuthKind]);
})();
useEffect(() => {
@@ -702,7 +691,7 @@ export default function InboundFormModal({
{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}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
@@ -896,6 +885,7 @@ export default function InboundFormModal({
scanRealityCandidates={scanRealityCandidates}
applyRealityScanResult={applyRealityScanResult}
randomizeShortIds={randomizeShortIds}
randomizeSpiderX={randomizeSpiderX}
genRealityKeypair={genRealityKeypair}
clearRealityKeypair={clearRealityKeypair}
genMldsa65={genMldsa65}
@@ -1,18 +1,13 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
type VlessAuthKind =
| 'x25519'
| 'x25519_xorpub'
| 'x25519_random'
| 'mlkem768'
| 'mlkem768_xorpub'
| 'mlkem768_random';
import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
interface VlessFieldsProps {
saving: boolean;
selectedVlessAuth: string;
vlessAuthKind: VlessAuthKind | null;
network: string;
security: string;
getNewVlessEnc: (kind: VlessAuthKind) => void;
@@ -22,22 +17,22 @@ interface VlessFieldsProps {
export default function VlessFields({
saving,
selectedVlessAuth,
vlessAuthKind,
network,
security,
getNewVlessEnc,
clearVlessEnc,
}: VlessFieldsProps) {
const { t } = useTranslation();
const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519');
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
const authOptions = [
{ value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') },
{ value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') },
{ value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
{ value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') },
{ value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') },
{ value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') },
];
useEffect(() => {
setAuthKind(vlessAuthKind ?? 'x25519');
}, [vlessAuthKind]);
const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map(
([value, labelKey]) => ({ value, label: t(labelKey) }),
);
return (
<>
@@ -16,6 +16,7 @@ interface RealityFormProps {
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
applyRealityScanResult: (result: RealityScanResult) => void;
randomizeShortIds: () => void;
randomizeSpiderX: () => void;
genRealityKeypair: () => void;
clearRealityKeypair: () => void;
genMldsa65: () => void;
@@ -30,6 +31,7 @@ export default function RealityForm({
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
randomizeSpiderX,
genRealityKeypair,
clearRealityKeypair,
genMldsa65,
@@ -147,10 +149,18 @@ export default function RealityForm({
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', '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
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 sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
setSaving(true);
@@ -270,6 +277,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
}
form.setFieldValue('streamSettings', cleaned);
if (next === 'reality') {
randomizeSpiderX();
try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
@@ -292,6 +300,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
randomizeSpiderX,
getNewEchCert,
clearEchCert,
pinFromCert,
+25 -8
View File
@@ -6,8 +6,11 @@ import axios from 'axios';
import { HttpUtil, PromiseUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import type { PanelUpdateStatus } from '@/generated/types';
import './PanelUpdateModal.css';
type UpdateOutcome = 'success' | 'failed' | 'timeout';
export interface PanelUpdateInfo {
channel?: string;
currentVersion: string;
@@ -45,19 +48,23 @@ export default function PanelUpdateModal({
const isDev = info.channel === 'dev';
async function pollUntilBack(): Promise<boolean> {
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
try {
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
if (r?.data?.success) return true;
const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 });
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 {
/* still restarting */
}
await PromiseUtil.sleep(2000);
}
return false;
return 'timeout';
}
async function handleChannel(checked: boolean) {
@@ -81,14 +88,24 @@ export default function PanelUpdateModal({
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
onClose();
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) {
onBusy({ busy: false });
return;
}
const back = await pollUntilBack();
if (back) await PromiseUtil.sleep(800);
window.location.reload();
const outcome = await pollUpdateStatus(result.obj?.runId ?? '');
onBusy({ busy: false });
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 { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface EmailTabProps {
allSetting: AllSetting;
@@ -72,10 +73,13 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
<Input.Password value={allSetting.smtpPassword}
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
<SecretInput value={allSetting.smtpPassword}
configured={allSetting.hasSmtpPassword}
clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
</SettingListItem>
<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 { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
import SecretInput from './SecretInput';
interface ApiMsg<T = unknown> {
success?: boolean;
@@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem
paddings="small"
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}
placeholder={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordPlaceholder') : ''}
onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
configured={allSetting.hasLdapPassword}
clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/>
</SettingListItem>
<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} />;
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
}, [activeSlug, allSetting, updateSetting]);
}, [activeSlug, allSetting, updateSetting, savePayload]);
return (
<ConfigProvider theme={antdThemeConfig}>
+8 -4
View File
@@ -9,6 +9,7 @@ import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface TelegramTabProps {
allSetting: AllSetting;
@@ -193,12 +194,15 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<SettingListItem
paddings="small"
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}
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
configured={allSetting.hasTgBotToken}
clearArmed={allSetting.clearTgBotToken}
placeholder={t('pages.settings.telegramTokenPlaceholder')}
onChange={(v) => updateSetting({ tgBotToken: v })}
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
/>
</SettingListItem>
@@ -367,7 +367,6 @@ export default function BalancersTab({
<ObservatorySettingsTab
templateSettings={templateSettings}
mutate={mutate}
isMobile={isMobile}
/>
),
},
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
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 {
@@ -13,11 +13,11 @@ import {
type PingConfigObject,
} from '@/schemas/observatory';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { settingsRequireBurstObservatory } from './balancer-helpers';
interface ObservatorySettingsTabProps {
templateSettings: XraySettingsValue | null;
mutate: (mutator: (next: XraySettingsValue) => void) => void;
isMobile: boolean;
}
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
@@ -43,7 +43,6 @@ function SelectorTags({ tags }: { tags: string[] }) {
export default function ObservatorySettingsTab({
templateSettings,
mutate,
isMobile,
}: ObservatorySettingsTabProps) {
const { t } = useTranslation();
@@ -63,13 +62,10 @@ export default function ObservatorySettingsTab({
const hasObservatory = observatory != null;
const hasBurst = burst != null;
const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory');
const effectiveView = !hasObservatory && hasBurst
const hasMixedObservers = hasObservatory && hasBurst;
const activeView = hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
? 'burstObservatory'
: !hasBurst && hasObservatory
? 'observatory'
: view;
: 'observatory';
function patchObservatory(patch: Partial<ObservatoryObject>) {
mutate((tt) => {
@@ -220,19 +216,15 @@ export default function ObservatorySettingsTab({
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} />
{hasObservatory && hasBurst && (
<Segmented
block={isMobile}
value={effectiveView}
onChange={(v) => setView(v as 'observatory' | 'burstObservatory')}
options={[
{ label: t('pages.xray.observatory.title'), value: 'observatory' },
{ label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
]}
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
{hasMixedObservers && (
<Alert
type="warning"
showIcon
title={t('pages.xray.observatory.mixedLegacy')}
/>
)}
<div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
</Space>
);
}
@@ -26,6 +26,16 @@ export function collectSelectors(list: BalancerObject[]): string[] {
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
// balancer strategies that actually require them. Observatories have no runtime
// 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
// calls RequireFeatures(Observatory) and the core aborts startup with "not all
// dependencies are resolved" if none exists; without a fallbackTag it never even
// consults an observatory. leastLoad always needs the burst observer, leastPing
// the regular one.
// consults an observatory. leastLoad needs the burst observer, while leastPing
// 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
// dropped the moment nothing does — clearing the last fallbackTag (or deleting
// the last leastLoad) removes the burst observer again. A no-fallback balancer's
// selector is still probed while the observer exists for another reason, but
// never keeps it alive on its own.
// the last leastLoad) removes the burst observer again. A no-fallback
// Random/RoundRobin balancer never expands the observer either, because those
// strategies do not consume observer data.
export function syncObservatories(t: XraySettingsValue) {
const balancers = (t.routing?.balancers || []) as BalancerObject[];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
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;
}
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);
});
const required = balancers.filter(balancerRequiresBurstObservatory);
if (required.length > 0) {
delete t.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 {
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 > 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"`;
@@ -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[`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"`;
@@ -7,12 +7,208 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
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
// 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
// observer may create one — which, for random/roundRobin, means a fallbackTag
// is set (xray-core then requires the Observatory feature; see #5605).
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)', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
syncObservatories(t);
@@ -64,7 +260,7 @@ describe('syncObservatories', () => {
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(
{
balancers: [
@@ -76,7 +272,7 @@ describe('syncObservatories', () => {
);
syncObservatories(t);
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)', () => {
@@ -86,20 +282,55 @@ describe('syncObservatories', () => {
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' } }] });
syncObservatories(t);
expect(t.observatory).toBeDefined();
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(
{ balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
{ burstObservatory: { subjectSelector: ['stale'] } },
);
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', () => {
@@ -112,6 +343,30 @@ describe('syncObservatories', () => {
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', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
syncObservatories(t);
@@ -104,6 +104,7 @@ describe('inbound security forms', () => {
scanRealityCandidates={async () => []}
applyRealityScanResult={noop}
randomizeShortIds={noop}
randomizeSpiderX={noop}
genRealityKeypair={noop}
clearRealityKeypair={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();
});
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', () => {
const tt = tpl({
outbounds: [
@@ -253,6 +312,68 @@ describe('balancer deletion', () => {
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', () => {
const tt = tpl({
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/op/go-logging v0.0.0-20160315200505-970db520ece7
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/valyala/fasthttp v1.72.0
github.com/xlzd/gotp v0.1.0
@@ -26,7 +26,7 @@ require (
golang.org/x/crypto v0.53.0
golang.org/x/sys v0.46.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
gorm.io/driver/postgres 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/json-iterator/go v1.1.12 // 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/leodido/go-urn v1.4.0 // 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/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/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
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.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
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/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
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=
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/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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
# Install certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
~/.acme.sh/acme.sh --installcert --force -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem \
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
@@ -517,7 +517,7 @@ setup_ip_certificate() {
# Install certificate
# 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.
~/.acme.sh/acme.sh --installcert -d ${ipv4} \
~/.acme.sh/acme.sh --installcert --force -d ${ipv4} \
--key-file "${certDir}/privkey.pem" \
--fullchain-file "${certDir}/fullchain.pem" \
--reloadcmd "${reloadCmd}" 2>&1 || true
@@ -705,7 +705,7 @@ ssl_cert_issue() {
# install the certificate
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 \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$?
@@ -1326,6 +1326,40 @@ setup_fail2ban() {
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() {
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}"
exit 1
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
tag_version=$1
# 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}"
exit 1
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
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
rm -f "${xui_script_temp}"
echo -e "${red}Failed to download x-ui.sh${plain}"
exit 1
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
if [[ -e ${xui_folder}/ ]]; then
@@ -1391,9 +1443,20 @@ install_x-ui() {
# Extract resources and set permissions
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
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.sh
@@ -1414,7 +1477,12 @@ install_x-ui() {
fi
# 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
mkdir -p /var/log/x-ui
config_after_install
@@ -1434,11 +1502,25 @@ install_x-ui() {
fi
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
rm -f "${xui_rc_temp}"
echo -e "${red}Failed to download x-ui.rc${plain}"
exit 1
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
rc-update add x-ui
rc-service x-ui start
@@ -1448,8 +1530,7 @@ install_x-ui() {
if [ -f "x-ui.service" ]; then
echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service" "false"; then
service_installed=true
fi
fi
@@ -1459,8 +1540,7 @@ install_x-ui() {
ubuntu | debian | armbian)
if [ -f "x-ui.service.debian" ]; then
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 [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service.debian" "false"; then
service_installed=true
fi
fi
@@ -1468,8 +1548,7 @@ install_x-ui() {
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
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 [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service.arch" "false"; then
service_installed=true
fi
fi
@@ -1477,8 +1556,7 @@ install_x-ui() {
*)
if [ -f "x-ui.service.rhel" ]; then
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 [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service.rhel" "false"; then
service_installed=true
fi
fi
@@ -1491,17 +1569,17 @@ install_x-ui() {
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
case "${release}" in
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)
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
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}"
exit 1
fi
+8
View File
@@ -169,6 +169,14 @@ func GetDBPath() string {
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".
func GetDBKind() string {
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
+35 -1
View File
@@ -664,7 +664,10 @@ func runSeeders(isUsersEmpty bool) error {
if err := seedWireguardPeersToClients(); err != nil {
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
@@ -769,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 {
var inbounds []model.Inbound
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)
}
}
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)
}
}
}
+3 -6
View File
@@ -240,8 +240,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
case model.VLESS:
proxy["type"] = "vless"
proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
streamSecurity, _ := stream["security"].(string)
if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
proxy["flow"] = client.Flow
@@ -258,8 +257,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
case model.Shadowsocks:
proxy["type"] = "ss"
proxy["password"] = client.Password
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
method, _ := inboundSettings["method"].(string)
if method == "" {
return nil
@@ -288,8 +286,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
// helpers prune fields (like `allowInsecure` / the salamander obfs
// 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 {
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
proxyType := "hysteria2"
authKey := "password"
+26 -20
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 {
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
// 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":
vc := client
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":
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":
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...)
@@ -234,15 +234,25 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
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
_ = json.Unmarshal([]byte(stream), &streamSettings)
if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
streamSettings = map[string]any{}
}
security, _ := streamSettings["security"].(string)
switch security {
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":
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")
@@ -322,7 +332,7 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
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)
rltyClientSettings, _ := rData["settings"].(map[string]any)
@@ -331,10 +341,8 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
rltyData["spiderX"] = "/" + random.Seq(15)
if spx, ok := rltyClientSettings["spiderX"].(string); ok && spx != "" {
rltyData["spiderX"] = spx
}
seed, _ := rltyClientSettings["spiderX"].(string)
rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
shortIds, ok := rData["shortIds"].([]any)
if ok && len(shortIds) > 0 {
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
@@ -385,7 +393,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
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.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy"
@@ -395,8 +403,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
outbound.StreamSettings = streamSettings
// Add encryption for VLESS outbound from inbound settings
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
encryption, _ := inboundSettings["encryption"].(string)
settings := map[string]any{
@@ -414,7 +421,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
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{}
serverData := make([]ServerSetting, 1)
@@ -426,8 +433,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
}
if inbound.Protocol == model.Shadowsocks {
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
method, _ := inboundSettings["method"].(string)
serverData[0].Method = method
@@ -467,7 +473,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
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.Protocol = string(inbound.Protocol)
+55 -21
View File
@@ -41,7 +41,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
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 {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
}
@@ -77,7 +77,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]}
}`)
}`, "")
finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any)
@@ -93,7 +93,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
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 {
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).
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
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)
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"}`}
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 {
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: `{}`}
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)
if server == nil {
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"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssServer := firstServer(ssSettings)
if ssServer == nil {
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
// 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"}}}`
parsed := svc.streamData(stream)
parsed := svc.streamData(stream, "")
mux := globalMux
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"}`}
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
if err := json.Unmarshal(raw, &ob); err != nil {
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.
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
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"}`}
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
if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
@@ -255,28 +255,62 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
}
}
func TestSubJsonServiceRealityDataUsesConfiguredSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
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":"/mypath"}
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`)
}`, clientKey)
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
if rlty["spiderX"] != "/mypath" {
t.Fatalf("spiderX = %v, want configured /mypath (#5718)", rlty["spiderX"])
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)
}
}
func TestSubJsonServiceRealityDataSpiderXFallsBackToRandom(t *testing.T) {
// 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(`{
@@ -286,7 +320,7 @@ func TestSubJsonServiceRealityDataSpiderXFallsBackToRandom(t *testing.T) {
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`)
}`, "")
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
+9 -9
View File
@@ -67,11 +67,11 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
protocol model.Protocol
}{
{"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},
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
{"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(&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},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&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},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
}
for _, tc := range cases {
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). ---
func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// 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,
// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
// so applyGlobalFinalMask must NOT set finalmask.
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 {
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
// guard is the only distinguishing factor.
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 {
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) {
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
// to an email-only client if not found.
func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
clients, _ := s.inboundService.GetClients(inbound)
for _, c := range clients {
if c.Email == email {
return c
}
if c, ok := s.clientForLink(inbound, email); ok {
return c
}
return model.Client{Email: email}
}
+194 -70
View File
@@ -54,6 +54,17 @@ type SubService struct {
// doesn't own its row (multi-inbound subscriptions). Filled in
// getInboundsBySubId; reset per request in PrepareForRequest.
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.
@@ -86,10 +97,96 @@ func (s *SubService) PrepareForRequest(host string) {
s.address = host
s.usageShown = map[string]bool{}
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.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
// every subscription format — raw, JSON, Clash — renders remarks the same way
// (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,
// deduplicated by email. settings.clients can accumulate duplicate entries
// for the same client (multi-node sync/import drift, old DBs): SyncInbound
// dedupes the normalized client_inbounds rows on write but never rewrites
// the legacy JSON, and the subscription builders iterate that JSON — so
// without this guard every duplicate became a duplicate profile in the
// output (#5134). Link generation keys purely on (inbound, email), so
// same-email entries are pure duplicates and dropping them is lossless.
// resolved from the normalized clients/client_inbounds tables (both filter
// columns indexed) instead of parsing the settings JSON — at large client
// counts that parse made every subscription fetch cost seconds. The
// case-insensitive email dedupe stays as cheap insurance even though
// clients.email is unique, preserving the #5134 guarantee that duplicate
// settings entries never fan out into duplicate profiles. Resolved clients
// 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 {
clients, err := s.inboundService.GetClients(inbound)
clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
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
}
var out []model.Client
seen := make(map[string]struct{}, len(clients))
for _, client := range clients {
if client.SubID != subId {
continue
}
key := strings.ToLower(client.Email)
if _, dup := seen[key]; dup {
continue
@@ -170,6 +265,7 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
seen[key] = struct{}{}
out = append(out, client)
}
s.primeLinkClients(inbound.Id, out, false)
return out
}
@@ -253,6 +349,7 @@ func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
if err != nil {
return nil
}
s.primeLinkClients(inbound.Id, clients, true)
s.projectThroughFallbackMaster(inbound)
hostEps := s.hostEndpoints(inbound, "raw")
var out []string
@@ -362,7 +459,7 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB()
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
FROM inbounds
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 {
return nil, err
}
s.indexStatsByEmail(inbounds)
s.indexStatsBySubId(subId)
return inbounds, nil
}
// indexStatsByEmail records every loaded inbound's client traffic rows keyed by
// email so statsForClient can resolve a client's usage even on an inbound that
// doesn't own its (globally unique) client_traffics row. See statsByEmail.
func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) {
// indexStatsBySubId loads the traffic rows for just this subscriber's clients
// into statsByEmail so statsForClient can resolve a client's usage on any of
// its inbounds. It replaces preloading every matched inbound's ClientStats,
// 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 {
s.statsByEmail = map[string]xray.ClientTraffic{}
}
for _, inbound := range inbounds {
for _, st := range inbound.ClientStats {
db := database.GetDB()
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
}
}
@@ -516,21 +628,14 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
if inbound.Protocol != model.WireGuard {
return ""
}
settings := map[string]any{}
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
settings := s.linkSettings(inbound)
secretKey, _ := settings["secretKey"].(string)
clients, _ := s.inboundService.GetClients(inbound)
var client *model.Client
for i := range clients {
if clients[i].Email == email {
client = &clients[i]
break
}
}
if client == nil || client.PrivateKey == "" {
resolved, ok := s.clientForLink(inbound, email)
if !ok || resolved.PrivateKey == "" {
return ""
}
client := &resolved
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
params := make(map[string]string)
@@ -607,10 +712,12 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
applyVmessTLSParams(stream, obj)
}
clients, _ := s.inboundService.GetClients(inbound)
clientIndex := findClientIndex(clients, email)
obj["id"] = clients[clientIndex].ID
obj["scy"] = clients[clientIndex].Security
client, ok := s.clientForLink(inbound, email)
if !ok {
return ""
}
obj["id"] = client.ID
obj["scy"] = client.Security
externalProxies, _ := stream["externalProxy"].([]any)
@@ -659,17 +766,18 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
}
address := s.resolveInboundAddress(inbound)
stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound)
clientIndex := findClientIndex(clients, email)
uuid := clients[clientIndex].ID
client, ok := s.clientForLink(inbound, email)
if !ok {
return ""
}
uuid := client.ID
port := inbound.Port
streamNetwork := stream["network"].(string)
params := make(map[string]string)
params["type"] = streamNetwork
// Add encryption parameter for VLESS from inbound settings
var settings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
settings := s.linkSettings(inbound)
if encryption, ok := settings["encryption"].(string); ok {
params["encryption"] = encryption
}
@@ -683,12 +791,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params)
applyShareRealityParams(stream, params, subKey(client))
default:
params["security"] = "none"
}
if len(clients[clientIndex].Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
params["flow"] = clients[clientIndex].Flow
if len(client.Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
params["flow"] = client.Flow
}
externalProxies, _ := stream["externalProxy"].([]any)
@@ -717,9 +825,11 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
}
address := s.resolveInboundAddress(inbound)
stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound)
clientIndex := findClientIndex(clients, email)
password := encodeUserinfo(clients[clientIndex].Password)
client, ok := s.clientForLink(inbound, email)
if !ok {
return ""
}
password := encodeUserinfo(client.Password)
port := inbound.Port
streamNetwork := stream["network"].(string)
params := make(map[string]string)
@@ -734,9 +844,9 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params)
if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
params["flow"] = clients[clientIndex].Flow
applyShareRealityParams(stream, params, subKey(client))
if streamNetwork == "tcp" && len(client.Flow) > 0 {
params["flow"] = client.Flow
}
default:
params["security"] = "none"
@@ -788,13 +898,14 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
}
address := s.resolveInboundAddress(inbound)
stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound)
client, ok := s.clientForLink(inbound, email)
if !ok {
return ""
}
var settings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
settings := s.linkSettings(inbound)
inboundPassword := settings["password"].(string)
method := settings["method"].(string)
clientIndex := findClientIndex(clients, email)
streamNetwork := stream["network"].(string)
params := make(map[string]string)
params["type"] = streamNetwork
@@ -828,9 +939,9 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
userInfo = fmt.Sprintf("%s:%s:%s",
url.QueryEscape(method),
url.QueryEscape(inboundPassword),
url.QueryEscape(clients[clientIndex].Password))
url.QueryEscape(client.Password))
} 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)
@@ -861,15 +972,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
}
var stream map[string]any
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
clients, _ := s.inboundService.GetClients(inbound)
clientIndex := -1
for i, client := range clients {
if client.Email == email {
clientIndex = i
break
}
client, ok := s.clientForLink(inbound, email)
if !ok {
return ""
}
auth := encodeUserinfo(clients[clientIndex].Auth)
auth := encodeUserinfo(client.Auth)
params := make(map[string]string)
params["security"] = "tls"
@@ -928,8 +1035,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
}
}
var settings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
settings := s.linkSettings(inbound)
version, _ := settings["version"].(float64)
protocol := "hysteria2"
if int(version) == 1 {
@@ -1330,7 +1436,7 @@ func hysteriaPinHex(pin string) string {
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"
realitySetting, _ := stream["realitySettings"].(map[string]any)
realitySettings, _ := searchKey(realitySetting, "settings")
@@ -1356,15 +1462,33 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
params["pqv"] = pqv
}
}
params["spx"] = "/" + random.Seq(15)
seed := ""
if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
if spx, ok := spxValue.(string); ok && len(spx) > 0 {
params["spx"] = spx
}
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 {
jsonStr, _ := json.MarshalIndent(obj, "", " ")
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:
// the two entries differ only by email case, so dropping strings.ToLower (or keying on
// another field) yields two clients. The byte-identical dupes above can't catch that.
// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count.
// clients.email is unique but case-sensitively so: two rows differing only by email case
// 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) {
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 uuid = "11111111-2222-4333-8444-555555555555"
ib := &model.Inbound{
Protocol: model.VLESS,
Settings: `{"clients":[
{"id":"` + uuid + `","email":"Dup@Example.com","subId":"` + subId + `","enable":true},
{"id":"` + uuid + `","email":"dup@example.com","subId":"` + subId + `","enable":true}
]}`,
db := database.GetDB()
ib := &model.Inbound{Protocol: model.VLESS, Enable: true, Port: 42002, Tag: "dedup-ci", Settings: `{"clients":[]}`}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
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{}
got := s.matchingClients(ib, subId)
if len(got) != 1 {
@@ -90,7 +106,7 @@ func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
if got[0].Email != "Dup@Example.com" {
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 {
t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
}
+89 -18
View File
@@ -1,6 +1,7 @@
package sub
import (
"net/url"
"strings"
"testing"
@@ -52,8 +53,8 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
}
}
// Locks the reality field mapping of applyShareRealityParams; a configured
// spiderX must round-trip verbatim (#5718), distinct pbk/sid catch a swap mutant.
// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
@@ -73,7 +74,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2Fmypath",
"spx=%2F",
}
for _, w := range wants {
if !strings.Contains(link, w) {
@@ -86,22 +87,92 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
}
}
// Without a configured spiderX, spx must still fall back to a random
// "/"-prefixed value so clients always receive a plausible path.
func TestGenVlessLink_RealitySpiderXFallsBackToRandom(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`
// 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{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
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)
}
}
if !strings.Contains(link, "spx=%2F") {
t.Fatalf("reality link missing random spx fallback\n got: %s", link)
// 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("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getUpdateStatus", a.getUpdateStatus)
g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb)
g.GET("/getMigration", a.getMigration)
@@ -209,21 +210,34 @@ func (a *ServerController) installXray(c *gin.Context) {
// 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
// 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) {
devParam := c.PostForm("dev")
var runID int64
var err error
if devParam == "" {
err = a.panelService.StartUpdate()
runID, err = a.panelService.StartUpdate()
} else {
dev, perr := strconv.ParseBool(devParam)
if perr != nil {
jsonMsg(c, "invalid data", perr)
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.
+13 -2
View File
@@ -26,9 +26,16 @@ type updateUserForm struct {
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 {
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.
@@ -105,7 +112,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
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 bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr
+217 -101
View File
@@ -113,33 +113,129 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
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 {
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
if err != nil {
return false
const ipScanChunk = 400
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 {
if inbound.Settings == "" {
// loadClientLimits maps each observed email to its clients.limit_ip in a few
// 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
}
for _, r := range rows {
out[r.Email] = r.LimitIp
}
}
return out
}
settings := map[string][]model.Client{}
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
for _, client := range clients {
limitIp := client.LimitIP
if limitIp > 0 {
return true
// loadInboundsByEmails resolves each email's owning inbound through the
// clients/client_inbounds relation in chunked queries. Like the old per-email
// First() it keeps the lowest inbound id when a client spans several inbounds.
func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound {
db := database.GetDB()
minInboundByEmail := make(map[string]int, len(emails))
for _, batch := range chunkEmails(emails, ipScanChunk) {
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
@@ -147,27 +243,68 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
// 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
// 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 {
shouldCleanLog := false
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
// be recorded under this panel's own guid for cross-node IP attribution.
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
// or deleted; its email no longer matches any inbound. Skip it (and
// drop any orphaned tracking row) instead of recreating a row and
// logging an ERROR every run (#4963).
inbound, err := j.getInboundByEmail(email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
j.delInboundClientIps(email)
} else {
j.checkError(err)
// logging an ERROR every run (#4963). The batch map resolves through
// the clients relation; the per-email fallback keeps its settings LIKE
// net for clients not yet present there.
inbound, ok := inboundByEmail[email]
if !ok {
var err error
inbound, err = j.getInboundByEmail(email)
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
@@ -188,13 +325,44 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
attribution[email] = attrEntries
}
clientIpsRecord, err := j.getInboundClientIps(email)
if err != nil {
_ = j.addInboundClientIps(email, ipsWithTime)
clientIpsRecord, ok := ipRowByEmail[email]
if !ok {
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
}
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)
@@ -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
// 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).
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
db := database.GetDB()
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
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 == "" {
logger.Debug("wrong data:", inbound)
return false
return false, false
}
settings := map[string][]model.Client{}
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
// 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.
if !enforce || limitIp <= 0 || !inbound.Enable {
// Nothing to enforce (collection-only run, no limit on the clients row,
// or inbound disabled): record the observed IPs for the panel and return.
jsonIps, _ := json.Marshal(newIpsWithTime)
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
db.Save(inboundClientIps)
return false
if err := tx.Save(inboundClientIps).Error; err != nil {
logger.Error("failed to save inboundClientIps:", err)
}
return false, false
}
// Parse old IPs from database
@@ -386,18 +507,18 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
}
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
shouldCleanLog := false
j.disAllowedIps = []string{}
// historical db-only ips are excluded from this count on purpose.
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
if len(bannedLive) > 0 {
shouldCleanLog = true
banned = true
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
logger.Errorf("failed to open IP limit log file: %s", err)
return false
return false, false
}
defer logIpFile.Close()
ipLogger := log.New(logIpFile, "", log.LstdFlags)
@@ -410,9 +531,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
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
@@ -424,18 +542,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
jsonIps, _ := json.Marshal(dbIps)
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
err := db.Save(inboundClientIps).Error
if err != nil {
if err := tx.Save(inboundClientIps).Error; err != nil {
logger.Error("failed to save inboundClientIps:", err)
return false
return false, banned
}
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))
}
return shouldCleanLog
return shouldCleanLog, banned
}
// 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 {
t.Helper()
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 {
t.Fatalf("seed client record: %v", err)
}
@@ -206,11 +206,14 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
if err != nil {
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 {
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 {
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
}
@@ -259,11 +262,14 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
if err != nil {
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 {
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" {
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)
}
}
// 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)
var wg sync.WaitGroup
var activeMu sync.Mutex
var activeEmails []string
for _, n := range nodes {
if !n.Enable || n.Status != "online" {
continue
@@ -120,7 +122,11 @@ func (j *NodeTrafficSyncJob) Run() {
common.GoRecover("node-traffic-sync:"+n.Name, func() {
defer wg.Done()
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()
@@ -143,14 +149,6 @@ func (j *NodeTrafficSyncJob) Run() {
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
// 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.
@@ -164,6 +162,45 @@ func (j *NodeTrafficSyncJob) Run() {
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()
if online == nil {
online = []string{}
@@ -181,10 +218,8 @@ func (j *NodeTrafficSyncJob) Run() {
trafficPayload["nodeTraffics"] = inboundSpeed
websocket.BroadcastTraffic(trafficPayload)
clientStats := map[string]any{}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStats := map[string]any{"snapshot": snapshot}
if len(stats) > 0 {
clientStats["clients"] = stats
}
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
@@ -192,7 +227,7 @@ func (j *NodeTrafficSyncJob) Run() {
} else if len(summary) > 0 {
clientStats["inbounds"] = summary
}
if len(clientStats) > 0 {
if len(clientStats) > 1 {
websocket.BroadcastClientStats(clientStats)
}
@@ -318,11 +353,14 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
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)
if err != nil {
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
return
return nil
}
if n.ConfigDirty {
@@ -331,7 +369,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
reconcileCancel()
if reconcileErr != nil {
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 {
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 {
logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
j.inboundService.ClearNodeOnlineClients(n.Id)
return
return nil
}
service.FilterNodeSnapshot(n, snap)
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
if err != nil {
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
return
return nil
}
if changed {
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 {
return
return active
}
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()
if err != nil {
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 err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
@@ -406,4 +450,5 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
}
}
}
return active
}
+46 -8
View File
@@ -20,6 +20,14 @@ type XrayTrafficJob struct {
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.
func NewXrayTrafficJob() *XrayTrafficJob {
return new(XrayTrafficJob)
@@ -116,9 +124,41 @@ func (j *XrayTrafficJob) Run() {
return
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("get clients last online failed:", err)
// Small installs broadcast the full snapshot (see GetAllClientTraffics for
// why deltas alone left UI rows stale). Above the threshold the snapshot
// 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 {
lastOnlineMap = make(map[string]int64)
@@ -136,10 +176,8 @@ func (j *XrayTrafficJob) Run() {
"lastOnlineMap": lastOnlineMap,
})
clientStatsPayload := map[string]any{}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload := map[string]any{"snapshot": snapshot}
if len(stats) > 0 {
clientStatsPayload["clients"] = stats
}
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
@@ -147,7 +185,7 @@ func (j *XrayTrafficJob) Run() {
} else if len(inboundSummary) > 0 {
clientStatsPayload["inbounds"] = inboundSummary
}
if len(clientStatsPayload) > 0 {
if len(clientStatsPayload) > 1 {
websocket.BroadcastClientStats(clientStatsPayload)
}
+31
View File
@@ -197,3 +197,34 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie
}
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
import (
"encoding/json"
"sync"
"time"
@@ -139,3 +140,39 @@ func isClientEmailTombstoned(email string) bool {
}
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
}
+2 -2
View File
@@ -74,7 +74,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
t.Fatalf("empty globals must use the local-only predicate")
}
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)
} else if count != 1 {
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)
}
if _, count, _, err := svc.disableInvalidClients(db); err != nil {
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 {
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
}
// 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) {
db := database.GetDB()
var emails []string
@@ -79,6 +79,14 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
now := time.Now().Unix()
cutoff := now - clientIpStaleAfterSeconds
// Node syncs run concurrently (one goroutine per node) and shared clients
// appear in several nodes' reports. Locking rows in each node's arbitrary
// report order lets two merges grab the same rows in opposite order, which
// Postgres aborts as a deadlock (40P01) — take them in one global order.
sort.Slice(incomingIps, func(i, j int) bool {
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
})
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
@@ -157,3 +157,77 @@ func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(t *testing.T)
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"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
@@ -80,7 +81,7 @@ func depletedCond(tx *gorm.DB) string {
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
needRestart := false
cond := depletedCond(tx)
@@ -90,10 +91,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
Where(cond+" AND enable = ?", now, true).
Find(&depletedRows).Error
if err != nil {
return false, 0, nil, err
return false, 0, err
}
if len(depletedRows) == 0 {
return false, 0, nil, nil
return false, 0, nil
}
depletedEmails := make([]string, 0, len(depletedRows))
@@ -121,7 +122,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
WHERE clients.email IN ?
`, depletedEmails).Scan(&targets).Error
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{}).
Where(cond+" AND enable = ?", now, true).
Update("enable", false)
err = result.Error
count := result.RowsAffected
if err != nil {
return needRestart, count, nil, err
// Flip the rows already collected above by primary key instead of
// re-evaluating the depleted predicate, which was a second full scan of
// client_traffics on every poll. Sorted ids keep the lock order stable.
ids := make([]int, 0, len(depletedRows))
for i := range depletedRows {
ids = append(ids, depletedRows[i].Id)
}
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 {
@@ -179,7 +190,6 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
}
}
disabledNodeIDs := make(map[int]struct{})
for inboundID, group := range remoteByInbound {
emails := make(map[string]struct{}, len(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 {
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
needRestart = true
} else {
for _, t := range group {
if t.NodeID != nil {
disabledNodeIDs[*t.NodeID] = struct{}{}
}
}
}
}
nodeIDs := make([]int, 0, len(disabledNodeIDs))
for nodeID := range disabledNodeIDs {
nodeIDs = append(nodeIDs, nodeID)
}
return needRestart, count, nodeIDs, nil
return needRestart, count, nil
}
// 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
}
// 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 {
oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
if err != nil {
+20 -25
View File
@@ -486,6 +486,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
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{}
if !dirty {
updates["enable"] = snapIb.Enable
@@ -496,7 +504,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["protocol"] = snapIb.Protocol
updates["total"] = snapIb.Total
updates["expiry_time"] = snapIb.ExpiryTime
updates["settings"] = snapIb.Settings
updates["settings"] = adoptedSettings
updates["stream_settings"] = snapIb.StreamSettings
updates["sniffing"] = snapIb.Sniffing
updates["traffic_reset"] = snapIb.TrafficReset
@@ -513,7 +521,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["origin_node_guid"] = og
}
if !dirty && (c.Settings != snapIb.Settings ||
if !dirty && (c.Settings != adoptedSettings ||
c.Remark != snapIb.Remark ||
c.Listen != snapIb.Listen ||
c.Port != snapIb.Port ||
@@ -634,8 +642,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if dirty {
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
if _, isNewInbound := newInboundIDs[c.Id]; isNewInbound && !isClientEmailTombstoned(cs.Email) {
if isNewInbound && !isClientEmailTombstoned(cs.Email) {
seedUp, seedDown = canon.Up, canon.Down
}
row := &xray.ClientTraffic{
@@ -895,28 +912,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
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 {
if p == nil {
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]
}
// 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()
defer func() {
if r := recover(); r != nil {
@@ -71,10 +81,8 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
}
}()
for email, incoming := range perEmail {
if email == "" {
continue
}
for _, email := range emails {
incoming := perEmail[email]
var old []model.ClientIpEntry
if cur, ok := existingByEmail[email]; ok && cur.Ips != "" {
_ = json.Unmarshal([]byte(cur.Ips), &old)
+40 -31
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"strings"
"time"
@@ -19,19 +21,15 @@ import (
)
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
var disabledNodeIDs []int
err = submitTrafficWrite(func() error {
var inner error
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
return inner
})
if err == nil && len(disabledNodeIDs) > 0 {
s.restartRemoteNodesOnDisable(disabledNodeIDs)
}
return
}
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
db := database.GetDB()
tx := db.Begin()
@@ -45,11 +43,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
}()
err = s.addInboundTraffic(tx, inboundTraffics)
if err != nil {
return false, false, nil, err
return false, false, err
}
err = s.addClientTraffic(tx, clientTraffics)
if err != nil {
return false, false, nil, err
return false, false, err
}
needRestart0, count, err := s.autoRenewClients(tx)
@@ -60,7 +58,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
}
disabledClientsCount := int64(0)
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
needRestart1, count, err := s.disableInvalidClients(tx)
if err != nil {
logger.Warning("Error in disabling invalid clients:", err)
} else if count > 0 {
@@ -74,7 +72,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
} else if count > 0 {
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 {
@@ -129,7 +127,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
return nil
}
dbClientTraffics, err = s.adjustTraffics(tx, dbClientTraffics)
dbClientTraffics, convertedExpiryByEmail, err := s.adjustTraffics(tx, dbClientTraffics)
if err != nil {
return err
}
@@ -165,22 +163,22 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
// adjustTraffics converts delayed-start rows (negative ExpiryTime → absolute
// deadline) in-memory. Persist that conversion now since the traffic UPDATE
// above only touches up/down/last_online.
for _, ct := range dbClientTraffics {
if ct.ExpiryTime > 0 {
if err = tx.Exec(
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
ct.ExpiryTime, ct.Email,
).Error; err != nil {
logger.Warning("AddClientTraffic update expiry_time ", err)
}
// above only touches up/down/last_online. Only converted emails are written:
// updating every polled row issued one no-op UPDATE per active client per
// poll. Sorted order keeps concurrent writers lock-compatible on Postgres.
for _, email := range slices.Sorted(maps.Keys(convertedExpiryByEmail)) {
if err = tx.Exec(
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
convertedExpiryByEmail[email], email,
).Error; err != nil {
logger.Warning("AddClientTraffic update expiry_time ", err)
}
}
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()
// "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 {
return dbClientTraffics, nil
return dbClientTraffics, nil, nil
}
delayedEmails := make([]string, 0, len(newExpiryByEmail))
@@ -212,16 +210,16 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
Distinct().
Pluck("client_inbounds.inbound_id", &inboundIds).Error
if err != nil {
return nil, err
return nil, nil, err
}
if len(inboundIds) == 0 {
return dbClientTraffics, nil
return dbClientTraffics, nil, nil
}
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
if err != nil {
return nil, err
return nil, nil, err
}
for inbound_index := range inbounds {
settings := map[string]any{}
@@ -247,7 +245,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return nil, err
return nil, nil, err
}
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) {
@@ -930,14 +928,18 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
}
traffics = append(traffics, page...)
}
overlayGlobalTraffic(db, traffics)
return traffics, nil
}
// GetAllClientTraffics returns the full set of client_traffics rows so the
// websocket broadcasters can ship a complete snapshot every cycle. The old
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
// the per-client section whenever no client moved bytes in the cycle or a
// node sync failed, leaving client rows in the UI stuck at stale numbers.
// websocket broadcasters can ship a complete snapshot every cycle. A pure
// delta path silently dropped the per-client section whenever no client moved
// bytes in the cycle or a node sync failed, leaving client rows in the UI
// 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) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
@@ -948,6 +950,13 @@ func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
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 {
Id int `json:"id"`
Up int64 `json:"up"`
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"path/filepath"
"strings"
"testing"
"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")
}
// TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot
// fetched just before a client's deletion still names the email. Applying it
// must neither recreate the client_traffics row (at zero) nor re-add the
// client to the central inbound's settings while the delete tombstone lives.
func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739")
svc := &InboundService{}
const email = "victim-5739"
withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in").
Update("settings", `{"clients": []}`).Error; err != nil {
t.Fatalf("clear central settings: %v", err)
}
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
t.Fatalf("delete stats row: %v", err)
}
tombstoneClientEmail(email)
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true})
var rows int64
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil {
t.Fatalf("count stats rows: %v", err)
}
if rows != 0 {
t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows)
}
var ib model.Inbound
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
if strings.Contains(ib.Settings, email) {
t.Errorf("deleted client re-added to central settings: %s", ib.Settings)
}
}
func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
+170 -11
View File
@@ -13,6 +13,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -44,10 +45,64 @@ const (
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
// newest main commit; the dev update channel installs from it.
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})`)
// 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 {
go func() {
time.Sleep(delay)
@@ -122,39 +177,77 @@ func getDevUpdateInfo() (*PanelUpdateInfo, error) {
}, nil
}
// StartUpdate starts the official updater using this panel's own channel setting.
func (s *PanelService) StartUpdate() error {
// StartUpdate starts the official updater using this panel's own channel
// 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())
}
// StartUpdateChannel runs the updater against an explicitly chosen channel,
// 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.
func (s *PanelService) StartUpdateChannel(dev bool) error {
func (s *PanelService) StartUpdateChannel(dev bool) (int64, error) {
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" {
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")
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()
if err != nil {
return err
return 0, err
}
statusFile := config.GetUpdateStatusFilePath()
mainFolder, serviceFolder := resolveUpdateFolders()
updateTag := ""
if useDev {
updateTag = devReleaseTag
}
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 {
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_SERVICE="+serviceFolder,
"--setenv", "XUI_UPDATE_TAG="+updateTag,
"--setenv", runIDEnv,
"--setenv", statusFileEnv,
bash, "-lc", updateScript,
)
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") &&
!strings.Contains(output, "Failed to connect to bus") {
_ = 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)
} else {
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_SERVICE="+serviceFolder,
"XUI_UPDATE_TAG="+updateTag,
runIDEnv,
statusFileEnv,
)
setDetachedProcess(cmd)
if err := cmd.Start(); err != nil {
_ = 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 {
logger.Warning("failed to release panel update process:", err)
}
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) {
@@ -230,6 +386,9 @@ func downloadPanelUpdater() (string, error) {
if err != nil {
return "", fmt.Errorf("write panel updater: %w", err)
}
if n == 0 {
return "", fmt.Errorf("panel updater download is empty")
}
if n > maxPanelUpdaterBytes {
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
}
@@ -5,3 +5,10 @@ package panel
import "os/exec"
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
import (
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"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")
}
}
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) {
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
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"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"
"github.com/op/go-logging"
"gorm.io/gorm"
)
// 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),
// and registers cleanup. Skips the test when neither backend is configured.
// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file;
// 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) {
t.Helper()
xuilogger.InitLogger(logging.ERROR)
@@ -31,7 +37,10 @@ func setupScaleDB(t *testing.T) {
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
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 {
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")
}
// 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
// single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
// 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) {
return s.getString("subSupportUrl")
value, err := s.getString("subSupportUrl")
return common.EnsureURLScheme(value), err
}
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) {
@@ -1083,8 +1085,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
return s.setInt("smtpMemory", value)
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := s.preserveRedactedSecrets(allSetting); err != nil {
// SecretClears marks redacted secrets the user explicitly emptied. Without a
// 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
}
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 {
if strings.TrimSpace(allSetting.TgBotToken) == "" {
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
value, err := s.GetTgBotToken()
if err != nil {
return err
}
allSetting.TgBotToken = value
}
if strings.TrimSpace(allSetting.LdapPassword) == "" {
if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
value, err := s.GetLdapPassword()
if err != nil {
return err
@@ -1152,7 +1163,7 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
}
allSetting.TwoFactorToken = value
}
if strings.TrimSpace(allSetting.SmtpPassword) == "" {
if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
value, err := s.GetSmtpPassword()
if err != nil {
return err
@@ -1177,6 +1188,12 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
}
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
}
+49 -1
View File
@@ -77,7 +77,7 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
t.Fatal(err)
}
settings := &view.AllSetting
if err := s.UpdateAllSetting(settings); err != nil {
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
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) {
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
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))
})
}
}
@@ -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))
})
}
}
}
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "ده هيحدث 3X-UI للإصدار #version# وهيعيد تشغيل البانل.",
"panelUpdateCheckPopover": "فشل التحقق من تحديث البانل",
"panelUpdateStartedPopover": "بدأ تحديث البانل",
"panelUpdateFailedTitle": "فشل تحديث البانل",
"panelUpdateFailedDesc": "لم يكتمل التحديث بنجاح. تحقق من سجلات الخادم، أو نفّذ الأمر «x-ui update» من سطر الأوامر.",
"panelUpdateUnknownTitle": "تعذّر التأكد من اكتمال التحديث",
"panelUpdateUnknownDesc": "لم تُبلغ اللوحة بنتيجة في الوقت المحدد. أعد تحميل الصفحة للتحقق من الإصدار الحالي، أو تحقق من سجلات الخادم.",
"geofileUpdateDialog": "هل تريد حقًا تحديث ملف الجغرافيا؟",
"geofileUpdateDialogDesc": "سيؤدي هذا إلى تحديث ملف #filename#.",
"geofilesUpdateDialogDesc": "سيؤدي هذا إلى تحديث كافة الملفات.",
@@ -646,6 +650,7 @@
"scanUse": "استخدام",
"scanRescan": "إعادة الفحص",
"spiderX": "SpiderX",
"spiderXHint": "بذرة لكل عميل — تشتق اللوحة مسار spx فريدًا لكل عميل منها؛ أعد التوليد لتدوير مسارات الجميع",
"getNewCert": "احصل على شهادة جديدة",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
"eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
"remarkTemplate": "قالب الملاحظة",
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه."
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
"validation": {
"pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
},
"secretClear": "مسح",
"secretClearUndo": "تراجع عن المسح"
},
"xray": {
"title": "إعدادات Xray",
@@ -1686,7 +1696,8 @@
"title": "المرصد",
"burstTitle": "مرصد Burst",
"autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.",
"emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى يتمكن الموازن من قياس الوجهات الصادرة واختيار الأفضل.",
"emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى تتمكن الموازنات المعتمدة على المرصد من فحص صحة الوجهات الصادرة قبل اختيار الهدف.",
"mixedLegacy": "يحتوي هذا الإعداد على Observatory و Burst Observatory معًا. يستخدم Xray مرصدًا عالميًا واحدًا، لذلك هذه الحالة القديمة المختلطة غير مدعومة؛ حفظ الموازنات سيحوّلها إلى مرصد واحد.",
"subjectSelector": "الوجهات المراقَبة",
"subjectSelectorDesc": "وسوم الوجهات الصادرة التي يفحصها هذا المرصد. تتم إدارتها تلقائيًا من الموازنات لديك.",
"probeURL": "رابط الفحص (URL)",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "This will update 3X-UI to #version# and restart the panel service.",
"panelUpdateCheckPopover": "Panel update check failed",
"panelUpdateStartedPopover": "Panel update started",
"panelUpdateFailedTitle": "Panel update failed",
"panelUpdateFailedDesc": "The update did not finish successfully. Check the server logs, or run 'x-ui update' from the command line.",
"panelUpdateUnknownTitle": "Couldn't confirm the update finished",
"panelUpdateUnknownDesc": "The panel didn't report a result in time. Reload to check the current version, or check the server logs.",
"geofileUpdateDialog": "Do you really want to update the geofile?",
"geofileUpdateDialogDesc": "This will update the #filename# file.",
"geofilesUpdateDialogDesc": "This will update all geofiles.",
@@ -658,6 +662,7 @@
"scanUse": "Use",
"scanRescan": "Rescan",
"spiderX": "SpiderX",
"spiderXHint": "Per-client seed — the panel derives a unique spx path for each client from it; regenerate to rotate everyone's paths",
"getNewCert": "Get New Cert",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1511,7 +1516,12 @@
"smtpErrorRelay": "Server rejects sending from this address",
"smtpErrorEof": "Connection closed by server",
"smtpErrorUnknown": "SMTP error: {{ .Error }}",
"eventMemoryHigh": "Memory high (%)"
"eventMemoryHigh": "Memory high (%)",
"validation": {
"pathLeadingSlash": "Path must start with /"
},
"secretClear": "Clear",
"secretClearUndo": "Undo clear"
},
"xray": {
"title": "Xray Configs",
@@ -1802,7 +1812,8 @@
"title": "Observatory",
"burstTitle": "Burst Observatory",
"autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.",
"emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so the balancer can measure your outbounds and pick the best one.",
"emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so observer-backed balancers can check outbound health before choosing a target.",
"mixedLegacy": "This config contains both Observatory and Burst Observatory. Xray uses one global observer, so this mixed legacy state is not supported; saving balancers will normalize it to one observer.",
"subjectSelector": "Watched Outbounds",
"subjectSelectorDesc": "Outbound tags this observer probes. Managed automatically from your balancers.",
"probeURL": "Probe URL",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Esto actualizará 3X-UI a la versión #version# y reiniciará el servicio del panel.",
"panelUpdateCheckPopover": "Fallo al comprobar actualización del panel",
"panelUpdateStartedPopover": "Actualización del panel iniciada",
"panelUpdateFailedTitle": "Error al actualizar el panel",
"panelUpdateFailedDesc": "La actualización no se completó correctamente. Revisa los registros del servidor o ejecuta 'x-ui update' desde la línea de comandos.",
"panelUpdateUnknownTitle": "No se pudo confirmar si la actualización terminó",
"panelUpdateUnknownDesc": "El panel no informó un resultado a tiempo. Recarga la página para comprobar la versión actual, o revisa los registros del servidor.",
"geofileUpdateDialog": "¿Realmente deseas actualizar el geofichero?",
"geofileUpdateDialogDesc": "Esto actualizará el archivo #filename#.",
"geofilesUpdateDialogDesc": "Esto actualizará todos los archivos.",
@@ -667,6 +671,7 @@
"scanUse": "Usar",
"scanRescan": "Reescanear",
"spiderX": "SpiderX",
"spiderXHint": "Semilla por cliente: el panel deriva de ella una ruta spx única para cada cliente; regenera para rotar las rutas de todos",
"getNewCert": "Obtener nuevo cert",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
"eventMemoryHigh": "Uso de memoria alto (%)",
"remarkTemplate": "Plantilla de notas",
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior."
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
"validation": {
"pathLeadingSlash": "La ruta debe comenzar con /"
},
"secretClear": "Borrar",
"secretClearUndo": "Deshacer borrado"
},
"xray": {
"title": "Xray Configuración",
@@ -1686,7 +1696,8 @@
"title": "Observatorio",
"burstTitle": "Observatorio Burst",
"autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.",
"emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que el balanceador pueda medir tus salidas y elegir la mejor.",
"emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que los balanceadores que usan observador puedan comprobar la salud de las salidas antes de elegir un destino.",
"mixedLegacy": "Esta configuración contiene Observatory y Burst Observatory a la vez. Xray usa un único observador global, por lo que este estado mixto heredado no está soportado; al guardar balanceadores se normalizará a un solo observador.",
"subjectSelector": "Salidas vigiladas",
"subjectSelectorDesc": "Etiquetas de salida que sondea este observador. Se gestionan automáticamente a partir de tus balanceadores.",
"probeURL": "URL de sondeo",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "این 3X-UI را به نسخه #version# به‌روزرسانی کرده و سرویس پنل را مجدداً راه‌اندازی می‌کند.",
"panelUpdateCheckPopover": "خطا در بررسی به‌روزرسانی پنل",
"panelUpdateStartedPopover": "به‌روزرسانی پنل آغاز شد",
"panelUpdateFailedTitle": "به‌روزرسانی پنل ناموفق بود",
"panelUpdateFailedDesc": "به‌روزرسانی با موفقیت به پایان نرسید. گزارش‌های سرور را بررسی کنید یا دستور «x-ui update» را از خط فرمان اجرا کنید.",
"panelUpdateUnknownTitle": "تأیید نشد که به‌روزرسانی به پایان رسیده باشد",
"panelUpdateUnknownDesc": "پنل به‌موقع نتیجه‌ای گزارش نکرد. صفحه را بارگذاری مجدد کنید تا نسخه فعلی را بررسی کنید یا گزارش‌های سرور را بررسی کنید.",
"geofileUpdateDialog": "آیا واقعاً می‌خواهید فایل جغرافیایی را به‌روز کنید؟",
"geofileUpdateDialogDesc": "این عمل فایل #filename# را به‌روز می‌کند.",
"geofilesUpdateDialogDesc": "با این کار همه فایل‌ها به‌روزرسانی می‌شوند.",
@@ -658,6 +662,7 @@
"scanUse": "استفاده",
"scanRescan": "اسکن مجدد",
"spiderX": "SpiderX",
"spiderXHint": "دانه‌ی هر کاربر — پنل از روی آن مسیر spx یکتا برای هر کاربر می‌سازد؛ برای چرخش مسیر همه، دوباره تولید کنید",
"getNewCert": "دریافت گواهی جدید",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
"smtpErrorEof": "اتصال توسط سرور بسته شد",
"smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
"eventMemoryHigh": "مصرف حافظه بالا (%)"
"eventMemoryHigh": "مصرف حافظه بالا (%)",
"validation": {
"pathLeadingSlash": "مسیر باید با / شروع شود"
},
"secretClear": "پاک کردن",
"secretClearUndo": "لغو پاک کردن"
},
"xray": {
"title": "پیکربندی ایکس‌ری",
@@ -1686,7 +1696,8 @@
"title": "رصدخانه",
"burstTitle": "رصدخانه Burst",
"autoManaged": "رصدگرها به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شوند. در ادامه می‌توانید نحوهٔ پروب‌زدن را تنظیم کنید؛ خروجی‌های تحت نظر از سلکتورهای بالانسر پیروی می‌کنند.",
"emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسر بتواند خروجی‌ها را اندازه‌گیری کند و بهترین را انتخاب کند.",
"emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسرهای متکی به رصدگر بتوانند پیش از انتخاب مقصد، سلامت خروجی‌ها را بررسی کنند.",
"mixedLegacy": "این پیکربندی هم Observatory و هم Burst Observatory دارد. Xray فقط از یک رصدگر سراسری استفاده می‌کند، بنابراین این حالت قدیمیِ ترکیبی پشتیبانی نمی‌شود؛ ذخیرهٔ بالانسرها آن را به یک رصدگر عادی‌سازی می‌کند.",
"subjectSelector": "خروجی‌های تحت نظر",
"subjectSelectorDesc": "تگ خروجی‌هایی که این رصدگر پروب می‌کند. به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شود.",
"probeURL": "آدرس پروب (URL)",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Ini akan memperbarui 3X-UI ke #version# dan me-restart layanan panel.",
"panelUpdateCheckPopover": "Pemeriksaan pembaruan panel gagal",
"panelUpdateStartedPopover": "Pembaruan panel dimulai",
"panelUpdateFailedTitle": "Pembaruan panel gagal",
"panelUpdateFailedDesc": "Pembaruan tidak selesai dengan sukses. Periksa log server, atau jalankan 'x-ui update' dari baris perintah.",
"panelUpdateUnknownTitle": "Tidak dapat memastikan pembaruan selesai",
"panelUpdateUnknownDesc": "Panel tidak melaporkan hasil tepat waktu. Muat ulang untuk memeriksa versi saat ini, atau periksa log server.",
"geofileUpdateDialog": "Apakah Anda yakin ingin memperbarui geofile?",
"geofileUpdateDialogDesc": "Ini akan memperbarui file #filename#.",
"geofilesUpdateDialogDesc": "Ini akan memperbarui semua berkas.",
@@ -646,6 +650,7 @@
"scanUse": "Gunakan",
"scanRescan": "Pindai ulang",
"spiderX": "SpiderX",
"spiderXHint": "Seed per-klien — panel menurunkan jalur spx unik untuk tiap klien darinya; regenerasi untuk merotasi jalur semua klien",
"getNewCert": "Dapatkan sertifikat baru",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
"eventMemoryHigh": "Penggunaan memori tinggi (%)",
"remarkTemplate": "Templat Catatan",
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas."
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
"validation": {
"pathLeadingSlash": "Path harus diawali dengan /"
},
"secretClear": "Hapus",
"secretClearUndo": "Batalkan hapus"
},
"xray": {
"title": "Konfigurasi Xray",
@@ -1686,7 +1696,8 @@
"title": "Observatory",
"burstTitle": "Burst Observatory",
"autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.",
"emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer dapat mengukur outbound Anda dan memilih yang terbaik.",
"emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer yang memakai observer dapat memeriksa kesehatan outbound sebelum memilih target.",
"mixedLegacy": "Konfigurasi ini berisi Observatory dan Burst Observatory sekaligus. Xray memakai satu observer global, jadi status campuran lama ini tidak didukung; menyimpan balancer akan menormalkannya menjadi satu observer.",
"subjectSelector": "Outbound yang Dipantau",
"subjectSelectorDesc": "Tag outbound yang di-probe observer ini. Dikelola otomatis dari balancer Anda.",
"probeURL": "URL Probe",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "これにより3X-UIが#version#に更新され、パネルサービスが再起動されます。",
"panelUpdateCheckPopover": "パネルの更新確認に失敗しました",
"panelUpdateStartedPopover": "パネルの更新を開始しました",
"panelUpdateFailedTitle": "パネルの更新に失敗しました",
"panelUpdateFailedDesc": "更新が正常に完了しませんでした。サーバーのログを確認するか、コマンドラインで「x-ui update」を実行してください。",
"panelUpdateUnknownTitle": "更新が完了したか確認できませんでした",
"panelUpdateUnknownDesc": "パネルから時間内に結果が報告されませんでした。再読み込みして現在のバージョンを確認するか、サーバーのログを確認してください。",
"geofileUpdateDialog": "ジオファイルを本当に更新しますか?",
"geofileUpdateDialogDesc": "これにより#filename#ファイルが更新されます。",
"geofilesUpdateDialogDesc": "これにより、すべてのファイルが更新されます。",
@@ -667,6 +671,7 @@
"scanUse": "使用",
"scanRescan": "再スキャン",
"spiderX": "SpiderX",
"spiderXHint": "クライアントごとのシード。パネルはこれから各クライアント固有の spx パスを生成します。再生成で全員のパスを更新します",
"getNewCert": "新しい証明書を取得",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
"eventMemoryHigh": "メモリ使用率が高い (%)",
"remarkTemplate": "備考テンプレート",
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。"
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
"validation": {
"pathLeadingSlash": "パスは / で始まる必要があります"
},
"secretClear": "クリア",
"secretClearUndo": "クリアを取り消す"
},
"xray": {
"title": "Xray 設定",
@@ -1686,7 +1696,8 @@
"title": "オブザーバトリ",
"burstTitle": "バースト オブザーバトリ",
"autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。",
"emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、バランサーがアウトバウンドを測定して最適なものを選べるようになります。",
"emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、オブザーバを使うバランサーがターゲットを選ぶ前にアウトバウンドの健全性を確認できるようになります。",
"mixedLegacy": "この設定には Observatory と Burst Observatory の両方が含まれています。Xray は単一のグローバルオブザーバを使用するため、この古い混在状態はサポートされません。バランサーを保存すると 1 つのオブザーバに正規化されます。",
"subjectSelector": "監視対象のアウトバウンド",
"subjectSelectorDesc": "このオブザーバがプローブするアウトバウンドのタグ。バランサーから自動的に管理されます。",
"probeURL": "プローブ URL",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Isso atualizará o 3X-UI para #version# e reiniciará o serviço do painel.",
"panelUpdateCheckPopover": "Falha na verificação de atualização do painel",
"panelUpdateStartedPopover": "Atualização do painel iniciada",
"panelUpdateFailedTitle": "Falha ao atualizar o painel",
"panelUpdateFailedDesc": "A atualização não foi concluída com sucesso. Verifique os logs do servidor ou execute 'x-ui update' na linha de comando.",
"panelUpdateUnknownTitle": "Não foi possível confirmar se a atualização terminou",
"panelUpdateUnknownDesc": "O painel não retornou um resultado a tempo. Recarregue a página para verificar a versão atual, ou verifique os logs do servidor.",
"geofileUpdateDialog": "Você realmente deseja atualizar o geofile?",
"geofileUpdateDialogDesc": "Isso atualizará o arquivo #filename#.",
"geofilesUpdateDialogDesc": "Isso atualizará todos os arquivos.",
@@ -667,6 +671,7 @@
"scanUse": "Usar",
"scanRescan": "Reescanear",
"spiderX": "SpiderX",
"spiderXHint": "Semente por cliente — o painel deriva dela um caminho spx único para cada cliente; regenere para rotacionar os caminhos de todos",
"getNewCert": "Obter novo certificado",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
"eventMemoryHigh": "Uso de memória alto (%)",
"remarkTemplate": "Modelo de Observação",
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima."
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
"validation": {
"pathLeadingSlash": "O caminho deve começar com /"
},
"secretClear": "Limpar",
"secretClearUndo": "Desfazer limpeza"
},
"xray": {
"title": "Configurações Xray",
@@ -1686,7 +1696,8 @@
"title": "Observatório",
"burstTitle": "Observatório Burst",
"autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.",
"emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que o balanceador possa medir suas saídas e escolher a melhor.",
"emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que balanceadores com observador possam verificar a saúde das saídas antes de escolher um destino.",
"mixedLegacy": "Esta configuração contém Observatory e Burst Observatory ao mesmo tempo. O Xray usa um único observador global, então esse estado misto legado não é suportado; ao salvar os balanceadores ele será normalizado para um único observador.",
"subjectSelector": "Saídas monitoradas",
"subjectSelectorDesc": "Tags de saída que este observador sonda. Gerenciadas automaticamente a partir dos seus balanceadores.",
"probeURL": "URL de sondagem",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Это обновит 3X-UI до версии #version# и перезапустит сервис панели.",
"panelUpdateCheckPopover": "Проверка обновления панели не удалась",
"panelUpdateStartedPopover": "Обновление панели началось",
"panelUpdateFailedTitle": "Не удалось обновить панель",
"panelUpdateFailedDesc": "Обновление не завершилось успешно. Проверьте журналы сервера или выполните 'x-ui update' в командной строке.",
"panelUpdateUnknownTitle": "Не удалось подтвердить завершение обновления",
"panelUpdateUnknownDesc": "Панель не сообщила результат вовремя. Перезагрузите страницу, чтобы проверить текущую версию, или проверьте журналы сервера.",
"geofileUpdateDialog": "Вы действительно хотите обновить геофайл?",
"geofileUpdateDialogDesc": "Это обновит файл #filename#.",
"geofilesUpdateDialogDesc": "Это обновит все геофайлы.",
@@ -667,6 +671,7 @@
"scanUse": "Выбрать",
"scanRescan": "Пересканировать",
"spiderX": "SpiderX",
"spiderXHint": "Сид на клиента — панель формирует из него уникальный путь spx для каждого клиента; перегенерируйте, чтобы обновить пути всех",
"getNewCert": "Получить новый сертификат",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
"eventMemoryHigh": "Превышение порога памяти (%)",
"remarkTemplate": "Шаблон примечания",
"remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше."
"remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
"validation": {
"pathLeadingSlash": "Путь должен начинаться с /"
},
"secretClear": "Очистить",
"secretClearUndo": "Отменить очистку"
},
"xray": {
"importRules": "Импорт правил",
@@ -1686,7 +1696,8 @@
"title": "Обсерватория",
"burstTitle": "Burst-обсерватория",
"autoManaged": "Наблюдатели управляются автоматически на основе ваших балансировщиков. Ниже можно настроить, как они опрашивают; отслеживаемые исходящие следуют за селекторами балансировщика.",
"emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщик мог измерять исходящие и выбирать лучший.",
"emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщики с наблюдателем могли проверять состояние исходящих перед выбором цели.",
"mixedLegacy": "В этой конфигурации одновременно есть Observatory и Burst Observatory. Xray использует один глобальный наблюдатель, поэтому такое устаревшее смешанное состояние не поддерживается; сохранение балансировщиков нормализует его до одного наблюдателя.",
"subjectSelector": "Отслеживаемые исходящие",
"subjectSelectorDesc": "Теги исходящих, которые опрашивает этот наблюдатель. Управляется автоматически на основе ваших балансировщиков.",
"probeURL": "URL пробы",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Bu işlem 3X-UI'yi #version# sürümüne güncelleyecek ve panel servisini yeniden başlatacaktır.",
"panelUpdateCheckPopover": "Panel güncelleme kontrolü başarısız oldu",
"panelUpdateStartedPopover": "Panel güncellemesi başlatıldı",
"panelUpdateFailedTitle": "Panel güncellemesi başarısız oldu",
"panelUpdateFailedDesc": "Güncelleme başarıyla tamamlanamadı. Sunucu günlüklerini kontrol edin veya komut satırından 'x-ui update' çalıştırın.",
"panelUpdateUnknownTitle": "Güncellemenin tamamlandığı doğrulanamadı",
"panelUpdateUnknownDesc": "Panel zamanında bir sonuç bildirmedi. Geçerli sürümü kontrol etmek için sayfayı yenileyin veya sunucu günlüklerini kontrol edin.",
"geofileUpdateDialog": "Geofile'ı gerçekten güncellemek istiyor musunuz?",
"geofileUpdateDialogDesc": "Bu işlem #filename# dosyasını güncelleyecektir.",
"geofilesUpdateDialogDesc": "Bu, tüm dosyaları güncelleyecektir.",
@@ -646,6 +650,7 @@
"scanUse": "Kullan",
"scanRescan": "Yeniden tara",
"spiderX": "SpiderX",
"spiderXHint": "İstemci başına tohum — panel bundan her istemci için benzersiz bir spx yolu türetir; herkesin yolunu döndürmek için yeniden üretin",
"getNewCert": "Yeni Sertifika Al",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
"eventMemoryHigh": "Bellek kullanımı yüksek (%)",
"remarkTemplate": "Açıklama Şablonu",
"remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın."
"remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
"validation": {
"pathLeadingSlash": "Yol / ile başlamalıdır"
},
"secretClear": "Temizle",
"secretClearUndo": "Temizlemeyi geri al"
},
"xray": {
"title": "Xray Yapılandırmaları",
@@ -1686,7 +1696,8 @@
"title": "Gözlemci",
"burstTitle": "Burst Gözlemci",
"autoManaged": "Gözlemciler dengeleyicilerinize göre otomatik yönetilir. Nasıl sınama yapacaklarını aşağıdan ayarlayın; izlenen çıkışlar dengeleyici seçicilerini izler.",
"emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece dengeleyici çıkışlarınızı ölçüp en iyisini seçebilir.",
"emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece gözlemci kullanan dengeleyiciler hedef seçmeden önce çıkış sağlığını kontrol edebilir.",
"mixedLegacy": "Bu yapılandırmada hem Observatory hem de Burst Observatory var. Xray tek bir global gözlemci kullanır, bu nedenle bu eski karma durum desteklenmez; dengeleyiciler kaydedildiğinde tek bir gözlemciye normalleştirilir.",
"subjectSelector": "İzlenen Çıkışlar",
"subjectSelectorDesc": "Bu gözlemcinin sınadığı çıkış etiketleri. Dengeleyicilerinize göre otomatik yönetilir.",
"probeURL": "Sınama URL'si",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Це оновить 3X-UI до #version# та перезапустить сервіс панелі.",
"panelUpdateCheckPopover": "Перевірка оновлення панелі не вдалася",
"panelUpdateStartedPopover": "Розпочато оновлення панелі",
"panelUpdateFailedTitle": "Не вдалося оновити панель",
"panelUpdateFailedDesc": "Оновлення не завершилося успішно. Перевірте журнали сервера або виконайте 'x-ui update' у командному рядку.",
"panelUpdateUnknownTitle": "Не вдалося підтвердити завершення оновлення",
"panelUpdateUnknownDesc": "Панель не повідомила результат вчасно. Перезавантажте сторінку, щоб перевірити поточну версію, або перевірте журнали сервера.",
"geofileUpdateDialog": "Ви дійсно хочете оновити геофайл?",
"geofileUpdateDialogDesc": "Це оновить файл #filename#.",
"geofilesUpdateDialogDesc": "Це оновить усі геофайли.",
@@ -646,6 +650,7 @@
"scanUse": "Обрати",
"scanRescan": "Пересканувати",
"spiderX": "SpiderX",
"spiderXHint": "Сід на клієнта — панель формує з нього унікальний шлях spx для кожного клієнта; перегенеруйте, щоб оновити шляхи всіх",
"getNewCert": "Отримати новий сертифікат",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
"eventMemoryHigh": "Високе використання пам'яті (%)",
"remarkTemplate": "Шаблон примітки",
"remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище."
"remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
"validation": {
"pathLeadingSlash": "Шлях має починатися з /"
},
"secretClear": "Очистити",
"secretClearUndo": "Скасувати очищення"
},
"xray": {
"title": "Xray конфігурації",
@@ -1686,7 +1696,8 @@
"title": "Обсерваторія",
"burstTitle": "Burst-обсерваторія",
"autoManaged": "Спостерігачі керуються автоматично на основі ваших балансувальників. Нижче можна налаштувати, як вони опитують; відстежувані вихідні слідують за селекторами балансувальника.",
"emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальник міг вимірювати вихідні й обирати найкращий.",
"emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальники зі спостерігачем могли перевіряти стан вихідних перед вибором цілі.",
"mixedLegacy": "Ця конфігурація містить і Observatory, і Burst Observatory. Xray використовує одного глобального спостерігача, тому такий застарілий змішаний стан не підтримується; збереження балансувальників нормалізує його до одного спостерігача.",
"subjectSelector": "Відстежувані вихідні",
"subjectSelectorDesc": "Теги вихідних, які опитує цей спостерігач. Керується автоматично на основі ваших балансувальників.",
"probeURL": "URL проби",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "Điều này sẽ cập nhật 3X-UI lên #version# và khởi động lại dịch vụ panel.",
"panelUpdateCheckPopover": "Kiểm tra cập nhật panel thất bại",
"panelUpdateStartedPopover": "Bắt đầu cập nhật panel",
"panelUpdateFailedTitle": "Cập nhật panel thất bại",
"panelUpdateFailedDesc": "Bản cập nhật không hoàn tất thành công. Hãy kiểm tra nhật ký máy chủ, hoặc chạy 'x-ui update' từ dòng lệnh.",
"panelUpdateUnknownTitle": "Không thể xác nhận việc cập nhật đã hoàn tất",
"panelUpdateUnknownDesc": "Panel không báo cáo kết quả kịp thời. Hãy tải lại trang để kiểm tra phiên bản hiện tại, hoặc kiểm tra nhật ký máy chủ.",
"geofileUpdateDialog": "Bạn có chắc chắn muốn cập nhật geofile không?",
"geofileUpdateDialogDesc": "Hành động này sẽ cập nhật tệp #filename#.",
"geofilesUpdateDialogDesc": "Thao tác này sẽ cập nhật tất cả các tập tin.",
@@ -667,6 +671,7 @@
"scanUse": "Dùng",
"scanRescan": "Quét lại",
"spiderX": "SpiderX",
"spiderXHint": "Hạt giống theo từng client — bảng điều khiển suy ra đường dẫn spx riêng cho mỗi client từ đó; tạo lại để xoay đường dẫn của tất cả",
"getNewCert": "Lấy chứng chỉ mới",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
"eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
"remarkTemplate": "Mẫu ghi chú",
"remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên."
"remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
"validation": {
"pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
},
"secretClear": "Xóa",
"secretClearUndo": "Hoàn tác xóa"
},
"xray": {
"title": "Cài đặt Xray",
@@ -1686,7 +1696,8 @@
"title": "Observatory",
"burstTitle": "Burst Observatory",
"autoManaged": "Observer được quản lý tự động từ các balancer của bạn. Điều chỉnh cách chúng dò ở bên dưới; các outbound được theo dõi sẽ tuân theo selector của balancer.",
"emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để balancer có thể đo các outbound và chọn cái tốt nhất.",
"emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để các balancer dùng observer có thể kiểm tra sức khỏe outbound trước khi chọn mục tiêu.",
"mixedLegacy": "Cấu hình này có cả Observatory và Burst Observatory. Xray chỉ dùng một observer toàn cục, nên trạng thái hỗn hợp cũ này không được hỗ trợ; khi lưu balancer, nó sẽ được chuẩn hóa về một observer.",
"subjectSelector": "Outbound được theo dõi",
"subjectSelectorDesc": "Các thẻ outbound mà observer này dò. Được quản lý tự động từ các balancer của bạn.",
"probeURL": "URL dò",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。",
"panelUpdateCheckPopover": "面板更新检查失败",
"panelUpdateStartedPopover": "已开始更新面板",
"panelUpdateFailedTitle": "面板更新失败",
"panelUpdateFailedDesc": "更新未成功完成。请检查服务器日志,或在命令行运行「x-ui update」。",
"panelUpdateUnknownTitle": "无法确认更新是否已完成",
"panelUpdateUnknownDesc": "面板未能及时返回结果。请刷新页面查看当前版本,或检查服务器日志。",
"geofileUpdateDialog": "您确定要更新地理文件吗?",
"geofileUpdateDialogDesc": "这将更新 #filename# 文件。",
"geofilesUpdateDialogDesc": "这将更新所有文件。",
@@ -666,6 +670,7 @@
"scanUse": "使用",
"scanRescan": "重新扫描",
"spiderX": "SpiderX",
"spiderXHint": "按客户端的种子——面板据此为每个客户端派生唯一的 spx 路径;重新生成可轮换所有客户端的路径",
"getNewCert": "获取新证书",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
"eventMemoryHigh": "内存使用率高 (%)",
"remarkTemplate": "备注模板",
"remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。"
"remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
"validation": {
"pathLeadingSlash": "路径必须以 / 开头"
},
"secretClear": "清除",
"secretClearUndo": "撤销清除"
},
"xray": {
"importRules": "导入规则",
@@ -1686,7 +1696,8 @@
"title": "观测器",
"burstTitle": "突发观测器",
"autoManaged": "观测器会根据你的负载均衡器自动管理。可在下方调整探测方式;被观测的出站会跟随负载均衡器的选择器。",
"emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便负载均衡器测量各出站并选择最优。",
"emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便依赖观测器的负载均衡器在选择目标前检查出站健康状态。",
"mixedLegacy": "此配置同时包含 Observatory 和 Burst Observatory。Xray 只使用一个全局观测器,因此不支持这种旧式混合状态;保存负载均衡器时会将其规范化为单个观测器。",
"subjectSelector": "被观测的出站",
"subjectSelectorDesc": "该观测器探测的出站标签。根据你的负载均衡器自动管理。",
"probeURL": "探测 URL",
+13 -2
View File
@@ -231,6 +231,10 @@
"panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。",
"panelUpdateCheckPopover": "面板更新檢查失敗",
"panelUpdateStartedPopover": "面板更新已開始",
"panelUpdateFailedTitle": "面板更新失敗",
"panelUpdateFailedDesc": "更新未成功完成。請檢查伺服器日誌,或在命令列執行「x-ui update」。",
"panelUpdateUnknownTitle": "無法確認更新是否已完成",
"panelUpdateUnknownDesc": "面板未能及時回報結果。請重新整理以檢查目前版本,或檢查伺服器日誌。",
"geofileUpdateDialog": "您確定要更新地理檔案嗎?",
"geofileUpdateDialogDesc": "這將更新 #filename# 檔案。",
"geofilesUpdateDialogDesc": "這將更新所有文件。",
@@ -646,6 +650,7 @@
"scanUse": "使用",
"scanRescan": "重新掃描",
"spiderX": "SpiderX",
"spiderXHint": "各客戶端的種子——面板據此為每個客戶端衍生唯一的 spx 路徑;重新產生可輪換所有客戶端的路徑",
"getNewCert": "取得新憑證",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
@@ -1395,7 +1400,12 @@
"smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
"eventMemoryHigh": "記憶體使用率高 (%)",
"remarkTemplate": "備註範本",
"remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。"
"remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
"validation": {
"pathLeadingSlash": "路徑必須以 / 開頭"
},
"secretClear": "清除",
"secretClearUndo": "復原清除"
},
"xray": {
"title": "Xray 配置",
@@ -1686,7 +1696,8 @@
"title": "觀測器",
"burstTitle": "突發觀測器",
"autoManaged": "觀測器會根據你的負載平衡器自動管理。可在下方調整探測方式;被觀測的出站會跟隨負載平衡器的選擇器。",
"emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓負載平衡器能量測各出站並選出最佳者。",
"emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓依賴觀測器的負載平衡器能在選擇目標前檢查出站健康狀態。",
"mixedLegacy": "此設定同時包含 Observatory 與 Burst Observatory。Xray 只使用一個全域觀測器,因此不支援這種舊式混合狀態;儲存負載平衡器時會將其正規化為單一觀測器。",
"subjectSelector": "被觀測的出站",
"subjectSelectorDesc": "此觀測器探測的出站標籤。會根據你的負載平衡器自動管理。",
"probeURL": "探測 URL",
+5 -4
View File
@@ -45,10 +45,11 @@ func BroadcastTraffic(traffic any) {
}
}
// BroadcastClientStats broadcasts absolute per-client traffic counters for the
// clients that had activity in the latest collection window. Use this instead
// of re-broadcasting the full inbound list — it scales to 10k+ clients because
// the payload only includes active rows (typically a fraction of total).
// BroadcastClientStats broadcasts absolute per-client traffic counters. Small
// installs send the complete row set each cycle (payload key snapshot=true);
// above the traffic job's snapshot threshold only the rows active in the
// latest collection window are sent (snapshot=false), which keeps the payload
// under the hub's cap at any client count.
func BroadcastClientStats(stats any) {
if hub := GetHub(); hub != nil {
hub.Broadcast(MessageTypeClientStats, stats)
+1 -1
View File
@@ -83,7 +83,7 @@ func run(root, outDir string) error {
},
{
Path: resolveRel(root, "internal/web/service/panel"),
StructAllow: setOf("ApiTokenView"),
StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"),
},
}
+118 -19
View File
@@ -31,6 +31,40 @@ _fail() {
exit 2
}
# Records this run's outcome for the panel's web updater to poll, since it
# launches this script detached and has no other way to learn whether it
# finished. Written to a fixed path outside XUI_MAIN_FOLDER so it survives
# the update regardless of what happens to that folder. The EXIT trap below
# covers every exit path in this file, including the bare `exit 1`/`exit 2`
# calls that don't go through _fail.
xui_update_run_id="${XUI_UPDATE_RUN_ID:-0}"
[[ "${xui_update_run_id}" =~ ^[0-9]+$ ]] || xui_update_run_id="0"
xui_update_status_file="${XUI_UPDATE_STATUS_FILE:-/etc/x-ui/update-status.json}"
_write_update_status() {
local state="$1"
local exit_code="$2"
local status_dir
status_dir="$(dirname "${xui_update_status_file}")"
mkdir -p "${status_dir}" > /dev/null 2>&1
local tmp_file="${xui_update_status_file}.tmp.$$"
printf '{"runId":"%s","state":"%s","exitCode":%s,"finishedAt":%s}\n' \
"${xui_update_run_id}" "${state}" "${exit_code}" "$(date +%s)" > "${tmp_file}" 2> /dev/null
mv -f "${tmp_file}" "${xui_update_status_file}" > /dev/null 2>&1
}
_report_update_exit() {
local code=$?
if [[ "${code}" -eq 0 ]]; then
_write_update_status "success" "0"
else
_write_update_status "failed" "${code}"
fi
}
trap _report_update_exit EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
# check root
[[ $EUID -ne 0 ]] && _fail "FATAL ERROR: Please run this script with root privilege."
@@ -149,13 +183,13 @@ install_base() {
apt-get update > /dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
dnf makecache -y > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update > /dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
else
dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
dnf makecache -y > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
fi
;;
arch | manjaro | parch)
@@ -223,7 +257,7 @@ setup_ssl_certificate() {
fi
# Install certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
~/.acme.sh/acme.sh --installcert --force -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem \
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
@@ -361,7 +395,7 @@ setup_ip_certificate() {
# Install certificate
# 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.
~/.acme.sh/acme.sh --installcert -d ${ipv4} \
~/.acme.sh/acme.sh --installcert --force -d ${ipv4} \
--key-file "${certDir}/privkey.pem" \
--fullchain-file "${certDir}/fullchain.pem" \
--reloadcmd "${reloadCmd}" 2>&1 || true
@@ -518,7 +552,7 @@ ssl_cert_issue() {
# install the certificate
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 \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$?
@@ -881,6 +915,40 @@ setup_fail2ban() {
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_bin} -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
}
update_x-ui() {
cd ${xui_folder%/x-ui}/
@@ -911,6 +979,10 @@ update_x-ui() {
if [[ $? -ne 0 ]]; then
_fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
fi
if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
rm ${xui_folder}-linux-$(arch).tar.gz -f > /dev/null 2>&1
_fail "ERROR: Downloaded x-ui release archive is empty, please be sure that your server can access GitHub"
fi
if [[ -e ${xui_folder}/ ]]; then
echo -e "${green}Stopping x-ui...${plain}"
@@ -961,8 +1033,15 @@ update_x-ui() {
echo -e "${green}Installing new x-ui version...${plain}"
tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
_fail "ERROR: 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 update again"
fi
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
cd x-ui > /dev/null 2>&1
if [[ $? -ne 0 || ! -s x-ui ]]; then
_fail "ERROR: 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 update again"
fi
chmod +x x-ui > /dev/null 2>&1
# Check the system's architecture and rename the file accordingly
@@ -974,10 +1053,22 @@ update_x-ui() {
chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1
echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
local xui_script_temp="/usr/bin/x-ui-temp.$$"
rm -f "${xui_script_temp}"
${curl_bin} -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
rm -f "${xui_script_temp}"
_fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
fi
if [[ ! -s "${xui_script_temp}" ]]; then
rm -f "${xui_script_temp}"
_fail "ERROR: Downloaded x-ui.sh script is empty, please be sure that your server can access GitHub"
fi
mv -f "${xui_script_temp}" /usr/bin/x-ui
if [[ $? -ne 0 ]]; then
rm -f "${xui_script_temp}"
_fail "ERROR: Failed to install x-ui.sh script"
fi
chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1
chmod +x /usr/bin/x-ui > /dev/null 2>&1
@@ -993,10 +1084,22 @@ update_x-ui() {
if [[ $release == "alpine" ]]; then
echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
rm -f "${xui_rc_temp}"
${curl_bin} -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
rm -f "${xui_rc_temp}"
_fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
fi
if [[ ! -s "${xui_rc_temp}" ]]; then
rm -f "${xui_rc_temp}"
_fail "ERROR: Downloaded startup unit x-ui.rc is empty, please be sure that your server can access GitHub"
fi
mv -f "${xui_rc_temp}" /etc/init.d/x-ui
if [[ $? -ne 0 ]]; then
rm -f "${xui_rc_temp}"
_fail "ERROR: Failed to install startup unit x-ui.rc"
fi
chmod +x /etc/init.d/x-ui > /dev/null 2>&1
chown root:root /etc/init.d/x-ui > /dev/null 2>&1
rc-update add x-ui > /dev/null 2>&1
@@ -1004,8 +1107,7 @@ update_x-ui() {
else
if [ -f "x-ui.service" ]; then
echo -e "${green}Installing systemd unit...${plain}"
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
if ! _install_xui_service_unit "x-ui.service" "false"; then
echo -e "${red}Failed to copy x-ui.service${plain}"
exit 1
fi
@@ -1015,8 +1117,7 @@ update_x-ui() {
ubuntu | debian | armbian)
if [ -f "x-ui.service.debian" ]; then
echo -e "${green}Installing debian-like systemd unit...${plain}"
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service.debian" "false"; then
service_installed=true
fi
fi
@@ -1024,8 +1125,7 @@ update_x-ui() {
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
echo -e "${green}Installing arch-like systemd unit...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service.arch" "false"; then
service_installed=true
fi
fi
@@ -1033,8 +1133,7 @@ update_x-ui() {
*)
if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}Installing rhel-like systemd unit...${plain}"
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
if _install_xui_service_unit "x-ui.service.rhel" "false"; then
service_installed=true
fi
fi
@@ -1046,17 +1145,17 @@ update_x-ui() {
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
case "${release}" in
ubuntu | debian | armbian)
${curl_bin} -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)
${curl_bin} -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_bin} -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
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}"
exit 1
fi
+70 -19
View File
@@ -173,6 +173,41 @@ update_dev() {
fi
}
replace_xui_script() {
local url="$1"
local use_if_modified_since="$2"
local temp_file="/usr/bin/x-ui-temp.$$"
rm -f "$temp_file"
if [[ "$use_if_modified_since" == "true" ]]; then
curl -fLRo "$temp_file" -z /usr/bin/x-ui "$url"
else
curl -fLRo "$temp_file" "$url"
fi
if [[ $? != 0 ]]; then
rm -f "$temp_file"
return 1
fi
if [[ ! -s "$temp_file" ]]; then
rm -f "$temp_file"
# -z above means "not modified since /usr/bin/x-ui" rather than a
# real failure, so an empty download here is success, not an error.
[[ "$use_if_modified_since" == "true" ]] && return 0
return 1
fi
mv -f "$temp_file" /usr/bin/x-ui
if [[ $? != 0 ]]; then
rm -f "$temp_file"
return 1
fi
# The move already landed the new script; a transient chmod failure here
# shouldn't make callers think the whole replace failed.
chmod +x /usr/bin/x-ui
return 0
}
update_menu() {
echo -e "${yellow}Updating Menu${plain}"
confirm "This function will update the menu to the latest changes." "y"
@@ -184,11 +219,8 @@ update_menu() {
return 0
fi
curl -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
chmod +x ${xui_folder}/x-ui.sh
chmod +x /usr/bin/x-ui
if [[ $? == 0 ]]; then
if replace_xui_script "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh" "false"; then
chmod +x ${xui_folder}/x-ui.sh
echo -e "${green}Update successful. The panel has automatically restarted.${plain}"
exit 0
else
@@ -804,14 +836,12 @@ enable_bbr() {
}
update_shell() {
curl -fLRo /usr/bin/x-ui -z /usr/bin/x-ui https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh
if [[ $? != 0 ]]; then
echo ""
LOGE "Failed to download script, Please check whether the machine can connect Github"
if replace_xui_script "https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh" "true"; then
LOGI "Upgrade script succeeded, Please rerun the script"
before_show_menu
else
chmod +x /usr/bin/x-ui
LOGI "Upgrade script succeeded, Please rerun the script"
echo ""
LOGE "Failed to download script, Please check whether the machine can connect Github"
before_show_menu
fi
}
@@ -1198,22 +1228,43 @@ update_geofiles() {
dat_files=(geoip_RU geosite_RU)
dat_source="runetfreedom/russia-v2ray-rules-dat"
;;
*)
echo -e "${red}update_geofiles: unknown dataset '${1}'${plain}"
return 1
;;
esac
local failed=0 http_code
for dat in "${dat_files[@]}"; do
# Remove suffix for remote filename (e.g., geoip_IR -> geoip)
remote_file="${dat%%_*}"
# -z skips the download (server answers 304) when the local copy is already current
http_code=$(curl -sSfLRo ${xui_folder}/bin/${dat}.dat -z ${xui_folder}/bin/${dat}.dat -w '%{http_code}' \
local dest="${xui_folder}/bin/${dat}.dat"
local temp_file="${dest}.tmp.$$"
rm -f "$temp_file"
# -z (against the live file, not the temp file) skips the download
# (server answers 304) when the local copy is already current.
http_code=$(curl -sSfLRo "$temp_file" -z "$dest" -w '%{http_code}' \
https://github.com/${dat_source}/releases/latest/download/${remote_file}.dat)
if [[ $? -ne 0 ]]; then
echo -e "${red}${dat}.dat: download failed${plain}"
rm -f "$temp_file"
failed=1
elif [[ "$http_code" == "304" ]]; then
echo -e "${dat}.dat: already up to date"
rm -f "$temp_file"
elif [[ ! -s "$temp_file" ]]; then
echo -e "${red}${dat}.dat: downloaded file is empty${plain}"
rm -f "$temp_file"
failed=1
else
echo -e "${green}${dat}.dat: updated${plain}"
geo_updated=1
mv -f "$temp_file" "$dest"
if [[ $? -ne 0 ]]; then
echo -e "${red}${dat}.dat: failed to install${plain}"
rm -f "$temp_file"
failed=1
else
echo -e "${green}${dat}.dat: updated${plain}"
geo_updated=1
fi
fi
done
return $failed
@@ -1435,7 +1486,7 @@ ssl_cert_issue_main() {
# renewed cert to these paths and reloads the panel. Without it acme.sh
# renews but never updates /root/cert, silently serving a stale cert.
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
~/.acme.sh/acme.sh --installcert -d "${domain}" \
~/.acme.sh/acme.sh --installcert --force -d "${domain}" \
--key-file "${webKeyFile}" \
--fullchain-file "${webCertFile}" \
--reloadcmd "x-ui restart" 2>&1 || true
@@ -1631,7 +1682,7 @@ ssl_cert_issue_for_ip() {
# Install the certificate
# 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.
~/.acme.sh/acme.sh --installcert -d ${server_ip} \
~/.acme.sh/acme.sh --installcert --force -d ${server_ip} \
--key-file "${certPath}/privkey.pem" \
--fullchain-file "${certPath}/fullchain.pem" \
--reloadcmd "${reloadCmd}" 2>&1 || true
@@ -1836,7 +1887,7 @@ ssl_cert_issue() {
# install the certificate
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 \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$?
@@ -1998,7 +2049,7 @@ ssl_cert_issue_CF() {
;;
esac
fi
~/.acme.sh/acme.sh --installcert -d ${CF_Domain} -d *.${CF_Domain} \
~/.acme.sh/acme.sh --installcert --force -d ${CF_Domain} -d *.${CF_Domain} \
--key-file ${certPath}/privkey.pem \
--fullchain-file ${certPath}/fullchain.pem --reloadcmd "${reloadCmd}"