mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 13:46:08 +00:00
43500a5470fea721123909cffca760651e70b266
453 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
43500a5470 |
feat(mtproto): per-client ad-tags, management-API auth, and record secret sync
Catch the panel up to the mtg-multi README (v1.14.0): - Each client can now carry its own 32-hex advertising tag overriding the inbound-level one. The tag lives on the client (settings JSON is the source of truth, clients.ad_tag is the UI projection), is rendered into the fork's [secret-ad-tags] section for active secrets only (mtg rejects a config whose override names an unknown secret), is pushed per entry through PUT /secrets, and is part of the reload fingerprint so a tag edit hot-applies without dropping connections. - The loopback management API can replace the whole secret set, so every mtg process now gets a random per-process api-token; the manager sends it as a bearer token on PUT /secrets and GET /stats and reuses it across config rewrites, because mtg reads the token only at startup. - Malformed tags are rejected at every save path and additionally dropped in InstanceFromInbound: one bad tag would otherwise fail the whole generated config and take every client of the inbound down with it. - SyncInbound never copied a re-keyed mtproto secret into the canonical clients table, so the clients page and subscription links kept serving the old secret, which mtg then rejects. It is now guarded-copied like the other credentials. |
||
|
|
6214ff4edc |
fix(mtproto): stop dropping connections on client/inbound edits; add live updates + ad-tag (#5838)
* fix(mtproto): split the mtg fingerprint into structural and secrets parts A reordered clients array in the stored settings used to read as a config change because the fingerprint concatenated secrets in array order, and one opaque fingerprint could not tell a restart-worthy change (bind address, fronting, throttle) from a secret-set change a reload-capable mtg can absorb in place. Sort the secret pairs so order stops mattering, and split the value so the upcoming hot-reload path can decide between keeping, reloading, and restarting the process. * fix(mtproto): stop restarting mtg on every inbound edit Saving an mtproto inbound tore down and respawned its mtg sidecar even when nothing material changed, dropping every live Telegram connection: the update path pushed DelInbound+AddInbound, and Remove deletes the manager's map entry, so Ensure's fingerprint no-op gate could never fire. Route mtproto updates through a single Ensure call so an edit that leaves the generated TOML alone keeps the process, and only real config changes restart it. Capturing the pre-edit protocol also fixes a latent leak: changing an inbound's protocol away from mtproto never stopped the sidecar, because the snapshot handed to the runtime already carried the new protocol and the removal took the xray branch, leaving an orphaned mtg holding the port. An mtproto push failure no longer requests an xray restart - xray cannot fix the sidecar, and the 10s reconcile job self-heals it. The regression test fakes mtg by re-executing the test binary, counting spawns through a pid file: an unchanged save and a remark-only edit must keep the process, a re-keyed secret must restart it. * fix(mtproto): exclude depleted clients from the reconcile job to match the sync push The 10s reconcile job derived mtg secret sets from raw inbound settings while the interactive push filtered clients through buildRuntimeInboundForAPI, which drops client_traffics-disabled (depleted or expired) clients. The two paths therefore disagreed on the fingerprint - each disagreement one needless mtg restart dropping live connections - and worse, the job kept serving depleted clients' secrets indefinitely, so running out of traffic never actually cut an mtproto client's access. DesiredMtprotoInstances now builds the job's desired state with the same depletion overlay the push uses (one bulk client_traffics query), drops inbounds whose every secret is filtered away so their sidecar stops, and AddInbound pushes the filtered payload too so an imported inbound carrying disabled stats does not seed a fingerprint the next reconcile disagrees with. * feat(mtproto): hot-reload mtg secrets in place instead of restarting A client add, removal, re-key, or enable-toggle changes only the [secrets] section of the generated config, yet the panel could apply it only by killing and respawning the mtg sidecar, dropping every Telegram connection on that inbound. Split the ensure decision three ways: an identical config is a no-op, a secrets-only change rewrites the TOML on the same api port and asks mtg to hot-swap it via POST /reload, and a structural change (or a failed reload) falls back to the full stop-and-start. The reload endpoint is served by the mhsanaei/mtg-multi fork; against an older binary the POST 404s and the manager restarts exactly as before, so panel and binary upgrades stay order-independent. * feat(mtproto): apply single-client edits to the sidecar immediately Client CRUD on an mtproto inbound was a runtime no-op, so an add, delete, re-key, or enable-toggle only reached mtg on the next 10s reconcile. With the sidecar now able to hot-reload, push the change straight after the edit commits: applyLocalMtproto rebuilds the inbound's filtered client set and re-applies it, so a new client works within a moment (and, on a reload-capable binary, without disturbing the others) and deleting the last client stops the process. The three interactive single-client paths (add, update, delete) call it; bulk operations still ride the reconcile job, which converges to the same state. * chore(mtproto): pin mtg-multi to the mhsanaei fork v1.13.3 The reload endpoint the panel now uses lives in the mhsanaei/mtg-multi fork, so point the source-build pin (DockerInit.sh + both release.yml matrices) at it and bump to v1.13.3. The install still produces the same mtg-multi binary name, so the mtg-<os>-<arch> rename and everything downstream are unchanged. Docs and the package comment note the hot-reload path and its restart fallback. * feat(mtproto): apply live secret updates via the management API and add ad-tag Two capabilities the mhsanaei/mtg-multi v1.13.3 fork exposes are now surfaced by the sidecar manager. Live updates go through PUT /secrets on the fork's management API instead of POST /reload: the panel already holds the whole desired set per inbound, so it sends secrets and the advertising tag as one JSON call that mtg applies atomically, keeping every unchanged connection and closing only removed or re-keyed ones. The config file is still written first so a restart or crash recovery reproduces the state, and any non-200 (an older binary, a refused connection) still falls back to a full restart. Per-inbound ad-tag adds an optional 32-hex Telegram advertising tag plus public-ipv4/public-ipv6 overrides. The ad-tag rides the reloadable secrets fingerprint, so changing it hot-applies without dropping connections; the public IPs are proxy-construction parameters and sit in the structural fingerprint, so a change there restarts the process. Empty public IPs are omitted so mtg auto-detects the reachable address. * feat(inbounds): expose the mtproto ad-tag and public IP in the inbound form Adds an Ad-tag field (validated as 32 hex characters) plus optional Public IPv4 and Public IPv6 overrides to the MTProto inbound form, backed by the same-named settings the sidecar writes into the mtg config. The public IPs are optional — left blank, mtg auto-detects the reachable address the ad-tag middle proxy needs. English strings are added to every locale; the non-English ones carry the English text until translated and fall back to it meanwhile. * ci(mtproto): install mtg-multi from prebuilt release binaries The fork now publishes release archives for every platform we package, so download and unpack the matching mtg-multi-<ver>-<os>-<arch> binary instead of compiling it from source with go install. Faster builds and no toolchain step, and the archive's platform labels line up with our matrix; the produced mtg-<os>-<arch> filenames are unchanged. * i18n(mtproto): localize the ad-tag and public IP strings The six mtgAdTag*/mtgPublicIp* keys shipped with English text in every locale as a placeholder. Translate them into the twelve non-English locales (Arabic, Spanish, Persian, Indonesian, Japanese, Portuguese-BR, Russian, Turkish, Ukrainian, Vietnamese, and Simplified/Traditional Chinese); en-US is unchanged. * retired goreportcard.com |
||
|
|
27fd19895a |
fix(mtproto): drop the remark fragment from tg proxy deep links
genMtprotoLink appended the panel remark as a URL fragment (tg://proxy?...&secret=...#remark). Because secret/server is the last query value, lenient Telegram parsers fold the "#remark" into it and the imported proxy breaks with "incorrect client random". Telegram proxy deep links have no name field, so emit a clean link on both the backend (internal/sub) and frontend (inbound-link.ts). The remark still shows as a separate tag in the inbound info modal, which reads it from genAllLinks, not the URL. Guards: Go TestGenMtprotoLinkFields asserts no fragment; the frontend mtproto link test asserts no '#'. |
||
|
|
a1ca43d869 |
chore(gen): refresh generated schemas after Client.Secret comment drop
Commit
|
||
|
|
d97bd8643e |
feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client
Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound. The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates. |
||
|
|
ed66209e38 |
feat(outbound): add real-delay connection test mode
The HTTP probe reports the warm per-request round-trip, which reads lower than the delay figure client apps show for the same server. Add a third "real" test mode that reuses the temp-instance HTTP probe but reports the cold request's full elapsed time - tunnel establishment included - and skips the warm request. UDP-transport outbounds forced out of the TCP lane still report "http"; in real mode they report "real". The mode joins the TCP/HTTP toggle on the outbounds tab, with the label translated in all 13 locales. |
||
|
|
9d1a21b484 |
fix(ui): keep an explicit zero happy-eyeballs delay across the round trip
Follow-up found in review: the wire normalizer still stripped
tryDelayMs when it equaled 0, but with the schema default now 250 a
reload rehydrates the missing field as 250 - a user who explicitly set
0 ("disabled", per the field's own placeholder) would see 250 and any
subsequent save would silently enable a delay they turned off. Keep
tryDelayMs on the wire unconditionally; it is the one happy-eyeballs
field whose presence changes xray's behavior.
Refs #5780
|
||
|
|
0753f5ee83 |
fix(link): reject non-finite and clamp out-of-range quicParams from fm=
Follow-up hardening of the fm= sanitizer found in review. ParseFloat accepts "inf"/"NaN", and a non-finite float64 makes json.Marshal fail later - the subscription refresh discards that error and blanks the stored outbound set, so one poisoned link could wipe a subscription's outbounds. Values that coerce fine but sit outside xray-core's accepted ranges (keepAlivePeriod 0 or 2-60, maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8) still killed the config load, and huge magnitudes serialize in exponent notation that xray's integer fields reject. Coerced values are now stored as integers, clamped into the accepted ranges, and dropped when negative, non-finite, or absurdly large; the TS import parser mirrors the same rules. Refs #5783 |
||
|
|
b6873c7a73 |
fix(outbound): measure HTTP test delay on a warm connection
Since the batched prober replaced the single tester, the reported delay came from one cold request with keep-alives disabled, so it stacked the SOCKS handshake, proxy dial, proxy TLS, target TCP and target TLS on top of the round-trip. Users upgrading from v2.9.4 - whose tester warmed the connection first and timed a second request - saw several times the real connection time. The cold request still proves reachability and supplies the HTTP status plus the connect/TLS/TTFB breakdown; the delay is now re-measured on a second request over the kept-alive connection, falling back to the cold total when the warm request fails. Bodies are drained (bounded) so the connection returns to the pool, and the batch test asserts both requests of a probe share one connection. |
||
|
|
11e45e81b6 |
fix(link): sanitize numeric quicParams taken from a share link's fm= param
The fm= finalmask blob was JSON-decoded and attached to streamSettings verbatim, both by the Go parser (outbound subscriptions) and the frontend import. Some providers emit duration strings for the strictly integer quicParams fields (e.g. keepAlivePeriod "10s"), and xray-core then refuses to load the whole config at startup - one bad subscription entry took the panel's Xray down on the next refresh. Coerce numeric strings, convert duration strings to whole seconds, and drop values that cannot be represented as integers; genuinely string-typed fields (congestion, bbrProfile, brutalUp/Down, udpHop) pass through untouched. Closes #5783 |
||
|
|
579a9daaa0 |
fix(ui): make the Happy Eyeballs toggle produce a config xray actually enables
Toggling Happy Eyeballs on filled the object with schema defaults, and tryDelayMs defaulted to 0. That broke the feature twice over: xray-core treats tryDelayMs=0 as happy-eyeballs-off, and the wire normalizer strips every field that equals its default, leaving an empty object it then deletes - so the switch silently flipped back off on reopen (the "disabled when Prefer IPv6 is off" symptom; prioritizeIPv6=true was the one non-default that let the object survive). Default tryDelayMs to the recommended 250ms so an enabled config survives serialization and is functional in the core. Closes #5780 |
||
|
|
0add63984f |
fix(ui): align the subUpdates limit with the backend and show the range
The hand-written settings schema capped subUpdates at 168 while the backend (and the generated schema mirrored from it) accepts 0-525600. Anyone upgrading from 2.x with a stored value above 168 could no longer save any settings tab: the whole settings object is validated on every save, so the stale field blocked everything with an unexplained "Invalid input". Match the backend bounds and put them on the input so the limit is discoverable. Closes #5821 |
||
|
|
b177e30714 |
feat(ui): client-realtime-speed (#5687)
* refactor(inbounds): extract TRAFFIC_POLL_INTERVAL_S to shared util * feat(clients): derive per-client live speed from traffic WebSocket deltas * feat(clients): render speed column and mobile card line * i18n(clients): add pages.clients.speed key to all 13 locales |
||
|
|
05cb70d8a8 |
feat(frontend): add text search to the inbound list
The v2.x panel could filter inbounds but the list page only had the node dropdown. Add a search box next to it matching on remark, port, and protocol, composed with the node filter; the dataset is already client-side, so no API change. Closes #5267 |
||
|
|
323cf09d10 |
feat(sub): show the announcement on the subscription info page
The subAnnounce setting was only emitted as a base64 Announce response header, which most client apps ignore and browsers never see. Pass it into the sub page view-model and render it as an info alert at the top of the card; custom themes get the announce key for free. Closes #5276 |
||
|
|
052dd85ad3 |
feat(clients): hide disabled inbounds in the client form selector
The attach-inbounds select in the client add/edit modal listed every inbound, so panels with many disabled inbounds had to scroll past dead entries. InboundOption now carries the inbound's enable flag and the form drops disabled inbounds from the options, keeping ones the client is already attached to so edit mode still renders existing assignments. Closes #5645 |
||
|
|
dd4f55f690 |
feat(frontend): add text search to node select components
Typing in the Deploy To select of the inbound form and the node filter select on the inbound list now filters nodes by label, matching the showSearch convention used elsewhere (NodeFormModal, HostFormModal). With 20+ nodes, scrolling was the only way to find one. Closes #5743 |
||
|
|
f90e4a6962 |
fix(panel): use the hosting node address for WireGuard client configs (#5679)
* fix(panel): use the hosting node address for WireGuard client configs The clients page rendered a node-managed WireGuard inbound's config with the master panel's host in Endpoint instead of the hosting node's address, so the copied/QR config pointed at the wrong server. The subscription path already resolves this via resolveInboundAddress; the UI generator did not. Expose the share-host resolution inputs (node address, listen, share-address strategy/address) on InboundOption and route buildWireguardClientConfig through the same canonical resolver the inbounds-page share links use, extracted as resolveShareHost. This also brings local inbounds with a shareable listen or a listen/custom share strategy into parity with the subscription Endpoint; the common listen=0.0.0.0 case still falls back to the panel host. * fix(frontend): keep a raw fallback host and refresh node-fed inbound options Code review of the WireGuard node-endpoint change surfaced two gaps. resolveShareHost normalized its last-resort fallbackHostname, so a panel reached via a hostname the share-host grammar rejects (underscore label, trailing-dot FQDN) emitted a broken 'Endpoint = :51820'; the fallback now stays verbatim when normalization empties it. Node mutations only invalidated the nodes query, leaving the staleTime-Infinity inbound options cache serving an edited node address until the sync job broadcast (never, for disabled/offline nodes); they now invalidate the options key too. Also folds the ShareHostFields projections into direct structural passes, elides the default node shareAddrStrategy so omitempty drops it, and replaces the nullable node-address scan with COALESCE. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
1afab47f04 |
feat(frontend): show client group in the client info modal
The group label was already on ClientRecord but the info modal never displayed it. Add a conditional row next to the comment, rendered as a geekblue tag to match the group column in the clients table. |
||
|
|
258d8b7344 |
feat(frontend): add targetStrategy field to the outbound editor
Xray-core added a top-level targetStrategy to OutboundObject that controls how the destination domain is resolved before dialing (AsIs/UseIP*/ForceIP*, any protocol). The panel neither offered a control for it nor preserved the key across the modal's JSON round trip, so hand-written values were silently dropped on save. The form now carries targetStrategy next to sendThrough as a select of the 11 canonical values; the adapter normalizes wire values to canonical case (the core matches case-insensitively) and omits the key when unset. Freedom settings additionally read the new settings-level targetStrategy with domainStrategy as fallback, mirroring the core, while still emitting the legacy domainStrategy key so configs keep working on older cores. |
||
|
|
9f760cf0fa |
fix(frontend): stop group modals clearing selection on background refetch
The reset effect in GroupAddClientsModal and GroupRemoveClientsModal depended on the memoized rows, which are rebuilt whenever GroupsPage re-renders because candidates/members are inline-filtered arrays. The 5s client-list poll re-renders the page, so any selection made in the modal was wiped a few seconds later. Reset only when the modal opens. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
64c306037f |
feat(wireguard): make client allowedIPs editable with validation
The WireGuard peer address was allocated server-side and shown read-only in the client editor, so changing it required hand-editing the inbound's raw settings JSON (#5715). The backend add/update paths already honored a submitted allowedIPs; only the form withheld it. Make the field editable (comma-separated, empty still auto-assigns) and validate submissions server-side: entries must parse as an IP or CIDR, bare addresses normalize to single-host prefixes, and an address already used by another peer on the inbound is rejected. Closes #5715 |
||
|
|
539bcc897c |
fix(inbounds): apply the legacy xhttp session-key migration when editing
rawInboundToFormValues injected the stored xhttpSettings blob into the form store without running it through XHttpStreamSettingsSchema, so the sessionPlacement/sessionKey -> sessionIDPlacement/sessionIDKey rename from xray-core v26.6.22 (and the v3.4.0 field defaults) never applied on the edit path. Inbounds saved before the rename opened with blank session fields, and the stale keys could ride back on save even though the core no longer reads them. Parse the sub-object through the schema on load, and lift any stale legacy keys in normalizeXhttpForWire as a backstop. Closes #5621 |
||
|
|
8332ba67ae |
chore(deps): bump antd to 6.5 and migrate deprecated component props
Upgrade frontend deps (antd 6.4.5 -> 6.5.0, Ant Design icons, TanStack Query, i18next, eslint) and fasthttp 1.71 -> 1.72. AntD 6.5 deprecated several Input/Card/Space props, so adapt the panel UI: - Input/InputNumber addonBefore/addonAfter -> prefix/suffix - Card bordered -> variant="outlined" - Space direction -> orientation - swap the hand-rolled Telegram SVG for the new TelegramFilled icon - guard SettingListItem against cloning aria-labelledby onto a Fragment, which only accepts key/children |
||
|
|
d8221a8153 |
fix(sub): bake Host VLESS Route into subscription UUIDs
The Host VLESS Route field was stored and shown in the panel but never applied to any generated subscription (raw, JSON, Clash), so the UUID was emitted unmodified (#5655). Xray reads the route from the UUID's 3rd group (bytes 6-7, net.PortFromBytes) and masks those bytes to zero before authenticating, so a value can be baked into the share/JSON/Clash UUIDs without breaking the user match. A shared applyVlessRoute helper encodes a single 0-65535 value as the 3rd group; empty/invalid/non-UUID input is left unchanged, so legacy data never yields a broken link and no DB migration is needed. The field was wrongly validated as a multi-segment port spec (that form belongs to the separate server-side routing rule). It is now a single value 0-65535, with frontend validation, link-preview parity (genVlessLink/hostToExternalProxyEntry), hint + error translations across all 13 locales, and tests on every path. Closes #5655 |
||
|
|
789e92cddc |
fix(clients): re-enable depleted clients on API renewal (#5619)
Renewing a subscription via POST /panel/api/clients/bulkAdjust extended a client's expiry/quota but left it disabled. The enforcement loop disables a depleted client across client_traffics, client_records and the inbound settings JSON (and pushes that to the node), while BulkAdjust only updated expiry/total and never cleared enable. On a node its UpdateUser push was built from the stale ClientRecord (Enable=false), which the next traffic poll merged back onto the master, so the client never recovered. BulkAdjust now re-enables a client only when it was disabled because it was depleted and the adjustment lifts it back within limits, computed as a set-difference of the production depletedCond predicate and applied through the canonical BulkSetEnable (run after the per-inbound loop, since lockInbound is non-reentrant). Manually-disabled or still-depleted clients stay disabled. Update now writes the clients.enable column explicitly so re-enabling sticks for inbound-less clients and stops feeding a stale record into node pushes. |
||
|
|
7a5d6da28c |
fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648)
* feat(xray): reference-cleanup helpers for entity deletion
When an outbound or balancer is deleted on the Xray page, routing rules and
balancers that reference it must be repaired in the same edit, or the saved
config breaks the core: a dangling balancerTag stops Router.Init (whole core
down), a dangling outboundTag black-holes matched traffic at the dispatcher.
Add pure plan*/apply* helpers that compute and apply the cleanup. A rule is
kept when a destination (outboundTag or balancerTag) remains and dropped when
none does. Deleting an outbound cascades: emptying a balancer selector removes
that balancer too, then repairs its rules in one pass against the full removed
set; fallbackTag and dialerProxy references are cleared and observatories
re-synced.
* fix(balancers): clean routing rules referencing a deleted balancer
Deleting a balancer left routing rules pointing at its balancerTag. xray-core's
Router.Init then fails ("balancer <tag> not found"), the core won't restart and
every inbound drops — the saved config passes CheckXrayConfig (JSON shape only),
so it breaks only on the next restart.
The delete confirm now lists the affected rules (modified vs removed) next to
the existing observatory warning and applies planBalancerDeletion's cleanup: a
rule keeps its outboundTag when present, otherwise the whole rule is dropped.
Adds the shared DeletionImpactList and refCleanup strings across all 13 locales.
* fix(outbounds): clean rules, balancer selectors and dialerProxy on outbound delete
Deleting an outbound left routing rules pointing at its outboundTag (matched
traffic black-holed at the dispatcher), plus stale references in balancer
selectors / fallbackTag and other outbounds' dialerProxy.
The delete confirm now shows planOutboundDeletion's impact and applies the
cascade: rules keep a remaining balancerTag (else are dropped), the tag is
pulled from balancer selectors and fallbacks, dialerProxy references are
cleared, and a balancer whose selector is emptied is removed along with its
own now-targetless rules.
* refactor(xray): share one rule classifier across preview and apply
Code review flagged that the keep/drop predicate was transcribed twice — in
ruleImpacts (the delete-modal preview) and in applyCleanup (the mutation) — kept
in sync only by a parity test. Extract a single classifyRule() that both call,
so the preview can never disagree with what apply actually does.
Also harden balancersEmptiedBy to skip tagless balancers: an empty/missing tag
would otherwise enter the removed set as "" and silently drop every other
tagless balancer (only reachable via a hand-edited config, but a silent data
loss). And remove observersRemovedByDeletingBalancer, orphaned once BalancersTab
switched to planBalancerDeletion.
* fix(xray): null-guard reference cleanup against unvalidated configs
The PR review noted that classifyRule and applyCleanup dereferenced rule /
balancer entries directly, while the sibling propagateOutboundTagRename uses
optional chaining — because fetchXrayConfig falls back to the unvalidated parsed
object when Zod validation fails, a stray null in rules / balancers can survive
into the editor and would throw during the delete preview/apply.
Match that defensive style: classifyRule and balancersEmptiedBy read through
optional chaining, the balancer loop skips nullish entries, and the dialerProxy
walk guards the outbound. A delete on a hand-edited config with null entries now
degrades gracefully instead of throwing.
|
||
|
|
71aca2018a |
feat(a11y): screen-reader & keyboard accessibility across the panel (#5486) (#5652)
* feat(a11y): label list, toolbar & dashboard actions for screen readers Phase 1 of #5486 (Android TalkBack support). Icon-only controls across the management surfaces previously announced only their untranslated icon name (e.g. "edit", "ellipsis") or nothing at all. - Add aria-label to icon-only row-action and toolbar buttons across inbounds, clients, groups, hosts, nodes and xray (outbounds/routing/dns/balancers) lists, plus the dashboard cards. - Make clickable bare icons and AntD Card actions keyboard-operable via role/tabIndex + Enter/Space (new activateOnKey helper); convert mobile dropdown triggers to buttons so they open from the keyboard. - Fix the sidebar hamburger's mislabeled aria-label (was the dashboard label) and translate previously-hardcoded outbound menu labels. New i18n keys in all 13 locales: sort, menu.openMenu, pages.xray.outbound.moveToTop. * feat(a11y): label modal, QR and copy/download controls for screen readers Phase 2 of #5486. Modal and overlay controls relied on tooltips (not a reliable accessible name) or were bare clickable icons with no keyboard or screen-reader support. - Add aria-label to copy/QR/download/info icon buttons in the inbound and client info modals, sub-links modal, QR panel, backup/log modals, and to the bare search/select inputs of the attach/detach client modals. - Make click-to-copy QR codes and the IP-log refresh/clear, geofile reload and log refresh icons keyboard-operable (role/tabIndex + Enter/Space) with translated labels. - Label the 2FA code input; drop the QrPanel download-image string fallback now that the key exists. New i18n key in all 13 locales: downloadImage. * feat(a11y): label form fields and shared form components for screen readers Phase 3 of #5486. Form controls and shared form widgets were largely unlabelled, and several remove controls were not keyboard-operable. - SettingListItem now ties its title to the control via aria-labelledby, giving accessible names to the ~90 settings-tab inputs at once. - InputAddon gains button semantics (role/tabIndex/Enter+Space) and an ariaLabel prop when used as an interactive remove control. - Sparkline charts expose a role="img" summary of their latest values. - Add aria-label to add/remove/regenerate icon buttons and bare inputs/selects across inbound, client and xray (dns/routing/balancer/ outbound) forms; make clickable remove icons keyboard-operable; mark decorative help/target icons aria-hidden; label the JSON editor, date-time clear button, header-map remove, notification select-all and remark token chips. New i18n keys in all 13 locales: regenerate, jsonEditor, pages.xray.balancer.{costMatch,costValue,costRegexp}. * chore(a11y): add eslint-plugin-jsx-a11y harness and fix flagged interactions Phase 4 of #5486. Adds eslint-plugin-jsx-a11y (recommended ruleset, scoped to .tsx) so screen-reader/keyboard regressions fail lint. - Make the mobile node-card header a proper keyboard disclosure (role=button, aria-expanded, Enter/Space activation that ignores clicks on the nested action buttons) and drop the now-redundant stop-propagation click handlers the linter flagged on card-action wrappers in the node, client and inbound mobile cards. - Disable jsx-a11y/no-autofocus: the autofocus on the login field and modal primary inputs is intentional focus management that helps screen-reader and keyboard users land on the right control. make lint passes with the a11y ruleset enforced. * feat(a11y): cover remaining deferred spots (settings tabs, sockopt, API docs) Completes the panel sweep for #5486 by labelling the spots previously left out of phases 1-4: - NotifyTimeField (Telegram notifications): the mode, interval, unit and custom-cron inputs now carry aria-labels. - The Sockopt toggle in transport options. - Settings category tabs in icons-only (mobile) mode now expose the tab name as the icon's aria-label instead of the raw icon name. - The Swagger API-docs view is wrapped in a labelled region landmark. New i18n keys in all 13 locales: pages.settings.notifyTime.{interval,unit}. * feat(a11y): label shared xray form components and remark field Code review surfaced frontend/src/lib/xray/forms/ — shared form components used by the host and inbound JSON forms — which the initial audit missed. - FinalMaskForm (TCP/UDP final-mask editor): label the icon-only add and regenerate buttons and make all six remove icons keyboard-operable (role/tabIndex/Enter+Space); adds useTranslation to its sub-components. - CustomSockoptList: the remove icon is now keyboard-operable. - SniffingFields: aria-label on the otherwise label-less destOverride select. - RemarkTemplateField: aria-label on the remark-variable picker button. New i18n key in all 13 locales: pages.inbounds.sniffingDestOverride. * feat(a11y): label client info modal and WireGuard config block After rebasing onto the WireGuard client-config feature, re-apply the ClientInfoModal copy/QR/IP-log aria-labels (the modal was restructured upstream, so the original labels did not carry over) and label the new ConfigBlock component's copy/download/QR actions. ConfigBlock's action wrapper keeps its stop-propagation handler (a non-interactive guard for the Collapse header) under a scoped jsx-a11y exception. * fix(frontend): let npm install jsx-a11y under ESLint 10 eslint-plugin-jsx-a11y@6.10.2 declares a peer range that stops at ESLint 9, but the panel is on ESLint 10, so `npm ci` aborts with ERESOLVE even though the plugin runs fine on ESLint 10 with flat config. Add an npm override so jsx-a11y accepts the project's ESLint version. This keeps normal peer resolution (recharts' react-is peer still auto-installs) — no global legacy-peer-deps and no manual react-is pin needed. * fix(a11y): size mobile row triggers and move node expand role to chevron Address automated review on #5652: - add size="small" to the inbound/client/node mobile-card "more" dropdown triggers so they match the adjacent small Switch and the established desktop RowActions pattern. - move the node card-head disclosure semantics (role/tabIndex/aria-expanded/ keyboard) onto the chevron affordance so the expand control is no longer a role="button" wrapping the Switch, info button and dropdown. Mouse click-anywhere-to-expand is preserved on the header div. |
||
|
|
6c71b725da |
fix(clients): hide WireGuard config after detaching the WG inbound
The client info and QR modals rendered a WireGuard config whenever the client still carried leftover WG key material (privateKey / publicKey / allowedIPs / preSharedKey / keepAlive), regardless of whether a WireGuard inbound was actually attached. After detaching the WG inbound the config kept showing, built with an empty endpoint port and public key. Gate wgConfigText on an attached WireGuard inbound (wgInbound) being present, not just isWireguardClient(client), in both ClientInfoModal and ClientQrModal. Also rename the i18n key pages.clients.conf -> config and add the missing pages.clients keys (wireguardConfig, config, bulkFlow, bulkFlowNoChange, bulkFlowDisable) to all 12 non-English locales so each one matches en-US. |
||
|
|
a329882e0e |
feat(wireguard): client config UX, collapsible config card, configurable DNS
Land the WireGuard client-config UX work on main (the upstream PR #5642 branch could not be pushed to). - Reusable collapsible ConfigBlock (copy/download/QR, actions aligned right) for the client .conf, used by client info and the public sub page. - Correct .conf: canonical PresharedKey casing and DNS sourced from the inbound (configurable per-inbound, default 1.1.1.1, 1.0.0.1). - Configurable per-inbound DNS for WireGuard (schema + form + backend hint via InboundOption.WgDns); inert at the Xray layer. - Public sub page now shows the WireGuard config, rebuilt from the share link; the Go wireguard:// link carries dns/presharedkey/keepalive for completeness. - QR enabled for the wireguard:// link; link rows are compact like other protocols. - Client information order is subscription, copy URL, WireGuard config; the redundant config tab is removed from the add/edit client modal. - Drop the Inbound Information and QR Code row actions for WireGuard inbounds. |
||
|
|
60c54827aa |
feat: ldap skip tls verify (#5637)
* feat(ldap): add InsecureSkipVerify field and tlsConfig helper Extract the inline TLS config at both LDAPS dial sites (FetchVlessFlags, AuthenticateUser) into a tlsConfig(cfg) helper, and add a new Config.InsecureSkipVerify bool that flows through to tls.Config.InsecureSkipVerify. This unblocks enterprise environments (e.g. Microsoft AD CS with internal CAs) where the server certificate chain cannot be imported into the system trust store. Behavior is identical when InsecureSkipVerify is false (the default) - pure refactor + plumbing. The helper is unit-testable without a live server, which is why it is extracted. Closes https://github.com/MHSanaei/3x-ui/issues/5538 * feat(settings): add LdapInsecureSkipVerify setting Plumb the new LDAP skip-TLS-verify toggle through the settings stack: - AllSetting struct field (json/form tag: ldapInsecureSkipVerify) - defaultValueMap default ("false") - GetLdapInsecureSkipVerify() getter - ldap_sync_job wiring into ldaputil.Config (FetchVlessFlags path) - panel/user.go wiring into ldaputil.Config (AuthenticateUser path; the original issue's file list missed this) Persistence is handled by UpdateAllSetting's reflect loop, matching the existing pattern used by ldapUseTLS (no explicit setter). Closes https://github.com/MHSanaei/3x-ui/issues/5538 * feat(ui): add Skip TLS verification switch in LDAP settings Wire the new ldapInsecureSkipVerify setting into the hand-written frontend model and Zod schema, and render it as a new Switch in GeneralTab right under "Use TLS (LDAPS)". The switch is disabled when TLS is off (the setting is meaningless without LDAPS) and shows an insecure-warning description to make the security implication visible to operators. Also adds a Vitest round-trip test pinning schema acceptance and model default-to-false behavior. Closes https://github.com/MHSanaei/3x-ui/issues/5538 * chore(i18n): add Skip TLS verification strings to all locales Add pages.settings.ldap.skipTlsVerify and skipTlsVerifyDesc to all 13 backend-served translation files, matching the existing repo convention of keeping LDAP keys present in every locale (en-US, fa-IR, ru-RU, zh-CN, zh-TW, pt-BR, ar-EG, uk-UA, id-ID, tr-TR, vi-VN, ja-JP, es-ES). No translation-parity test exists in CI, but every other LDAP key is replicated across all files, so this keeps the invariant intact. Closes https://github.com/MHSanaei/3x-ui/issues/5538 * chore(codegen): regenerate frontend artifacts Regenerate frontend/src/generated/{zod,types,schemas,examples}.ts and frontend/public/openapi.json via `npm run gen` to reflect the new ldapInsecureSkipVerify field. The codegen CI job runs `git diff --exit-code` on these files; failing to commit them would break the build. Closes https://github.com/MHSanaei/3x-ui/issues/5538 |
||
|
|
2b10808fbd |
fix(settings): require re-2FA confirmation for sensitive setting changes (#5610)
* fix(settings): require server-side 2fa for sensitive changes * fix(lint): group third-party imports separately from local (goimports) golangci-lint goimports flagged setting.go and setting_security_test.go because xlzd/gotp and gorm.io/gorm were mixed into the github.com/mhsanaei/3x-ui local-prefix group. Move them into the third-party group so the local imports stand alone. |
||
|
|
25a86b9ee2 |
feat(balancers): tabbed Observatory/Burst Observatory form (#5627)
* feat(balancers): tabbed Observatory/Burst form replacing raw JSON Replace the raw JSON editor for the Observatory / Burst Observatory sections with a proper Ant Design form, and split the Balancers page into two sub-tabs: "Balancer Settings" (the existing table) and "Observatory". Observers stay fully auto-managed by balancer strategy through the existing syncObservatories logic: users edit only the tunable probe fields, the subjectSelector is shown read-only since it is derived from the balancers, and deleting the last balancer that needs an observer now warns in the confirm dialog that the observer will be removed too. Overlapping selectors keep an observer alive while any balancer still references it. Also add the previously missing pingConfig.httpMethod field (HEAD/GET) and translations for the new strings across all 13 locales. * refactor(balancers): tighten httpMethod typing and align connectivity default Address automated review feedback on the Observatory form: - Use the ObservatoryHttpMethodSchema enum for pingConfig.httpMethod instead of a free-form z.string(), and drive the HTTP method Select from its options. Removes the previously dead enum export and the duplicate local list, and types the field as 'HEAD' | 'GET'. - Align the schema's connectivity default with DEFAULT_BURST_OBSERVATORY (the hicloud URL) so it matches what burst observers are actually created with. No behavior change. |
||
|
|
51ffba5961 |
fix(balancers): defer validation errors until touched or save (#5626)
The Add Balancer modal parsed its empty initial state through BalancerFormSchema on mount and bound Form.Item validateStatus/help directly to the result, so "Tag is required" and "Pick at least one outbound" rendered the moment the modal opened, before any user input. Gate the inline errors behind per-field touched tracking plus a submit-attempted flag, and drop the disabled Create button so a save attempt surfaces the errors (matching RuleFormModal). The existing key-based remount in BalancersTab resets the flags on each open. Add a regression test asserting no errors on open and errors only after a save attempt. |
||
|
|
9c8cd08f90 |
feat(wireguard): multi-client support
WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing. Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription. Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales. Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics. |
||
|
|
33aada0c7c |
feat(xhttp): default xmux maxConnections to 6
xray-core v26.6.27 changed the XHTTP client xmux default to maxConnections=6 (anti-RKN). The panel previously sent maxConnections=0, which overrode that default; default XHttpXmuxSchema to 6 so new outbounds adopt it and the wire-exclusivity rule drops maxConcurrency accordingly. |
||
|
|
9b8a0c9b17 |
feat(groups): reset group traffic without touching client counters
The group page shows traffic counting per group, but the only reset available zeroed every member client's up/down counters (and their quotas) via bulkResetTraffic. Group traffic is a derived sum of client traffic, so zeroing the group display previously required mutating the clients themselves. Add a display-only baseline: ClientGroup gains reset_up/reset_down columns (additive, handled by AutoMigrate). ResetGroupTraffic snapshots the group's current up/down sum into the baseline, and ListGroups now reports max(0, sum - baseline). Client counters are left untouched and no Xray restart is triggered. A new POST /panel/api/clients/groups/ resetTraffic endpoint drives it, creating the client_groups row when the group exists only as a derived label. The groups page action now calls the new endpoint; confirm/success strings updated across all 13 locales to reflect group-only semantics. |
||
|
|
7efa0d9ddd |
docs: add CLAUDE.md agent guides for root and frontend
Operational guides the Claude Code CLI auto-loads. The root file covers the stack, repo map, hard rules (no // comments, the endpoints.ts registry, the openapigen StructAllow allowlist, i18n locales, migrations), Go and frontend conventions, and the make verify gate. frontend/CLAUDE.md covers the React + AntD 6 + Vite setup. Both link to CONTRIBUTING.md and frontend/README.md instead of duplicating them, and every claim was fact-checked against the source. |
||
|
|
797b08cd07 |
fix(balancers): create burst observer for random/roundRobin with fallbackTag
xray-core's Random/RoundRobinStrategy calls RequireFeatures(Observatory) whenever a fallbackTag is set, so a balancer that declares a fallback but has no observatory aborts startup with 'core: not all dependencies are resolved'. syncObservatories never created an observer for these strategies, crashing the core on any load balancer that used a fallback (the default 'random' strategy with a fallbackTag, exactly issue #5605). Treat random/roundRobin balancers that set a fallbackTag as requiring the burst observer. Also make the burst observer strictly requirement-driven (mirroring the leastPing/observatory path) so clearing the last fallbackTag drops it again instead of leaving a dead observer that forces needless restarts and probing. Closes #5605 |
||
|
|
439245d42b |
feat(inbounds): apply remark template to Export all inbound links
Export-all now renders links through the subscription engine via a new GET /panel/api/inbounds/allLinks endpoint, so the configured remark template (name-only display part) is applied per client -- matching the client info/QR pages. Previously it generated links client-side with a hardcoded inbound-email remark. Host-aware: managed Host endpoints win over the plain link, so HOST and per-host variants render; duplicate client JSON entries are deduped by email and the list is scoped to the logged-in user. |
||
|
|
535b89a352 | fix(routing): write lowercase L4 network to xray config, display uppercase in UI | ||
|
|
7a2179535a |
fix(settings): normalize API token timestamps (#5599)
* fix(settings): normalize API token timestamps * refactor(api-token): share timestamp threshold --------- Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com> |