Compare commits

..

235 Commits

Author SHA1 Message Date
dependabot[bot] 964cd6c124 chore(deps): bump react-i18next from 17.1.10 to 17.1.11 in /frontend
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.1.10 to 17.1.11.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.1.10...v17.1.11)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.1.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 09:08:32 +00:00
25f2002654 83ea59fb69 fix(tgbot): restore the admin's client link and QR buttons (#6986)
The v3.11.0 bot rebuild left the admin's client card without the subscription link, individual links and QR code, so an admin could not hand a config to a client who cannot reach Telegram yet. The card now carries Links and QR code buttons that open the existing screens. Closes #6984.
2026-11-03 15:52:35 +01:00
Hamed 19f2f21d81 fix(inbounds): reject incompatible VLESS fallbacks and prevent Xray crashes (#7003)
Xray refuses a VLESS inbound with server decryption whenever fallbacks is non-nil, even an empty list, so an API or imported inbound with decryption and fallbacks: [] took every inbound down. Empty fallback arrays are now normalized away on add, update, config build and node push; non-empty ones are refused with decryption, enabling decryption over stored fallback rules is refused, and a legacy conflicting row holds back the running core instead of stopping it. The form, API docs and locales follow. Closes #7001.
2026-11-03 15:52:30 +01:00
Aleksey fee7b1621d fix(amneziawg): validate peer subnets before enabling kernel mode (#6965)
A kernel-mode AmneziaWG inbound routes and masquerades only its interface subnet, so peers whose AllowedIPs lay outside it (a restored backup, a changed subnet) handshook but got no internet. Enabling kernel mode, saving an active kernel inbound, and adding, enabling or re-addressing a client now refuse out-of-subnet peers with the offending client named. Legacy stray peers still start and can still be edited or disabled.
2026-11-03 15:52:26 +01:00
Hamed e093854f5b fix(tgbot): send fresh screens for typed commands (#6999)
Typed commands edited the remembered menu message, which a user who cleared the chat can no longer see, and then deleted the command, so /start left the chat empty again. Typed commands (/start, /status, /usage, /clearall) now send a new screen and then remove the old menu, or retire its buttons when it is past Telegram's delete window; button taps still edit in place. Closes #6997.
2026-11-03 15:52:20 +01:00
Hamed 728c2fb94f fix(install): handle yes responses and validate panel ports (#6992)
The panel-port prompt accepted only y/Y, so yes picked a random port; a custom port was never validated (empty, non-numeric, out-of-range, busy, or leading-zero octal), is_port_in_use never detected a listener, and the installer printed success even when applying the settings failed. Answers are now case-insensitive, custom ports are normalized and checked, the port check is fixed, and the install aborts unless the CLI reports every setting applied. A bash suite runs in shell-lint.
2026-11-03 15:52:14 +01:00
Jay Hemnani 129e4f5292 fix(link): keep IPv6 brackets in imported wireguard endpoints (#6976)
parseWireguard joined u.Hostname(), which strips IPv6 brackets, with the port, so wireguard://key@[2001:db8::1]:51820 became 2001:db8::1:51820 and Xray's ParseDestination rejected it. The endpoint is now built with net.JoinHostPort, matching the frontend parser; IPv4 and hostname endpoints are unchanged.
2026-11-03 15:52:09 +01:00
25f2002654 eae4725d99 fix(traffic): restore quota-disabled inbounds when their traffic is reset (#6962)
An inbound switched off by its own traffic cap came back on the periodic reset, but the admin per-inbound reset and Reset All Traffic only zeroed the counters. After that nothing could tell it from a hand-disabled inbound, so it stayed off with every client on it. Both admin resets now re-enable the inbounds their cap switched off, using the predicate the periodic job used, now in the service next to disableInvalidInbounds; expired and hand-disabled inbounds stay off.
2026-11-02 15:50:25 +01:00
VAIO73 1796352600 fix(ui): avoid scrollbar flashes when lazy dialogs load (#6981)
While a lazily loaded dialog's code downloaded, LazyMount's default Spin fallback was laid out beside the page content, narrowing it and poking past the right edge, so scrollbars flashed. The default fallback is now a fullscreen Spin outside the layout; a story pins the content width while the dialog loads.
2026-11-02 15:50:20 +01:00
Guidance 54a509fd67 fix(hosts): say how a host remark reaches the config name (#7044)
The host Remark hint said the label is shown as the config name when the inbound has no remark, but a host remark only ever fills the {{HOST}} token of the remark template; it never substitutes the inbound's remark. The hint in all 14 locales now tells operators to put {{HOST}} in the Remark Template. Fixes #7027.
2026-11-02 15:39:00 +01:00
Hamed 09799ff734 fix(scripts): preserve custom webBasePath during updates (#6964)
install.sh and update.sh stripped the leading slash and regenerated webBasePath whenever the rest was shorter than four characters, so valid short paths such as /a/ or /ab/ were replaced by a random one on every update or reinstall. Only an empty or root path is initialized now, and both scripts read one settings snapshot and leave the settings alone when that read fails or is malformed. Fixes #6961.
2026-11-02 15:35:20 +01:00
PluS98 121123eaaa fix: keep panel restarts from hanging on the CPU job and Telegram (#7029)
Restarting the panel left it unreachable for up to a minute. CheckCpuJob sampled cpu.Percent over its whole one-minute period, so cron.Stop nearly always waited on it; it now diffs cpu.Times against its previous run (iowait counted as idle, as before). With Telegram unreachable, bus.Stop waited on an in-flight send and tgbot.Start ran getMe/setMyCommands inline; each bot call now returns once StopBot closes the bot's stop channel, the bot stops before the bus, and the artwork/commands warm-up runs in the background.
2026-11-02 15:34:22 +01:00
Denis Gritsenko 43af7ada96 fix(sub): restore TUIC links in subscriptions after v3.11.0 (#6963)
The subscription settings cache strips each inbound's clients array, but the TUIC link generator still reads credentials from it, so raw subscriptions (v2rayN, Shadowrocket) lost every TUIC link while the browser page kept it. TUIC now reads its full settings, as WireGuard and AmneziaWG already do.
2026-11-02 15:27:49 +01:00
Seyed Mahdi 81059edc91 fix(sync): preserve shared client lifecycle across nodes (#7038)
A client on inbounds of several nodes has one traffic row homed on one inbound, but the lifecycle merge looked it up in a node-scoped map. A node that was not the home therefore looked like it had no master state, so its stale enable or disable overwrote the shared record and adopted settings, flipping the client every tick, and a renewal on that node was missed. The merge now uses the global per-email traffic lookup, including clients reported only in settings.
2026-11-02 15:27:44 +01:00
Бат-Орших a712bf1413 fix(tgbot): let clients page their screens and see usage per account (#7033)
pg:next, pg:prev and pg:none were missing from isClientSelfCallback, so the callback gate dropped every page tap from a non-admin and a client with several accounts was stuck on page one. Paging only re-renders the screen stored for the caller's own chat, so the gate now lets it through. An account's links screen also shows that account's usage to its owner instead of the admin-only client card button. Fixes #7032.
2026-11-02 15:27:40 +01:00
kevin 77b56a2178 fix(url): keep capitalized mailto: and tel: links as they are (#7052)
EnsureURLScheme matched the mailto: and tel: prefixes case-sensitively, so a support link typed as Mailto: or TEL: (phone keyboards capitalize the first letter) got https:// prepended and opened a broken web page. URL schemes are case-insensitive, and the check now is too.
2026-11-02 15:22:43 +01:00
Cheng Ho Ming, Eric 6d9e836d61 fix(update): keep Xray-core running until update downloads finish (#7002)
UpdateXray stopped the core before downloading, which also stopped the Panel Traffic Outbound bridge the download needed on servers that reach GitHub only through it, leaving every client offline. The new core is now downloaded, staged and checked with run -test against the current config while the old one serves; only the rename and restart swap it (Windows still stops first). update.sh gets the bridge as XUI_UPDATE_PROXY, installs x-ui.sh from the release archive, and fetches Alpine's x-ui.rc before stopping x-ui.
2026-11-02 15:17:01 +01:00
Hamed 4d583cebf3 fix(tgbot): recover menus when cached artwork is rejected (#6968)
When Telegram rejected a cached decorative photo, /start returned without a screen and edits failed the same way. A Bot API 400 on cached artwork now retries once with the generated PNG, keeping caption, keyboard and target, and the stale cached id is dropped only after the fallback succeeds. Refs #6943.
2026-11-02 15:14:03 +01:00
Hamed d2450e103c fix(server): honor keepHostSettings for SQLite imports into PostgreSQL (#6995)
importPostgresDB forwarded keepHostSettings only for native PostgreSQL dumps; both SQLite branches dropped it, so importing a .db or SQL dump copied the source's ports, paths, certificates and node identity over the destination's. Both formats now capture the host-bound settings first and restore them after a successful migration. CI runs the new integration test against PostgreSQL. Fixes #6993.
2026-11-02 15:13:58 +01:00
Jay Hemnani 4180899f15 fix(link): decode share link remarks once (#6977)
url.Parse already unescapes the fragment, and decodeHash unescaped it again, so a + in a vless, trojan, hysteria2 or wireguard remark became a space and an escaped % was decoded twice. These parsers now pass the escaped fragment, decoding it once like the shadowsocks parser and the frontend.
2026-11-02 15:13:54 +01:00
Hamed 4cc742bf6f fix(tgbot): restore current ban log delivery and skip empty files (#6991)
The bot rewrite sent only the rotated previous ban log, so today's bans stayed hidden until the daily rotation, and an empty file was uploaded and rejected. The bot now sends the previous and then the current log, skips missing or empty files, and replies No results only when neither can be sent. Closes #6990.
2026-11-02 15:10:32 +01:00
25f2002654 ce44d2e772 fix(ldap): keep trusted baseline after rejected fetch (#6936)
The auto-delete retention guard refused a partial directory result for one tick, but still stored it as the next baseline, so the following tick detached clients that were still in LDAP. A refused fetch now leaves the baseline unchanged. Closes #6935.
2026-11-02 15:10:28 +01:00
Hamed 34a762f7c8 fix(sub): preserve external Hysteria2 Clash options (#6944)
An external hy2:// link turned into a bare Clash Hysteria2 node: obfs, obfs-password, the port-hopping range and the certificate pin were dropped, so Mihomo could not reach servers that require them. External and panel Hysteria2 nodes now share one converter.
2026-11-02 15:10:24 +01:00
Cheng Ho Ming, Eric 05199dda00 fix(routing): show the Balancer label on mobile rule cards (#6960)
RuleCardList labelled balancer rules with t('pages.xray.balancer'), which is an object in every locale, so phones showed i18next's 'returned an object instead of string' error. It now uses the pages.xray.rules.balancer leaf the Balancers tab already uses, and the dead-keys test fails on any literal key that names a group.
2026-11-02 15:10:18 +01:00
Jay Hemnani 14f2bb5e2c fix(link): accept padded URL-safe base64 in vmess links (#6975)
parseVmess tried standard base64, then unpadded URL-safe base64, so a URL-safe body that kept its = padding failed both and the server silently dropped out of an imported outbound subscription. It now uses base64DecodeFlexible, the helper the shadowsocks parser already uses.
2026-11-02 15:10:13 +01:00
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00
MHSanaei cbf596d889 chore(deps): bump docs and frontend dependencies
Bump docs deps (fumadocs-core/ui 16.17.9, fumadocs-openapi 12.5.7, lucide-react 1.66, oxfmt 0.75, oxlint 1.90, postcss, @types/node) and pnpm to 12.16.1. Drop the now-unneeded minimumReleaseAge excludes for the old fumadocs versions.

Frontend: override minimatch to ^11.0.3 for swagger-ui-react and glob to ^14.0.3 for the docgen vite plugin, which updates the lockfile.

Also fix the AmneziaWG kernel Alert to use the Ant Design 6 `title` prop instead of the deprecated `message`.
2026-11-01 15:58:11 +01:00
Sanaei d94a63d8bc Merge pull request #7040 from MHSanaei/fix/hardening-2026-11
Hardening and bug fixes: node sync, subscriptions, web, outbounds, TUIC/AmneziaWG/MTProto, client lifecycle; xray-core v26.10.31
2026-11-01 15:44:17 +01:00
MHSanaei 6f37433d43 fix(amneziawgnet): stop tests binding fixed ports in the ephemeral range
The race job failed TestEnsureRebuildsWhenS4ChangesTheDerivedMTU with
"bind 127.0.0.1:58720: address already in use". The package hardcoded
WireGuard and port-forward ports 58712-58922, all inside Linux's ephemeral
range (32768-60999), so any concurrent :0 socket could already hold one;
several were also reused across tests (58713 x3, 58715, 58717, 58720).

Tests that never dial the server now use ListenPort 0; tests that need the
number take an OS-assigned one via freeUDPPort / freeTCPUDPPort (the latter
for port forwards, which bind TCP and UDP on the same port).
2026-11-01 15:14:49 +01:00
MHSanaei 757444d505 fix(tgbot): let a screen-art reset outlast a probe still in flight
TestScreenArtReprobeWindow failed under -race -shuffle in CI: the probe
slot is process-wide, and a probe an earlier test started against its
own (now closing) server still held it when the next test asked for a
re-probe, so that request was dropped and the stale avatar never
re-probed. The leftover probe could also write its result over the
state the next test had just reset.

Probes now run in a WaitGroup that resetScreenArt waits on, so a reset
between mock servers always starts from an idle probe slot.
2026-11-01 14:56:40 +01:00
MHSanaei 62d27d54d5 feat(xray): support the XDRIVE s3 storage service
xray-core 26.10.31 (#7231) added an "s3" XDRIVE service that carries the
stream through S3-compatible storage: secrets are AccessKeyID,
SecretAccessKey and an optional SessionToken, an "s3" object names the
endpoint, region, bucket and pathStyle, and remoteFolder becomes the
object prefix.

The schema and the shared XDRIVE form offer the service with a field for
every S3 key, so an outbound or inbound keeps its storage on save. The
save path refused "s3" as an unknown service; it now runs the vendored
loader, whose ValidateS3Config would otherwise only fire when the
listener starts and stop every inbound. A node refuses a pushed S3
storage like a pushed template one, since its endpoint is any URL the
node would dial, including its own panel. Labels in all 14 locales;
docs updated in en/fa/ru/zh.
2026-11-01 14:56:26 +01:00
MHSanaei 87749c1ee4 feat(xray): accept the TUN WFP leak ports of xray-core 26.10.31
xray-core 26.10.31 (#7288) widened a TUN inbound's
autoSystemWfpBlockLeak: "dns" now also blocks DNS over TLS/QUIC (port
853), "windnsclient" covers only Windows' DNS Client service, and any
"tcp/<port>" or "udp/<port>" keeps that port inside the TUN. Anything
else, such as "tcp/0" or "icmp/1", stops the core at startup.

The form offers windnsclient and takes typed port values, the hint
explains them in all 14 locales, and the inbound and template save
paths run the core's own ExpandWfpBlockLeak so a value it refuses is
refused at save instead of taking every inbound down.
2026-11-01 14:56:13 +01:00
MHSanaei ba27b74be8 feat(xray): keep the XHTTP server's h2Params
xray-core 26.10.31 (#7295) added xhttpSettings.h2Params, a Go net/http
HTTP2Config the XHTTP server applies to its HTTP/2 listener. The
inbound form parsed xhttpSettings through a Zod object that strips
unknown keys, so a value set through the JSON editor vanished on the
next save; it is now kept as an optional record.

h2Params tunes the server only. Share links already carry an allowlist
of XHTTP keys, but the JSON subscription copies the inbound's
xhttpSettings minus a denylist, which now drops h2Params too.
2026-11-01 14:55:57 +01:00
MHSanaei 7982794291 fix(xray): refuse geodata settings xray-core 26.10.29 refuses at startup
xray-core 26.10.29 (#7267) checks a scheduled geodata section when the
core starts: at most 100 assets, every file a .dat, a cron it can parse
and an @every interval of at least a minute. A refusal there stops the
core and every inbound with it, and the panel's own file-name pattern
still let any extension through.

The geodata editor now requires a .dat file name, and the template save
path applies the same checks the core does. The invalid-file hint names
the .dat requirement in all 14 locales.
2026-11-01 14:55:41 +01:00
MHSanaei e789583b63 fix(xray): keep legacy-private unencrypted outbounds on cores before 26.10.29
xray-core 26.10.29 (#7249) narrowed the "private" addresses an
unencrypted vless/trojan outbound may reach to RFC 1918, loopback and
link-local, so 100.64.0.0/10 (CGNAT, Tailscale), 198.18.0.0/15 and the
other ranges of the old geoip:private list are now refused. The
embedded validator follows the new core, which made the template save
and outbound subscriptions refuse such an outbound even when the panel
still runs an older core that accepts it.

The legacy skip now also lets through, on a core from 26.7.11 up to
26.10.28, an outbound whose server address (flat, vnext or servers) is
an IP in that core's wider private set. Unknown versions stay strict.
2026-11-01 14:55:29 +01:00
MHSanaei 72c9092d4a feat(xray): update xray-core to v26.10.31
Bump xtls/xray-core to a4a3dff5b20c (v26.10.31) and the three binary
pins in DockerInit.sh and release.yml in lockstep, so the in-process
conf.Build() validation and the child binary agree. The other Go
modules move to their latest patch/minor releases in the same go.mod.

26.10.31 moved ResolveLuaFile from common/platform to
common/platform/filesystem; childLuaScriptPath now calls it there. The
lookup order is unchanged, the extension check is now case-insensitive.
2026-11-01 14:55:14 +01:00
MHSanaei 9938d0b7a6 chore(deps): update frontend dependencies
Minor and patch updates across the frontend toolchain and runtime
(antd, TanStack Query, react-router, react-hook-form, i18next,
Storybook, oxfmt/oxlint), plus @hookform/resolvers 7. Typecheck,
lint, the full vitest suite and the production build pass.
2026-11-01 14:55:10 +01:00
MHSanaei 4dfd008edb test(sub): assert linear allocation growth instead of an absolute budget
TestSubscriptionDecodesInboundSettingsOncePerRequest capped one request
at a fixed number of bytes, which the race detector, a Go upgrade or a
richer link format can move: the race job already broke 128 MiB on
correct code, and raising the cap only waits for the next drift.

The test now builds each subscription at n and 3n clients and asserts
the allocation grows by at most 5x. One decode per request grows about
3x (2.94 to 3.40 measured, under -race too); one decode per link grows
6.9 to 9.3x, so the bound tells the two apart whatever the absolute
sizes are.
2026-11-01 14:00:29 +01:00
MHSanaei 99f386820d fix(tgbot): treat messages sent on behalf of a chat as strangers
Telegram fills a message's From with one of three fixed placeholder
users whenever it is posted on behalf of a chat, by an anonymous group
admin or a channel. The level gate and the invite claim took that
placeholder for the account acting, so it got a throttle window of its
own, and a claim bound the client to an id every anonymous admin shares.

Such a message is rated as a stranger whatever the placeholder id is
bound to, and its invite claim is refused before it is counted or bound.
2026-11-01 13:47:40 +01:00
MHSanaei 8f3dd70db4 fix(geodata): refuse a database whose category index is too large to browse
The geofile browser indexes every category record and attribute key a
database holds, which cost the panel far more memory than the file's
bytes: a crafted file of a few megabytes full of empty categories or
attribute keys ran the panel out of memory. The file size cap bounded
nothing here.

The scan now counts records and attribute keys as it keeps them and
refuses a database past 65536 with "too large to browse", which the
cache remembers; the other layout is not retried for the same file.
2026-11-01 13:46:18 +01:00
MHSanaei 846dfe3d74 fix(notifications): throttle failed-login alerts where they are published
Every failed login was published on the event bus, and the e-mail,
Telegram and Discord notifiers exempt login events from their cooldown
so that successful logins are never swallowed. An unauthenticated login
flood therefore filled each notifier's queue and crowded out every
other alert, a successful login, an Xray crash or a node going down.

UserLoginNotify now passes a failed attempt only for the first failure
of its source address in a minute, at most ten addresses per minute;
successful logins are always published.
2026-11-01 13:44:32 +01:00
MHSanaei 54339661a6 fix(server): keep the access log view when one line is over 64 KiB
GetXrayLogs read access.log with a default bufio.Scanner, whose token
limit is 64 KiB, and returned nothing at all when it stopped with
ErrTooLong. Xray logs an HTTP request's URL whole, so one proxied
request with a long URL blanked the Access Logs view for every client.

Lines are now read with a bounded reader: a line that fits is kept
whole, and a longer one keeps its first 64 KiB and its last 1 KiB,
where the route and email are, so it still shows as a correctly
attributed row.
2026-11-01 13:42:12 +01:00
MHSanaei e02f51320a fix(tgbot): release the invite claim lock even when a claim panics
claimInvite took the package-level inviteClaimMu and released it
without defer, inside a handler whose panics the bot recovers. A panic
while resolving or binding a token left the lock held for good, and
every later /start claim parked on a worker slot until the bot answered
nobody.

The lock is now held in its own function with a deferred unlock.
2026-11-01 13:38:58 +01:00
MHSanaei d63583caaf fix(tgbot): keep a login alert's username on one line
The Telegram login alert is a list of "label: value" lines, and the
username, whatever the visitor typed on the login form, was inserted
with its line breaks intact. Text after a break read as the panel's
own IP and time lines, letting a failed login forge the alert.

Control characters and Unicode line separators in the username become
spaces, in the failed and the successful alert alike.
2026-11-01 13:37:40 +01:00
MHSanaei 72fb4d9358 fix(frontend): keep node-reported text out of i18next's nesting parser
The SPA initialised i18next with nesting on, though no locale uses
$t(...), so its nesting regex, exponential on an unclosed "$t(", ran
over every missing key and interpolated value, including the status and
name a node reports for its sub-nodes. One crafted status froze the
Nodes page.

Nesting is switched off for every i18next instance the project creates,
an unknown status renders as Unknown instead of being used as a key,
and a sub-node is never named as the parent it is reached through.
2026-11-01 13:34:21 +01:00
MHSanaei 6c74fccbf7 fix(amneziawg): only delete IPv6 aliases this panel added itself
Teardown and the per-peer diff built the list of IPv6 aliases to delete
from configuration alone, while adding an alias treated "already
exists" as success. An address the host already owned, ::1 on lo or its
own public /128, was therefore deleted when an inbound or peer that
named it went away.

The manager records the aliases whose add actually created them and
deletes only those; an address that was already there is left in place.
2026-11-01 13:32:21 +01:00
MHSanaei 8b525b5be6 fix(node): clear a client's orphan mark when it is attached again
A client with an inbound link is not an orphan, but the node sweep's
mark was cleared only at the end of a node merge. An admin attach left
a stale mark, and ReapSyncOrphans deleted the client at its next
detach, traffic and IPs included, without the grace window.

Inserting a link now clears the mark of the clients it links, and the
reaper rereads its candidates inside its writer, so an attach that
commits after the list was read is not reaped.
2026-11-01 13:30:53 +01:00
MHSanaei 3471cecbda fix(mtproto): leave clients mtg cannot parse out of the generated config
One mtg-multi process serves every client of an MTProto inbound from a
single TOML file and refuses the whole file over one value it cannot
parse. InstanceFromInbound already guarded the ad tag for that reason
but copied secret, quota and expiry as stored, so a malformed secret, a
year-10000 expiry or a near-MaxInt64 quota on one client took the
sidecar down for everyone on the inbound.

A client whose secret mtg cannot parse is left out with a warning, and
quota and expiry are capped at what mtg accepts.
2026-11-01 13:27:02 +01:00
MHSanaei 5a6d40e3ff fix(inbounds): keep disabled clients out of the core when adding an inbound
The running core must only see clients the panel has enabled, and
UpdateInbound and SetInboundEnable already build their payload through
buildInboundForLocalRuntime, which strips disabled ones. AddInbound used
it only for MTProto and TUIC and hot-added the stored row for every
other protocol, so an imported or pushed inbound re-armed its disabled
clients until the next restart.

AddInbound now builds the runtime payload the same way for every
protocol.
2026-11-01 13:26:10 +01:00
MHSanaei 2e41f908db fix(limit-ip): do not re-add a client the database stopped serving
disconnectClientTemporarily removed an over-limit client from the core
and re-added the snapshot the scan had loaded 100 ms later, so a
disable or detach committed in between was undone: the traffic job's
own RemoveUser had already found nothing, and the disabled account
stayed live in Xray until the next restart.

The re-add now rereads the client and its inbound and skips one that
is disabled, expired, over quota or no longer attached.
2026-11-01 13:22:26 +01:00
MHSanaei cc3aff2434 fix(discord): escape Markdown in alert text the panel did not write
Every Discord alert field went through cleanField, which only trimmed
and truncated, so a login username, a node's error text, a remark or an
email reached Discord as live Markdown: masked links, bold, headings,
mentions. A failed login with a crafted username could post a working
phishing link in the admins' channel.

Foreign text is now Markdown-escaped in every field and command reply,
and values shown in code spans can no longer close their span.
2026-11-01 13:21:08 +01:00
MHSanaei 4d8e2b48ff perf(sub): make balancer member retagging linear
buildBalancerConfig restarted the free tag-suffix search at 2 for every
member, so member k formatted and looked up k tags and n members of one
protocol cost n²/2 lookups; a balancer with many same-protocol members
held a subscription request for minutes.

The search now resumes per base tag where the previous member stopped,
producing the same tags in linear time.
2026-11-01 13:20:27 +01:00
MHSanaei 8bbfda0510 fix(email): cap what one SMTP session reads from the server
net/smtp buffers a server reply of any length, so a mail server, or
anyone on the path before STARTTLS, could send a greeting or EHLO reply
that never ends and grow the panel's heap until the 30 s deadline, on
every notification.

Each session now reads at most 1 MiB from the server, below TLS, and
fails with a clear error past it.
2026-11-01 13:18:54 +01:00
MHSanaei 06cb1742a1 fix(server): bound the public-IP lookup and accept only an address
getPublicIP read a third-party IP echo service's body whole and cached
whatever came back as the panel's address, so one misbehaving service
could make the panel inflate an endless response at startup, and an
error page became the address shown in every status sample.

The read is capped at 64 bytes and only a parsable IP is accepted;
anything else counts as unavailable. The GitHub release lookups are
capped as well.
2026-11-01 13:18:01 +01:00
MHSanaei f99c6c664d fix(sub): resolve legacy socket fallback fronts on PostgreSQL too
The socket-front lookup filtered and extracted settings.fallbacks with
SQLite's json_valid, JSON_TYPE and json_extract, which PostgreSQL does
not have, so on PostgreSQL the statement failed and a legacy @socket
child never got its front's address and security in its links.

The lookup now selects the inbounds whose settings mention fallbacks and
parses them in Go, which works on both databases and still skips an
inbound whose settings do not decode.
2026-11-01 13:16:14 +01:00
MHSanaei 93d1e63554 fix(node): count only values against the node-answer value cap
decodeCappedValues is documented to count JSON values, but it also
counted object keys and closing brackets, so a node's GUID-keyed online
and IP trees reached the cap at roughly half the stated number of
values, and the master then refused the whole report rather than the
excess.

Keys and closing brackets no longer count; every value, nested ones
included, still does.
2026-11-01 13:14:46 +01:00
MHSanaei 1d373cf67e fix(node): let a node take back a client orphaned before the upgrade
The migration backfills sync_orphaned_by = 0 for every client a node
merge had already soft-orphaned, and the gate refused a detached record
unless its mark named the reporting node. A client inside its orphan
grace window at upgrade time was therefore refused when its node
reported it again, and ReapSyncOrphans then deleted it on the master
while the node kept serving it.

A mark that names no node predates the column and may be taken back by
any node, as before.
2026-11-01 13:13:10 +01:00
MHSanaei 828855cdb5 fix(node): apply the client gate to emails with non-ASCII capitals
The node client gate loaded the records it judges with LOWER(email) IN
the lowered reported emails, but SQLite's LOWER folds ASCII only. For a
stored email such as Иван no record was found, so the node's claim was
treated as a new client and passed untouched, rewriting a client that
lives only on the master's inbounds.

The lookup matches the lowered or the stored spellings, as the shared
email checks already do.
2026-11-01 13:12:15 +01:00
MHSanaei 4cddcff12b fix(sub): fetch operator-chosen private and proxied external subscriptions again
Guarding the external subscription fetch against a provider's redirects
also refused the URL the operator configured when it names a LAN or
loopback address, and the guarded transport dropped the host's proxy
setting, so a panel merging another panel on its own network, or one
reaching the Internet only through a proxy, silently lost those links.
A provider listing more than 1000 links was refused whole as well.

A URL that names a private address itself is now fetched, while a
hostname still is not, since the provider controls what its DNS answers;
public providers stay held to public addresses through every redirect.
A fetch the host's proxy setting covers goes through that proxy, and a
long list is cut to its first 1000 links instead of refused.
2026-11-01 10:30:26 +01:00
MHSanaei 255c658468 fix(sub): escape a VLESS id the way the panel's own links do
Subscription links escaped the VLESS id with url.QueryEscape, while the
panel builds its own link and QR through the browser's URL parser,
which escapes userinfo with the WHATWG userinfo set. A non-UUID id such
as team+1 was therefore served as team%2B1 by the subscription and as
team+1 in the QR, and a client that does not percent-decode userinfo
authenticated with a different id depending on where it came from.

The id is now escaped with exactly that set, which still keeps a line
break or an "@" from splitting the link.
2026-11-01 10:26:54 +01:00
MHSanaei 0394996a30 fix(node): keep summing usage across panels for a client the node shares by email
The client gate refused a node's entry for an email the master serves
only on its own or another node's inbounds, and so also dropped the
node's usage for it; on top of that, a refused or rewritten entry made
the master mark the node dirty and push its own settings back, which
deleted the node's local client of that email. A master → node → node
chain whose panels each serve the same client therefore stopped adding
the nodes' usage to the master's totals.

Such an email is now usage-only: the node's usage still adds to the
master's row, while its enable, expiry and record values do not. A
claim rewrite no longer forces a re-push; the stored, filtered settings
are what the reconcile fingerprint tracks.
2026-11-01 10:25:14 +01:00
MHSanaei 9c0bf6938c test(sub): leave the decode-once allocation bound room for the race detector
TestSubscriptionDecodesInboundSettingsOncePerRequest capped a request
at 128 MiB, which the race job exceeds on correct code (up to 170 MiB)
because the detector inflates allocations. One decode per link costs
489 to 750 MiB without it, so a 256 MiB bound still tells the two apart.
2026-11-01 10:10:01 +01:00
MHSanaei 5c6cbb5680 chore(deps): update follow-redirects to 1.16.2
A high-severity advisory published against follow-redirects 1.16.0 to
1.16.1, pulled in through swagger-ui-react's client, fails the
frontend's production audit. npm audit fix moves it to 1.16.2, along
with the swagger-ui-react 5.34.1 patch release that allows it.
2026-11-01 10:04:45 +01:00
MHSanaei 082d7ac091 fix(xray): block private-range egress in per-peer AmneziaWG IPv6 outbounds
The per-peer IPv6 freedom outbounds the panel injects for AmneziaWG
clients were built with empty settings and their routing rule sits
ahead of the template's private-range block, so a peer's IPv6 traffic
skipped both layers and could reach fc00::/7 hosts on the panel's
network.

Each per-peer outbound now carries the same private-range finalRules
block every other freedom outbound gets.
2026-11-01 10:03:17 +01:00
MHSanaei ecd817658c fix(amneziawg): attribute an equal-prefix source to the peer the device picks
PeerIndex names the peer a source address came from, but for two peers
holding the same prefix, which only a default route can be after the
overlap check, it kept the first while amneziawg-go's allowed-IPs table
hands the prefix to the peer configured last. Traffic was then billed
to a different client than the one the device accepted it from.

An equal-length match now goes to the later peer, as in the device.
2026-11-01 10:02:11 +01:00
MHSanaei efaa73db3a perf(tgbot): stop loading the whole inbound per client in usage listings
Listing a Telegram account's clients ran CheckIsEnabledByEmail and
describeAttachedInbounds for every client, and both loaded and parsed
the client's entire inbound, so one message cost the number of clients
times the size of their inbound's settings.

CheckIsEnabledByEmail now reads the client's own record first, keeping
the inbound parse only for legacy clients never normalized into the
clients table, and describeAttachedInbounds takes every remark in one
query without loading any settings.
2026-11-01 09:14:43 +01:00
MHSanaei 7163c6e521 perf(sub): decode TUIC and AmneziaWG settings once per request
genTuicLink and genAmneziaWGLink, and their Clash and JSON siblings,
parsed the inbound's whole settings, every client included, for each
link they built, bypassing the per-request caches the other generators
use. Building the links of n clients of one inbound, as the export of
all links does, cost n full parses of an n-client document.

Typed per-request caches next to settingsByInbound now hold the decoded
TUIC instance, indexed by email, and the AmneziaWG server block, and
every generator reads through them.
2026-11-01 09:09:28 +01:00
MHSanaei dda44c1e72 perf(sub): resolve legacy socket fallback fronts once per request
projectThroughFallbackMaster found the front of each unix-socket child
by scanning every inbound's settings JSON, once per child, so a
subscription cost the number of socket inbounds times the size of all
settings in the table.

A request now collects every host's socket fronts in one statement,
the lowest-id front naming a socket on that host still wins, and an
inbound with unreadable settings no longer fails the lookup.
2026-11-01 09:06:32 +01:00
MHSanaei 878d37c2fc fix(panel): bound the sponsor logo cache and evict logos that left the list
The logo cache was keyed by whatever names the sponsors list named and
never pruned, so a list naming thousands of logos made the public logo
route pin hundreds of megabytes, and every hourly refresh with new
names added more.

Only the first 32 distinct logo names of the active list are kept,
surplus sponsors render with their initial as a bad logo name already
does, and each store drops cached logos no longer on the list.
2026-11-01 09:03:42 +01:00
MHSanaei 1507fd4edf fix(hwid): re-check the device limit where an unlimited fetch registers
EnforceHwidForSubID read the subscription's device limit and, finding
none, recorded the device without looking again. A fetch racing an
operator who had just set a limit could therefore register its device
right after the limit was written and trimmed, leaving more devices
registered than the limit and serving all of them.

The tracking now re-reads the limit and writes the row in one
transaction, which serializes with the operator's, and a limit that
has appeared sends the request through the limited gate instead.
2026-11-01 09:02:15 +01:00
MHSanaei fac9009398 fix(tgbot): escape template parameters once in I18nBot
Every bot message is sent HTML-parsed while the intended markup lives
in the translation templates, yet the values filled into them were
escaped only at some call sites. Remarks and e-mails, which a node
chooses for the inbounds it reports, reached the scheduled report and
the client info unescaped and turned into markup, and the reports that
escaped their whole text instead showed the templates' own tags.

I18nBot now escapes every parameter value, the call sites drop their
own escaping and the whole-report escapes, the two values concatenated
outside a template are escaped where they are written, and callback
answers, which are plain text, are unescaped again.
2026-11-01 08:58:43 +01:00
MHSanaei a9191cb468 fix(amneziawg): let an emptied forwardedPorts withdraw a client's forwards
UpdateInboundClient carried the stored forwardedPorts forward whenever
the incoming value was empty, and Client.ForwardedPorts was a plain
omitempty string, so clearing the field in the client editor, as its
hint says to, reported success while the settings kept the old ports
and the host kept relaying them to the client's device.

The field is now a pointer, as KeepAlive is: nil (not sent) keeps the
stored spec and "" withdraws it. The client record follows the live
spec on every update, and a one-time seeder fills records that still
hold their creation value from the inbound settings, so the editor no
longer posts an empty field that would drop forwards added later.
2026-11-01 08:55:38 +01:00
MHSanaei 0972ac31eb fix(clients): collapse repeated inbound ids before fanning a client out
Create, BulkCreate, Attach and BulkAttach built one marshalled copy of
the client per entry of the request's inboundIds and held all of them
until the fan-out returned, with repeated ids never collapsed. An id
costs two bytes of request body, so a large client named on the same
inbound a few thousand times needed gigabytes and the panel died.

Every fan-out entry point now collapses the id list first, keeping the
order of first occurrence so credentials are still minted on the first
inbound.
2026-11-01 08:44:16 +01:00
MHSanaei f8ceede46f fix(hwid): keep a subscription's device limit while its client is disabled
effectiveHwidLimitForSubID took the limit over enabled clients only. A
client switched off for expiry, quota or by hand therefore had no limit
at all, while the subscription body is still served for disabled
clients, so any number of devices registered and stayed past the
renewal.

The limit now follows the subId whatever the enable flag, and the gate,
the slot status and the trim keep sharing that one query.
2026-11-01 08:39:50 +01:00
MHSanaei bd36053356 fix(clients): match non-ASCII capitals in the shared-email checks
The checks that decide whether a client_traffics row is still used by
another inbound, whether an email is already taken under another subId,
and which depleted rows a scoped delete may drop compared a Go-lowered
email with LOWER(clients.email), which SQLite folds for ASCII only. An
email such as Иван was never seen as attached elsewhere, so the node
sweep deleted the usage row of a client the master's own inbound still
served and the client outlived its expiry and quota.

Each check now matches the lowered or the stored spelling and keys the
result in Go, so ASCII behaviour is unchanged.
2026-11-01 08:37:01 +01:00
MHSanaei 2024d34928 fix(geodata): index a split category's attributes in one pass
scanIndex merged each further record of an already-seen category with
mergeAttributes, which rebuilt a map and re-sorted every attribute the
category had so far. A category with A attributes split over N records
cost A*N, about an hour and a half for a 5 MB file, while the scan lock
was held and every geodata route waited on it.

Each category now collects its attributes in one set during the walk,
and the sorted lists are built once at the end.
2026-11-01 08:33:19 +01:00
MHSanaei 7f64a67ba5 fix(clients): re-enable only quota-disabled clients on a traffic reset
Every traffic reset re-enabled its clients unconditionally, in the
client record and in the traffic row a master treats as the client's
state. A master forwards an inbound's scheduled reset and "reset all"
to its nodes through the per-client reset calls, so a client disabled
by hand on a node inbound came back on at the next scheduled reset.

Each reset now decides, before it zeroes the counters, which of its
clients the quota switched off, counting cross-panel usage, and
re-enables only those; a client disabled below its quota keeps its
record and traffic row off. The reset API docs say so.
2026-11-01 08:31:27 +01:00
MHSanaei 67820dc20d fix(clients): keep a hand-disabled client off through auto-renew
autoRenewClients re-enabled every renewing client whose traffic row was
off, as if only the panel's quota and expiry check cleared that flag.
The operator's disable routes (the client editor and bulk disable)
clear it too, so a client disabled by hand came back on with a fresh
period and zeroed counters as soon as its Auto Renew period ended.

A renewal now restores only a client the lifecycle job switched off:
its quota is spent, counting cross-panel usage, or its record was
stamped at or after its deadline, which the job does and a disable of a
live client cannot.
2026-11-01 08:23:48 +01:00
MHSanaei 30f17ef5a3 fix(amneziawg): keep port forwards per inbound and off future relay ports
Attach and BulkAttach copied a client record's forwardedPorts onto the
target inbound. A client a node reports keeps the forwards the node
chose, so attaching it to one of the master's AmneziaWG inbounds opened
listeners of the node's choosing on the master, among them the
loopback relay port a later AmneziaWG or TUIC inbound derives from its
id; that inbound's traffic then went to the forward and Xray failed to
bind.

A forward is set per inbound and is no longer carried by an attach, and
creating an AmneziaWG or TUIC inbound is refused while a peer forward
already holds its relay port.
2026-11-01 01:22:51 +01:00
MHSanaei 6dbd219a14 fix(database): keep disabled clients disabled in the ClientsTable seeder
The one-time ClientsTable migration builds client records from each
inbound's settings with a plain Create, and gorm's default:true on
ClientRecord.Enable drops an explicit false from that insert. Every
client an operator had disabled before v3.1.0 was therefore stored
enabled on an in-place upgrade or a restored backup, and Xray, which now
reads that column, let the customer back in.

The seeder restates enable=false after Create, as the service paths do
since #6478, and a legacy client whose settings carry no switch stays
enabled, as releases before 3.1.0 served it.
2026-11-01 01:16:56 +01:00
MHSanaei 2467b9d542 fix(sub): bound what one subscription response renders
A subscription's size is the product of its clients and inbounds with
each one's remark and endpoints, and nothing bounded that product: data
adopted from a node could make one unauthenticated request render
gigabytes and run the panel out of memory.

Each request now carries an 8 MiB budget that the raw, JSON and Clash
outputs charge as they materialize a client's links, configs or
proxies. Rendering stops once it is spent, with one warning, and
anything below the budget is unchanged.
2026-11-01 01:15:28 +01:00
MHSanaei fc8efc0745 fix(node): bound what one node inbound adds to a subscription
A subscription renders an inbound's stream and remark once per client
and per external proxy, and every client holding its subId, all values
a node chooses: an inbound with a megabyte stream and thousands of
external proxies, or many clients sharing one subId, made a single
unauthenticated request cost gigabytes of memory and minutes of CPU.

A reported inbound whose streamSettings exceeds 64 KiB, sniffing 16 KiB
or remark 1 KiB, or that lists more than 64 external proxies, is no
longer taken in (an existing one keeps its stored values), and a node
can no longer give a client a subId another client already holds,
including another one in the same snapshot.
2026-11-01 01:11:00 +01:00
MHSanaei f9f21fd8a4 fix(node): bound the host groups a node's inbounds may adopt
A node-reported host group is stored once per address and per adopted
inbound, so the rows and bytes a merge wrote were the product of three
values the node chooses, unbounded by the response size cap: a small
hosts/list answer could fill the master's disk.

A group whose serialized form exceeds 16 KiB is no longer adopted, and
a node's inbounds hold at most 2000 host rows, counted over what they
already hold, so repeated merges do not add more; the merge logs how
many groups it left out.
2026-11-01 01:05:48 +01:00
MHSanaei 3526a65f5d fix(web): bound the panel's open connections and share asset gzip
The engine-wide gzip middleware gave every in-flight request its own
compressor of about 1 MiB, and the public /assets bundles let an
anonymous client keep a handler blocked in Write until WriteTimeout, so
a thousand stalled downloads held a gigabyte. The listener itself took
any number of connections, each holding buffers and a goroutine before
any login.

The bundles are now served from a gzip copy made once per file, with
the assets prefix left out of the per-request middleware, and the panel
listener closes a new connection once 2048 are open or its source (an
IPv4 address or IPv6 /64) holds 256; a loopback reverse proxy counts
only toward the total.
2026-11-01 01:01:38 +01:00
MHSanaei 1c748adadf fix(sub): resolve a blank subId to no subscription
A subId is the only selector of a subscription's inbounds and external
links, so a blank one matched every client stored without a subId, as
clients embedded through inbounds/add or inbounds/import are. The
Telegram bot authorised a client by email and then looked its links up
by that blank subId, handing a bound user the working links of every
other subId-less client.

A blank subId now resolves to nothing in the subscription service, and
the bot builds a subId-less client's own links by email, as the panel's
client page does.
2026-11-01 00:57:32 +01:00
MHSanaei 9a95153f0e fix(hwid): track devices only for subscriptions a client owns
The subscription routes run the HWID gate before they look up the
subId, and the gate read "no device limit" and "no such subscription"
alike, so a request naming any path segment with an X-HWID header was
answered 404 yet stored that segment, up to about 1 MiB, in
client_hwids for good. Anyone reaching the subscription port could fill
the disk until the panel could no longer save anything.

An unlimited subscription is tracked only while an enabled client owns
its subId, and a one-time seeder drops the rows already stored for
subIds no client owns.
2026-11-01 00:54:59 +01:00
MHSanaei 2b9adaa6ff fix(clients): set a client's Telegram user on every inbound it is on
SetClientTelegramUserID patched tgId in the settings of only the
inbound the client's traffic row points at. Sibling inbounds kept the
old value, and the next sync of any of them wrote it back into the
client record, so removing a Telegram user from a multi-inbound client
silently re-bound that user to it.

The write now goes through applyClientFieldByEmail, as the other
by-email setters do, so every attached inbound, on the master or a
node, receives the new tgId.
2026-11-01 00:51:31 +01:00
MHSanaei 6a0c983c89 fix(node): keep only the latest refused claims for the log-once state
The set that kept a refused node claim's warning to one line was keyed
by the reported email and never forgot one. Some refused emails, such
as a padded one or a new client holding a foreign subId, match no row
on the master, so a node rotating them grew the set without bound; a
snapshot made only of padded entries also skipped the warning.

Each node now keeps just the emails its latest snapshot was refused,
both return paths log, and the logged email is cut to 64 characters.
2026-11-01 00:47:46 +01:00
MHSanaei c2250dbe57 fix(sub): keep the salamander warning state one entry per inbound
genHysteriaLink remembered which salamander settings it had warned
about in a set keyed by the setting names themselves, which a node
chooses with no bound on length or number and the set never forgot,
and it logged the names verbatim into the in-memory log buffer. A node
renaming a setting on every poll grew the master's memory by twice the
name's size per subscription fetch until the process ran out.

The set is keyed by inbound id and holds a digest of the names, the
warning repeats only when that set changes, and it logs at most eight
names cut to 32 characters.
2026-11-01 00:45:21 +01:00
MHSanaei bebe1b911d fix(ldap): keep expired and over-quota clients off on directory sync
The LDAP sync enabled every disabled client whose directory flag was
true, without asking why it was off. A client the traffic job had just
disabled for a passed expiry or a spent quota therefore came back on
every sync and connected again for the seconds until the next traffic
pass, for as long as the directory listed it.

The sync's enable step now drops the clients the panel's own depletion
rule keeps off, and enables nobody in a run whose lookup fails. A
client the operator renews is enabled at the next sync.
2026-11-01 00:44:05 +01:00
MHSanaei c92c7718bf fix(xray): refuse xhttp downloadSettings the core's dialer cannot use
ValidateOutboundConfig, the save-time gate shared by template saves,
API saves and outbound subscription refreshes, relied on conf.Build,
which accepts a downloadSettings block with no address or on another
transport. The core's XHTTP dialer dereferences that address and
asserts that transport on every dial, so an imported link or a
subscription carrying such a block panicked Xray, and every user's
connections with it, whenever traffic was routed to that outbound.

The built sender settings are now checked for both, and the outbound
is refused with a message that names the missing part.
2026-11-01 00:41:37 +01:00
MHSanaei 68e06a174b fix(job): withhold fail2ban bans for addresses a datagram alone reports
The IP-limit job treated every address on the core's online list as one
its sender proved to own and wrote the oldest over-limit ones for the
3x-ipl jail. For Shadowsocks over UDP and for mKCP the core lists a
datagram's source field with no exchange behind it, so a client with an
IP limit could forge sources and get any address firewalled.

A client attached to an enabled inbound that accepts such datagrams,
on the master or on a node, is still counted and disconnected, but its
old addresses are no longer written for fail2ban; a warning says why.
2026-11-01 00:39:26 +01:00
MHSanaei 152c84efa2 fix(inbounds): delete an inbound's dependent rows on every delete path
The node sync's sweep of an inbound the node stopped reporting deleted
the inbound but left its host rows, and the depleted-clients delete
left its sub-balancer references. SQLite hands a freed inbound id out
again once the table empties, so a node's host overrides could bind to
a new inbound it never hosted and point that inbound's subscribers at
an address, port and TLS settings the node chose.

delInbound's cleanup moves into deleteInboundRow (detach clients,
delete the inbound, its host rows and its sub-balancer references),
which the sweep and the depleted-clients delete now call as well.
2026-11-01 00:36:45 +01:00
MHSanaei 776ca554bd fix(server): keep only the requested tail while reading the access log
GetXrayLogs held one entry for every line of Xray's access log and cut
the list to the requested count only at EOF, so the panel's memory grew
with the file. Xray writes a line for every refused request, which puts
the file's size in the hands of anyone who can reach an inbound port,
and the periodic prune does not bound it between runs. A negative count
also panicked on the final slice.

Entries beyond the newest count are dropped while scanning, and count
is clamped to 1..10000 with 100 as the fallback, as GetAmneziaWGLogs
already does.
2026-11-01 00:31:59 +01:00
MHSanaei c3a32c8343 fix(shadowsocks): keep a 2022 inbound with no enabled client locked
When every client of a Shadowsocks-2022 inbound was disabled the panel
handed the core an empty client list, from which xray-core builds a
single-user server keyed by the inbound's server key alone. That key is
the first half of every link the panel ever issued, so a client cut off
for quota, expiry or by the operator connected again with it.

An inbound left without clients now gets a placeholder user whose key
is random and held only in memory, in the config and in the running
core alike. The core stays multi-user, and disabling the last real
client takes the same remove-and-restart path as any other client, so
its live sessions end as well.
2026-11-01 00:30:14 +01:00
MHSanaei 07bbf4788a fix(node): remove a client a pushed inbound disables from the running core
UpdateInbound applies a master's push to the node's running core as the
difference between the stored and the pushed user sets, but both sets
dropped clients whose traffic row is off, and the same transaction had
already stored the pushed flags on those rows. A client the push
disabled therefore vanished from both sets, no RemoveUser was sent and
the node's Xray kept serving it.

The running set is now taken from the stored row's own enable flags,
so the client the push switches off is removed from every handler,
including a second inbound sharing the same traffic row.
2026-11-01 00:24:29 +01:00
MHSanaei c0bd572085 fix(sub): look up a socket child's fallback front on the child's own host
projectThroughFallbackMaster found the front of a unix-socket child by
scanning every inbound's settings.fallbacks for that socket. A node's
inbound settings are mirrored from the node, so a node could name any
socket and have its own port, security and external proxies substituted
into the subscription links of another host's child.

A socket exists only on the host that binds it, so the lookup, and the
same scan the inbounds page uses for share links, now considers only
inbounds hosted where the child runs.
2026-11-01 00:21:03 +01:00
MHSanaei bc2c64d901 fix(amneziawg): bound the egress relay's pending tunnel DNS lookups
relayUDP started a goroutine for every domain-addressed datagram and
each lookup opened its own netstack UDP endpoint, so the lookups in
flight were bounded only by the client's send rate: one proxy user could
grow the panel by gigabytes and exhaust the outbound stack's ports,
stalling every user of that outbound.

One association may now have 32 datagrams waiting on DNS and drops the
rest, a cached name is delivered from the reader loop without a lookup,
and resolveTunnelVia takes one of 1024 process-wide slots before it
opens an endpoint, which also covers the TCP CONNECT path.
2026-11-01 00:19:34 +01:00
MHSanaei 1810996db6 fix(amneziawg): stop the TUN handoff from parking a peer's receiver
stackTun.WriteNotify runs on whichever goroutine made the stack emit a
packet; for echo replies and RSTs that is amneziawg-go's per-peer
receiver. It waited without bound for room in the outbound queue, whose
reader waits on that same receiver while the device rebinds, so a peer
that flooded the stack while nothing drained the queue deadlocked the
next reconfiguration of the interface.

The handoff now tries once, then waits at most 5 ms and drops the
packet, as channel.Endpoint already does when its own queue is full.
2026-11-01 00:17:33 +01:00
MHSanaei 5e6ffebdf2 fix(amneziawg): give each peer a share of the forwarder and UDP relay
The embedded interface's TCP forwarder admits 65535 connections in
flight for all peers together, and a forwarded request holds its slot
until the handshake ends or gVisor gives up on it. One client sending
bare SYNs from fresh source ports therefore took every slot and left
the other clients' connections unanswered. The UDP relay likewise
opened a SOCKS5 association and a host UDP socket for every fresh
tunnel source port with no per-peer bound.

The forwarder now names the peer on the SYN, refuses a source no peer
owns and caps one peer at 4096 connections; the UDP relay caps one peer
at 1024 sessions and drops the datagrams that would need more.
2026-11-01 00:16:04 +01:00
MHSanaei 05abd00bf6 fix(sub): encode the link host and the VLESS id in share links
joinHostPort wrote an endpoint address into the authority of every URL
share link unencoded, and genVlessLink alone skipped encodeUserinfo for
the client id. Both can come from a node's sync (an externalProxy dest,
an adopted host row, the client id), the finished link is never
re-parsed, and the raw subscription is one link per line: a line break
in either added lines of the node's choosing to the master's
subscription, and an id carrying "@" moved the client's VLESS links on
other inbounds to another server.

Whitespace, control characters and URI delimiters in the host are now
percent-encoded, which leaves host names, IP literals and zone ids
unchanged, and the VLESS id is encoded as userinfo like every other
protocol's credential.
2026-11-01 00:10:23 +01:00
MHSanaei d3df3e9ea7 fix(frontend): keep node-reported values one URL path segment
InboundInfoModal put a client's email into /panel/api/clients/ips and
clearIps unencoded, and the host mutations did the same with a host
group id. Both values reach the master from a node's sync without the
panel's own character rules, so "../" and "?" in them chose the route
and query of the admin's authenticated POST; the IP-log request fires as
soon as the window opens. Encode them as every other client route in
the SPA already does.
2026-11-01 00:08:35 +01:00
MHSanaei daf5b16eda fix(wireguard): hold /0 allowedIPs to the overlap guard
The tunnel overlap guard treated every /0 allowedIPs entry as claiming
no address, while the entry was stored with its host bits and the
consumers read them back: the AmneziaWG port forwarder resolves a peer's
target from them and the device routes the /0 by longest prefix. An
entry such as 10.8.1.2/0 therefore passed next to another client's
10.8.1.2/32, and forwarded ports reached that client's device.

A written entry now counts with its whole range, so a /0 collides with
every held address of its family. On the stored side only a literal
0.0.0.0/0 or ::/0 default route keeps nobody out, and a /0 with host
bits claims that host. Editing a legacy peer that echoes its stored
default route still works.
2026-11-01 00:06:09 +01:00
MHSanaei d114845e44 fix(clients): store client group names trimmed
The groups page lists clients.group_name as stored, while every group
action trims the name it is given. A name with surrounding white space,
saved by a node snapshot or by a client edit, was listed as a separate
group, and deleting, renaming or resetting that row acted on the real
group of the trimmed name instead.

Group names are trimmed wherever a client record is built or updated,
and a one-time seeder folds stored names onto their trimmed form.
2026-11-01 00:03:48 +01:00
MHSanaei 376ab34c0f fix(clients): purge depleted clients by their exact usage key
DelDepleted turned the emails of depleted usage rows into client
identities through BulkDelete, which trims each email for request
bodies. A depleted row keyed by a padded email, which a node's stats
could store and which belongs to no client, therefore resolved to the
healthy client it resembled and deleted it. Rows whose key carries
surrounding white space name no client and are skipped.
2026-11-01 00:02:25 +01:00
MHSanaei 7907843941 fix(node): let a node change only the clients it alone hosts
A node snapshot was merged into the master's client records by email, and
the only ownership check filtered one derived copy of it: the node's
settings were still stored and re-synced later, its stats still merged,
and a record linked to the master as well as the node took every value
the node reported, including credentials rendered into the master's own
Xray config.

Each snapshot now passes one gate before anything is written. A record
also linked to the master or another node keeps the master's values
apart from per-link fields and the lifecycle the merge already settles;
a record linked only elsewhere, a detached record, a padded or
case-variant email and a new client holding another client's subId are
dropped from the stored settings as well. A soft-orphaned record can be
taken back only by the node whose merge orphaned it, which the new
clients.sync_orphaned_by column records. A node tunnel no longer owns
the peer keys of a client a master-local tunnel also serves.
2026-11-01 00:02:12 +01:00
MHSanaei f66e369e41 fix(node): keep pushed inbounds off the node host's own resources
A node-sync token, or a master enrolled with the mTLS certificate, may
push inbounds, and the node stored them as already validated. An
inbound is effectively config for a core running as root, so a push
could serve the host's files (hysteria file masquerade), relay to its
own services or the core API (masquerade proxy, fallback dest, REALITY
target), write files (masterKeyLog), use local or templated XDRIVE
storage, fetch URLs (ECH, realm masks), or run tun/http/mixed inbounds.

A push now refuses those fields unless the node already stores the same
value, which keeps rows configured by the node's own admin syncing:
relay targets must be public or another of this node's inbounds, and
only node-eligible protocols are accepted. Every caller is refused env:
references, which the core expands from the panel's environment, and
the inbound listen address is JSON-encoded into the config instead of
quoted by hand.
2026-10-31 23:30:02 +01:00
MHSanaei e1950b7575 fix(amneziawg): bound the egress relay's UDP sessions as a whole
Every UDP destination a proxy user reached through the AmneziaWG egress
opened a session with a goroutine, a port and a buffer, per association,
and a Mux frame opens a new association, so one user could hold sessions
without limit. All egress UDP sessions now share one budget of 4096;
when it is spent the longest-idle session is closed to make room.
2026-10-31 15:20:50 +01:00
MHSanaei f988e9677f fix(amneziawg): cap port-forward relays and UDP sessions
A forwarded port accepts unauthenticated traffic, and every TCP
connection and every new UDP source got its own relay with a goroutine
and a buffer, with no limit, so anyone reaching the port could exhaust
the panel's memory. Live TCP relays and UDP sessions are now capped at
2048 each across all forwards; past that a connection is closed and a
datagram from a new source is dropped, while open flows keep going.
2026-10-31 15:18:43 +01:00
MHSanaei ac896a9f95 fix(amneziawg): drop a removed client's flows before its address is reused
Removing or re-keying a client reconfigured the device in place but left
its TCP and UDP flows standing, and withdrawing a port forward stopped
only new connections. The next client given the same tunnel address
inherited those flows: it could read what outside hosts sent to the
former client and write back into those connections.

After an in-place reconfigure, every stack endpoint and UDP relay
session pinned to an address whose peer was removed or changed is
aborted, and closing a port forward cuts the connections it is relaying.
2026-10-31 15:10:32 +01:00
MHSanaei 0ea11e1643 fix(amneziawg): keep a port forward accepting after a failed accept
A TCP port forward's accept loop returned on any accept error, leaving
the socket bound and the forward registered as open, so nothing served
the port again until a panel restart; briefly pushing the panel to its
open-file limit was enough. Forward and egress accept loops now log,
wait 100 ms (or until closed) and keep accepting.
2026-10-31 15:06:34 +01:00
MHSanaei 53ee53ed9c fix(amneziawg): take a forwarded TCP connection's source from the request
The TCP forwarder handler read the tunnel client's address from the
accepted connection's RemoteAddr, which asks the live endpoint and is
nil once the peer's RST has been processed. A client sending SYN, ACK
and RST through its own tunnel could hit that window and crash the panel
on a goroutine with no recover. The forwarder now hands the handler the
source from the request's endpoint ID, as it already did for the
destination.
2026-10-31 15:05:12 +01:00
MHSanaei 6d90dcfe0c fix(tuic): bound what unauthenticated connections cost
Anyone reaching a TUIC port could start QUIC handshakes without ever
reading a reply, each holding state and goroutines, and an unauthenticated
connection got a handler goroutine per stream it opened. Both grew until
the panel ran out of memory.

The listener now counts connections from their first packet until they
authenticate or close: past 64 they must answer a stateless Retry, and
past 1024 in total or 32 per verified source (IPv4 address or IPv6 /64)
they are refused. Before authenticating, a connection may start at most
16 handlers per stream kind; further streams are reset.
2026-10-31 15:03:21 +01:00
MHSanaei e51a32b045 fix(tuic): bound one client's connections and UDP relays
The UDP association cap applied per QUIC connection, and nothing limited
how many connections one credential could authenticate, so a single
client could hold enough UDP relays (each a host port) to exhaust the
host's ephemeral range. A client now gets at most 32 connections and 512
open UDP relays across all of them; past that a connection is refused at
authentication and a relay is not opened.
2026-10-31 14:59:20 +01:00
MHSanaei 838923058b fix(tuic): end a UDP response relay with its association
Dissociating a UDP association closed its SOCKS session, but the
response relay could be blocked opening a stream on credit the client
never grants, and only the connection's end released it. A client could
leave one such goroutine per Packet/Dissociate round, past the cap on
live associations. Each relay now runs under a context the association's
removal cancels, whichever path removes it.
2026-10-31 14:55:29 +01:00
MHSanaei 5cf2ab4f69 fix(tuic): bound the timeout settings at a day
max_idle_time and authentication_timeout are second counts multiplied
into a Duration with no bound, so a stored value past the int64 range
wrapped negative and the QUIC listener panicked on the first
ClientHello, taking the panel down at every start until the row was
fixed. Saves now refuse more than a day, and a stored row past it fails
its own listener start instead.
2026-10-31 14:53:20 +01:00
MHSanaei 425a2bbf08 fix(amneziawg): bound what a handshake allocates and the tunnel MTU
Every handshake initiation builds Jc junk packets of Jmin..Jmax bytes
and the I1-I5 signature packets, and the limits allowed the full uint32
range and lengths of any size, so one connection made the embedded
device allocate without bound. The MTU reaches the device as an int32
and a wider value made it panic. Both ended the whole panel at the next
connection, after every restart.

Jc is now at most 128, Jmin/Jmax and each signature packet at most one
65535-byte datagram, and the MTU at most 65535. Saves refuse more, the
device setup refuses rows stored before, and the stored MTU is capped
where it is read. The form schemas carry the same limits.
2026-10-31 14:50:18 +01:00
MHSanaei dcdc56c64b fix(traffic): stop adding capped counters in SQL
Per-client counters are stored up to TrafficMax, which a node can reach
by reporting huge usage, and the group totals, group baselines and the
per-client reset lookup added two such counters inside SQL, overflowing
it: every group listing and reset failed, and no client on that reset
cycle was reset. The counters are now read separately and added in Go,
saturating at TrafficMax.
2026-10-31 14:41:12 +01:00
MHSanaei d69b924baf fix(wireguard): step over wide address claims when allocating
allocateWireguardAddress walked the scope one address at a time and
tested each against every wide claim, so a claim covering an IPv6 /64
(a node can report such allowedIPs) turned the next address-less client
add into a 2^64-step loop that also held every later client change on
that inbound. The scan now jumps past a wide claim in one step, so it is
bounded by the number of claims.
2026-10-31 14:35:01 +01:00
MHSanaei 351a01ff0c fix(clients): bound the auto-renew interval at a century
An interval renewal turned the client's reset days into milliseconds
with no bound, so a value that wraps int64 (a node reports it unchecked)
sent the catch-up loop stepping through time for seconds per client
while the traffic writer held the database. Intervals above 36500 days
no longer renew, and every client write refuses them.
2026-10-31 14:32:31 +01:00
MHSanaei 47e9ef1a07 fix(sub): convert far expiry dates to Jalali in constant time
gregorianToJalali walked the calendar one month at a time from a 2024
reference, so an expiry near the int64 limit (a node reports expiries
unchecked) cost seconds of CPU on every subscription fetch that renders
{{JALALI_EXPIRE_DATE}}. Jalali leap years repeat every 33 years, so
whole 33-year blocks are skipped before the walk.
2026-10-31 14:28:18 +01:00
MHSanaei fe5d704d67 fix(api): cap the client traffic list a master pushes by entry count
pushClientTraffics decoded the pushed list whole, and each "{}" became a
full traffic row, so a body under the size cap could cost the panel
close to a gigabyte. The list is now decoded entry by entry and refused
past 262144 entries. The capped decoder the node runtime already used
moves to json_util so both share it.
2026-10-31 14:25:21 +01:00
MHSanaei 44a502a407 fix(clients): probe inbound settings with a short literal pattern
Deleting a client by an email no record holds fell back to a settings
LIKE '%email%' over every inbound with the raw route parameter, which
costs SQLite the settings length times the pattern length while it holds
the database; a long near-miss email froze the panel for minutes, and
wildcards were not escaped. The pre-filter now uses the first 64 bytes
of the email, escaped; the exact client match after it still decides.
2026-10-31 14:24:28 +01:00
MHSanaei a69f2dee13 fix(node): keep a node's online and IP reports to its own clients
A node's online tree and both client-IP reports were stored as sent:
they could name any client of the master or another node, under any
panel's GUID, dated into the future, and in any number. That marked
master clients online, fed source addresses for clients the node does
not serve into the IP limit and its fail2ban log, and planted rows the
retention sweep never removed.

Each report is now cut to the clients linked to the node's inbounds and
to the node's own key and its sub-nodes' origins (never this panel's or
another node's GUID); timestamps are capped at now, and one client's
list from a node keeps at most its 256 newest entries.
2026-10-31 14:17:43 +01:00
MHSanaei 4d27313c5e fix(node): give each node one push budget per traffic sync
After a sync tick disabled clients, the master pushed every affected
node inbound with its own 4 s timeout, a few at a time, and the job
served no other node until all were done; a hanging node could stall it
for as long as it had inbounds to report. Pushes are now grouped by node
and each node shares one budget, still a few inbounds at a time; what
does not make it stays for the reconcile, the node being dirty already.
2026-10-31 14:07:08 +01:00
MHSanaei d005d89a8b fix(node): settle each queued node reset on its own
A per-client reset the node never accepted stopped the delivery loop:
resets queued before it were re-sent on every sync, each time wiping the
usage made since, and those after it were never sent. Each reset now
settles by itself, a refused one moves to the back of the queue, and the
loop goes on until the context is spent.

A node also delivered its master's reset to its own sub-nodes inline, so
one silent sub-node held the master's request until it timed out and was
replayed; the inline attempt now waits at most a second and the rest
finishes, or stays queued, in the background.
2026-10-31 14:01:45 +01:00
MHSanaei 469758837a fix(node): reconcile the local core after node disables without forcing it
The node traffic sync restarted the master's own Xray with force after
any tick that disabled clients, so a node reporting a fresh over-quota
client every few seconds dropped every local user's connections each
time, though nodes serve none of them. Use the non-forced restart the
local traffic job uses: an unchanged local config keeps the core, and a
change still restarts when restartXrayOnClientDisable asks for it.
Reaching RestartXray needs a running core, so no unit test covers this.
2026-10-31 10:44:59 +01:00
MHSanaei f67111a08a fix(traffic): keep enforcing limits when one inbound's settings are broken
Disabling depleted clients rewrote each affected inbound's settings and
aborted the whole lifecycle tick when one blob did not parse, so a node
storing a malformed blob on the master switched expiry and quota checks
off for every inbound. Unparseable node settings are now treated like a
snapshot that drops the hub's clients (the hub keeps its copy), and an
unparseable stored blob is left as it is while the client's rows are
still disabled; the rewrite also works one level deep like the others.
2026-10-31 10:43:40 +01:00
MHSanaei a203056949 fix(node): rewrite a node's settings blob without decoding all of it
Dedupe, tombstone stripping and the lifecycle lift decoded a node's
whole settings blob into generic maps and wrote it back indented, so a
deeply nested value anywhere in it cost the square of its depth on every
merge (10 KB became 50 MB). They now split the blob one level deep, read
only email, expiryTime and enable of each client entry, keep everything
else as raw text and write the result compactly.
2026-10-31 10:39:13 +01:00
MHSanaei 8ca321138b fix(traffic): keep lifecycle statements under SQLite's variable limit
Three statement shapes bound one SQL variable per row and failed past
SQLite's limit, which a node can reach by itself with the clients and
inbounds it reports: the depleted-client lookup and disable in
disableInvalidClients (stopping every expiry and quota check), the
whole-slice Saves of renewed inbounds and traffics (stopping renewals),
and Preload("ClientStats") over all inbounds (stopping config builds).
The IN lists are chunked, the Saves go 100 rows at a time, and client
stats are attached in chunks instead of preloaded.
2026-10-31 10:34:35 +01:00
MHSanaei fd3fa86982 fix(node): drop a swept inbound's bookkeeping from the node runtime
The node Remote keeps a fingerprint, a remote id and possibly an alias
per inbound tag. When the master swept an inbound the node stopped
reporting, those entries stayed, so a node rotating tags grew the maps
for as long as the master ran. The sweep now hands every removed tag to
Remote.ForgetInbound, which drops all of it in both tag forms, and a
master-driven delete uses it too. The sweep's call is wiring;
TestRemoteForgetInboundDropsEveryTrace covers what it drops.
2026-10-31 09:28:39 +00:00
MHSanaei d4b80b5d80 fix(node): merge each node inbound once and sweep what the node dropped
Two shapes of a node's inbound list cost the master without bound:
- an inbound listed many times was merged once per listing, every pass
  walking its whole client set again; the first listing now wins and the
  repeats are dropped;
- disabled inbounds were never swept, an exemption meant for inbounds the
  master created but has not delivered yet, so a node could report any
  number of them and the master kept them all; the exemption now covers
  only rows without an origin, i.e. never reported by the node.
2026-10-31 09:26:31 +00:00
MHSanaei 87f3f54ea6 fix(node): bound the panel GUIDs a peer reports
A peer's panelGuid is copied into nodes.guid and into every inbound row
it reports (an indexed column), a forwarded origin GUID likewise, and a
GUID-keyed IP tree or a master's global-traffic push keys one row per
client. One oversized value therefore multiplied into gigabytes stored.
GUIDs over 64 bytes (panels mint 36-character UUIDs) now fail the
heartbeat, fall back to the reporting node's key, or are dropped.
2026-10-31 09:22:24 +00:00
MHSanaei 9cf8c87e8d fix(netsafe): give a guarded dial one time budget for every address
SSRFGuardedDialContext tried each address of a name in turn with a fresh
10 s, so a name resolving to thousands of unanswering addresses held a
dial (and the caller's goroutine) for hours; a node can choose such a
name for itself. The whole call now shares one 10 s budget, divided over
the remaining addresses with net.Dialer's 2 s floor. Multi-address DNS
answers cannot be produced in a unit test here, so no test reaches it.
2026-10-31 09:19:54 +00:00
MHSanaei 5a1ea42785 fix(node): bound what a node's answers can cost the master
Node responses are untrusted input on the master, and a few shapes still
cost it without bound under the 64 MiB body cap:
- a success=false message became an error (logged, stored as lastError)
  in full; it is now cut to the 8 KiB the non-200 diagnostic already uses;
- each "{}" of a reported list decodes into a whole struct, so lists are
  now decoded entry by entry with per-endpoint caps (1024 descendants,
  65536 inbounds and host groups, 1M client-IP rows), and the GUID-keyed
  trees and maps are capped at 1M values;
- a TLS handshake the node never answers kept a socket and goroutines
  alive forever, since the transport keeps dialing for its pool after the
  request gives up; node transports now give up after 10 s.
2026-10-31 09:19:51 +00:00
MHSanaei 08482154c2 fix(node): keep master requests on the node they were sent to
The master's node clients followed redirects to any host, presenting the
same panel-wide credentials (the mTLS client certificate) and replaying
the request there, so one node could have the master push to another.
Node clients and the certificate-pin probe now stop at a redirect off
the original host:port and report the 3xx as the node's answer. The
probe change is wiring; TestNodeClientsDoNotFollowARedirectToAnotherHost
covers the guard.
2026-10-31 09:14:57 +00:00
MHSanaei f1e1b0fb0c fix(i18n): publish the translation bundle only once it is complete
InitLocalizer filled the shared bundle in place, and the Telegram bot
runs it again while the panel is serving, so a request could read the
bundle's maps while they were being written and the process died on a
concurrent map access. Build the bundle privately and publish it, and
the bot and web localizers, through atomic pointers; the lazy load in
the middleware publishes only if nothing else did first.
2026-10-31 09:11:54 +00:00
MHSanaei e3736564a3 perf(wirecodec): decode a config envelope into one buffer
The shared zstd decoder grew its output per frame, copying everything
decoded so far for each extra frame, so 30 KB of tiny frames cost about
8 GB of allocation and seconds of CPU per request. The output buffer is
now sized once from the first frame's declared size (Compress writes a
single frame) with decoding capped at that capacity, and callers waiting
for a decoder worker no longer each hold a buffer.
2026-10-31 09:09:22 +00:00
MHSanaei cea69a4117 fix(api): accept config envelopes only on requests that carry a body
The envelope middleware buffered and decoded any request carrying its
headers, GETs included, which the router-wide body cap skips and which
read-only tokens may call; its raw read had no cap of its own. Refuse an
envelope on safe methods and read at most the decoded-size limit.
2026-10-31 09:09:06 +00:00
MHSanaei b1108a9e81 fix(web): match the WebSocket origin on scheme and port, not only host
The live-update socket accepted any Origin whose host name matched, but
cookies do not separate ports or schemes, so a page served by another
service on the same host name could open it with the admin's session.
Only http(s) origins are accepted now (https when the panel terminates
TLS itself), and the port must match whenever the Host header has one.
2026-10-31 09:06:41 +00:00
MHSanaei ce532e8df7 fix(web): start a fresh session at login
SetLoginUser added the user to whatever session the login request
carried, so its CSRF token outlived the login. Anyone can fetch an
anonymous session with its token, and a host on a sibling domain can
plant that cookie in the admin's browser. Clear the session before the
user is stored; the next page load or token fetch mints a new token.
2026-10-31 09:05:28 +00:00
MHSanaei 2e14a2a51a fix(web): refuse oversized login usernames before recording them
A failed login keeps its username in the in-memory log buffer, the
limiter table and the notifier queue, and the route accepted any body
the global cap allowed. The login route now takes at most 16 KiB and
answers a username over 256 bytes with the generic failure before any
of those see it; the user setters refuse such a name too, so no stored
username can be one the login route turns away.
2026-10-31 09:04:27 +00:00
MHSanaei 8e8541a5d8 fix(web): count login attempts per address and while they are checked
The login limiter counted failures per (address, username) only after
the check finished, so attempts sent together all passed before the
first failure was recorded, and one address could own every record of
the bounded table and push out the one holding its own failures.

An admitted attempt now holds a slot until it reports back, and an
address is blocked as a whole after five times the per-username limit
spread over any names, which also caps the records one peer can own.
2026-10-31 09:04:23 +00:00
MHSanaei 661a169045 fix(sub): bound what a remote routing profile renders to
Remote routing sources are fetched documents the panel re-renders on
every subscription request. A Clash YAML document stays small through
aliases, which decode to one shared value but are written out in full,
so it now has to fit a 4 MiB written-size budget and 16 levels of
nesting when it is accepted (and when a persisted copy is loaded). A JSON
routing profile could repeat route groups, each copy repeating every
site list; a group now counts once, since a repeat can never match.
2026-10-31 09:00:06 +00:00
MHSanaei 8359a0f72e fix(sub): keep every Clash scalar on its own line
The YAML encoder writes multi-line strings as block scalars and gets
CR and the Unicode line breaks (NEL, LS, PS) wrong inside them, so a
remark, path or header name with such characters could continue as YAML
outside its scalar. Strings with a non-printable rune or invalid UTF-8
are now written double-quoted with escapes, map keys included.
2026-10-31 08:56:24 +00:00
MHSanaei 534afe13ab fix(sub): treat external subscription providers as untrusted input
A client's external subscription is fetched from a third party on every
subscriber refresh, and the fetcher trusted it more than its siblings:
- it dialled any address, redirects included; it now goes through the
  SSRF guard like the outbound subscription and remote routing fetchers;
- it forwarded any line containing "://"; it now keeps only server share
  links, so app deeplinks and plain URLs never reach the subscriber's app;
- it bounded the body but not the link count (now 1000) or the headers
  (now 64 KiB), and stored the raw error, URL included, on every row; the
  stored error is now host-only and at most 1 KiB.

The link parser also refuses links that nest JSON past 16 levels, since
the JSON subscription pretty-prints it at quadratic cost, and external
configs are marshalled compactly until the final, single indent pass.
2026-10-31 08:53:27 +00:00
MHSanaei 3cdb7e2b49 fix(traffic): keep client accounting independent of outbound counters
The traffic poll read every Xray counter in one QueryStats reply under
gRPC's default 4 MiB receive limit; past it the whole poll failed and
no client was counted, so quotas stopped being enforced. Outbound
counters carry tags derived from third-party link remarks, so one long
remark was enough.

Poll the user, inbound and outbound families separately with a 64 MiB
limit, keep counting the families that answer, and cap a subscription
link's slug at 64 runes so the tag stays small to begin with.
2026-10-31 08:46:13 +00:00
MHSanaei 61ee202750 fix(xray): bound what the in-process config loader is asked to expand
The panel runs xray-core's conf.Build() in its own process on inbounds
and on outbounds that come from pushes and subscriptions. Some values
make it allocate or loop without bound (an XHTTP sessionIDLength, udphop
port lists, any number of finalmask entries, each xmc one costing an RSA
key), and certificate paths are read whole whatever they name.

Refuse those before any build: sessionIDLength above 1024, more than one
port range's worth of udphop ports per stream, more than 16 masks per
stream, more than 64 xmc masks per outbound subscription, and certificate
or key files that are not regular files of at most 1 MiB. Inbound saves,
the TUIC readers and the certificate-pin helper apply the file rule too;
the pin helper also stops looking at paths that name files on a node.
2026-10-31 08:43:30 +00:00
MHSanaei db20e491e6 fix(net): stop per-request clients parking connections to remote hosts
The node certificate probe builds a transport for one request and dropped
it with the connection still parked, and the remote routing client kept
idle connections with no timeout; either stayed open for as long as the
remote side wanted. Close the probe's idle connections when it returns,
and turn keep-alive off for routing downloads, which are minutes apart.
The routing client dials only public hosts, so no loopback test reaches
it; TestFetchCertFingerprintLeavesNoIdleConnection covers the probe.
2026-10-31 08:32:58 +00:00
MHSanaei f1444bec3f fix(outbound): store subscription refreshes without trusting the row read
The refresh rewrote the whole row it read before a download whose length
the provider controls, so an admin's delete, disable or edit made in the
meantime was undone; Update had the mirror problem. Both now write only
their own columns, and a refresh of a row that was deleted or re-pointed
stores nothing.

Around the same path:
- the preview fetches without creating (and leaking) a scratch row, and
  Create keeps enabled=false instead of falling back to the column default;
- fetch errors name the host but not the subscription URL, which is a
  credential and ends up in the log and in lastError;
- each fetch closes its transport's idle connections;
- one document loads at most 2000 links, and the cut is reported;
- wireguard outbounds from a subscription always run on the userspace TUN;
- editing a subscription schedules an Xray reload like delete and move do.
2026-10-31 08:31:03 +00:00
MHSanaei 2d72d9c715 fix(xray): check outbounds the way the core's handlers will
ValidateOutboundConfig ran only conf.Build(), but the core parses some
fields again in its handler constructors, and a failure there stops the
whole core at start: wireguard local addresses and peers, the vless
encryption keys and padding, and shadowsocks 2022 keys. Mirror those
parses with the core's own helpers, and turn a panic in the loader into
a rejection so one bad link cannot unwind the caller.
2026-10-31 08:23:24 +00:00
MHSanaei 1fa8cc785d fix(outbound): keep imported links to remote-server settings
A share link describes a remote server, but extra=, fm= and the vmess JSON
copy provider objects verbatim, so a link could carry keys that only make
sense on the importing host. Both link parsers now drop dial-redirect and
host file keys (matched case-folded, as the core binds them).

Outbound subscriptions also refuse outbounds that dial this host or its
network without allowPrivate, any that target the core's own API or
metrics port, and any that take their server from DNS records. Rows
stored earlier are filtered the same way when the config is built.
2026-10-31 08:19:44 +00:00
MHSanaei 4167597a15 fix(database): replace the stored session secret once on upgrade
The session cookie key is generated once and kept forever. Keys stored by
older releases came from a weaker generator, and a stored value cannot be
told apart from a current one, so drop it once through a seeder and let
GetSecret store a fresh crypto/rand key on the next start. Admins log in
again once after upgrading; fresh installs record the seeder up front.
2026-10-31 08:10:36 +00:00
MHSanaei c621ba9014 v3.11.0 2026-10-25 20:08:14 +01:00
MHSanaei 7f82daa42f feat(finalmask): tlshello fragment presets for client editors
The host, JSON subscription and outbound mask editors now offer PattN's
two chained tlshello fragment presets; a pick replaces the TCP mask list.

The fragment length validator rejected a 0 minimum in every entry, but
xray-core only refuses it in the last lengths entry. An earlier 0 is an
empty segment, which the tlshello-0-len preset relies on.
2026-10-25 15:54:53 +01:00
MHSanaei f992694712 feat(tls): cipher suite presets and a uTLS fingerprint warning
Adds PattN's semi-python and real-firefox client cipher lists as presets
in the cipher suites picker. A preset replaces the whole list because the
order is part of the ClientHello.

The inbound TLS form now warns when cipher suites are set under a uTLS
fingerprint other than "unsafe": clients ignore them there, while an
empty fingerprint is already pinned to unsafe in share links.
2026-10-25 15:54:48 +01:00
MHSanaei c3d4a153b3 fix(sub): pin fp=unsafe on links that carry cipher suites
Xray builds the ClientHello with uTLS for every fingerprint except
"unsafe", and GetFingerprint("") resolves to chrome, so cipherSuites are
dropped unless the fingerprint is exactly "unsafe". A host or inbound
that set cs and left the fingerprint blank shipped links whose cs did
nothing in v2rayN, PattN and every other Xray-based client.

Raw links (query and vmess JSON), the JSON subscription and the panel's
own link generator now emit fp=unsafe when cs is present and the final
fingerprint is empty. An explicit fingerprint is left as configured.
2026-10-25 15:54:42 +01:00
Hamed 8eb58a4a14 perf(ip-sync): avoid redundant client IP reads and writes (#6930)
The access-log scanner and the cross-node merge stored the same IP list in
opposite orders, so neither saw a row as unchanged, and the merge re-read
every preloaded row and re-upserted every attribution row each sync cycle.
All writers now share one canonical encoder (timestamp, then IP ascending),
the first CAS is seeded from the preload, and identical attribution rows are
skipped. A create race between the scanner and node sync now merges into the
winning row instead of dropping the loser's IPs, which on PostgreSQL also
aborted the scanner's whole scan transaction.

Closes #6927
2026-10-25 14:13:18 +01:00
Hamed 952c1c751a perf(inbounds): batch client identities for stats enrichment (#6929)
backfillClientStats loaded every column of every client once per inbound,
although enrichment only reads UUID, email and subscription ID. Each inbound
list read (panel list, the master's node traffic poll, Xray config builds,
bots) repeated that wide join per inbound. Identities now come from one narrow
clients/client_inbounds projection per 400 inbounds, honouring the supplied
transaction; stats output, sibling-traffic append order and the #6436
clients-table source of truth are unchanged.

Closes #6926
2026-10-25 14:01:46 +01:00
VAIO73 be25314157 fix(monitoring): refresh cached public IP addresses (#6933)
The public IPv4/IPv6 lookup was cached for the life of the panel process, so
Overview kept showing an old address after the server's external IP changed,
and a failed first lookup pinned N/A until a restart. Addresses now refresh
every five minutes, a failed IPv4 lookup retries after a minute, failed IPv6
lookups back off 1/2/5/15 minutes, and a temporary failure keeps the last
known address. Status sampling still never waits on the lookup.
2026-10-25 13:50:29 +01:00
YoungReckless4 56ac8d0fc7 feat(amneziawg): add opt-in Linux kernel datapath (#6877)
* feat(amneziawg): add opt-in Linux kernel datapath

Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply.

Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed.

* fix(amneziawg): align readiness and endpoint reporting

Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support.

Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass.

* style(amneziawg): format readiness test

* test: reproduce kernel AWG client validation gaps

* test(service): cover kernel AWG shared client fanout

* fix(amneziawg): validate kernel client fanout and config values

* test(amneziawgkern): cover owned interface lifecycle recovery

* fix(amneziawgkern): recover owned interfaces on restart

* test(amneziawgkern): retain visibility after recovered cleanup failure

* fix(amneziawgkern): report recovered interfaces while cleanup retries

* style(amneziawgkern): satisfy static analysis

* test(amneziawgkern): cover long rendered peer comments

* fix(amneziawgkern): accept long generated config comments

* test(amneziawgkern): refuse foreign occupied interfaces

Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries.

* test(amneziawgkern): cover stale interface collisions

Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown.

* fix(amneziawgkern): verify interface ownership before mutation

Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs.

Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check.

* build: update Go toolchain to 1.27.3

* test(amneziawg): reproduce unsafe key rotation recovery

Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure.

* fix(amneziawg): recover interrupted key rotations

Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership.

* fix(scanner): align Go image with module version

* fix(docs): pin pnpm release with bootstrap fix

pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely.

* fix(xray): build core from pinned module

The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets.

* fix(tests): stabilize local network fixtures

Keep the TUIC stream fragmentation check on a working UDP loopback path,
allocate the AmneziaWG fixture ports dynamically, and wait for a real
Xray stats response before starting API mutation assertions. Recreate
failed readiness channels so an early connection cannot stall the probe.

Verified host and offline parallel Go suites, repeated occupied-port and
UDP tests, race/shuffle runs, and golangci-lint. Production behavior and
dependency versions are unchanged.

* fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK

main gained the sk-SK locale after this branch added its kernel-mode keys
to the other 13, so i18n-dead-keys.test.ts failed on the merged tree.

* refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick

awg-quick evaluates PostUp/PostDown through a root shell, so every value the
panel rendered into its config was one validation slip away from root command
execution. The interface is now built with direct ip/awg/nft execs from a
setconf-only file: link add, awg setconf, address, mtu, an atomically replaced
nftables table per inbound, and the IPv4 forwarding sysctl written directly.

- Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables
  and configs under the panel's data dir, so nothing collides with an
  operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg.
- FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker
  and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel.
- A full panel stop records the final counters and tears the interfaces down,
  like the embedded datapath; a stopped panel enforces no quota or expiry.
  Crash survivors are still adopted without a rebuild.
- The job reads counters before reconciling, and from every managed interface,
  so a client disabled for quota, or a removed inbound, is charged for its last
  interval instead of losing it with the dropped peer.
- An unchanged reconcile tick forks no process (it probed the live key with
  `awg show` every 10s); readiness no longer loads the module from a GET.

Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and
egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM
teardown leaving no link, table, FORWARD rule or config behind.

* fix(amneziawg): gate kernel host readiness only when a save starts the datapath

Every save of a kernel-mode inbound re-ran the host preflight, so a host that
lost its module (a kernel upgrade without a DKMS rebuild) refused even
disabling the inbound or switching it back to embedded mode. Host blockers now
apply only when the save starts the kernel datapath: a new inbound, a switch
from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded
ports, pinned listen) still apply to every save.

* feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it

A node built before kernel mode drops the unknown kernelMode field and serves
the inbound on the embedded datapath while the master's form shows kernel
mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses
to push a kernel-mode inbound to a node that has not advertised it, learning
the caps with an inbound list first when this Remote has not heard from it.

* fix(amneziawg): pin the kernel module installer to verified commits

The installer ran as root and built whatever upstream tag sorted newest at
install time. It now clones fixed tags (module v3.1.20260906, tools
v3.1.20260812) and refuses unless each resolves to its recorded commit. The
udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat
upstream, so the patch never matched.

* fix(i18n): translate the kernel-mode AmneziaWG strings

All thirteen non-English locales carried the English text verbatim.

* fix(amneziawg): skip the kernel traffic flush when no kernel interface runs

Shutdown listed the desired AmneziaWG inbounds from the database even on a
panel with no kernel interface, an unneeded query on every stop. A server
stopped before its database was opened panicked on it, which turned
TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-25 13:24:50 +01:00
MHSanaei 4d6db99be8 fix(docker): ship the right Xray and mtg binaries on arm and 386 images
DockerInit.sh only received TARGETARCH, matched no case for Docker's "386"
and named mtg after its own platform token instead of the panel's GOARCH
lookup, so three of the five published platforms were broken:

- linux/386 fell through to amd64: amd64 binaries named xray-linux-amd64,
  while the panel looks for xray-linux-386 / mtg-linux-386.
- linux/arm/v6 got the ARMv7 Xray and mtg builds, which die with an illegal
  instruction on ARMv6 boards.
- linux/arm/v7 and v6 named mtg mtg-linux-arm32; the panel looks for
  mtg-linux-arm (install.sh renames it the same way), so MTProto never ran.

The Dockerfile now passes TARGETVARIANT, the script maps every docker.yml
platform to the release asset and to the names the runtime looks up, and an
unknown architecture fails the build instead of silently packaging amd64.

Checked by running the old and new script for each docker.yml platform in
golang:1.27-alpine and reading every binary's GOARCH/GOARM with
`go version -m`: the old one fails 386, arm/v6 and both arm mtg names; the
new one passes all five.
2026-10-25 11:46:50 +01:00
YoungReckless4 39935ed9ef fix(tests): stabilize local network fixtures
Keep the TUIC stream fragmentation check on a working UDP loopback path,
allocate the AmneziaWG fixture ports dynamically, and wait for a real
Xray stats response before starting API mutation assertions. Recreate
failed readiness channels so an early connection cannot stall the probe.

Verified host and offline parallel Go suites, repeated occupied-port and
UDP tests, race/shuffle runs, and golangci-lint. Production behavior and
dependency versions are unchanged.

Split out of #6877 so that pull request's squash carries only the feature.
2026-10-25 11:44:58 +01:00
MHSanaei 96069e9393 perf(amneziawgnet): autotune the embedded TCP receive buffer up to 8 MB
gVisor ships with receive-buffer moderation off and a fixed 1 MB window
capped at 4 MB, so every client upload relayed through the embedded datapath
was window-bound on any real-world RTT. Moderation is now on and may grow a
connection to 8 MB; connections still start at gVisor's 1 MB default and
only grow while the relay drains them faster per RTT, so idle peers cost no
more memory.

Single-stream client upload through the panel's own embedded inbound
(netns client, 50 ms RTT via netem, 16 vCPU WSL2 host, iperf3 -t 15):

  before (fixed buffer)        308 / 309 Mbit/s
  moderation, 4 MB cap         312 / 311 Mbit/s
  moderation, 8 MB cap         490 / 509 Mbit/s
  0 ms RTT, before / after     1789-1928 / 1795 Mbit/s

No test can observe a gVisor tuning option without restating it; the numbers
come from a real panel + Xray run in network namespaces with a kernel
AmneziaWG client.
2026-10-25 11:31:38 +01:00
Hamed 64a7697f56 perf(traffic): skip unchanged global writes between heartbeats (#6931)
* perf(traffic): skip unchanged global writes between heartbeats

Persist directional usage changes immediately, including lower resets, while
refreshing identical global totals only at an inclusive one-hour heartbeat.
Use the GORM clock consistently for the existing 24-hour freshness window and
preserve local-only snapshots, quota enforcement, and downstream forwarding.

Fixes #6928

* docs(traffic): describe persisted global heartbeat timestamps

Replace the stale every-push model comment with the change-or-hourly-heartbeat
rule. Keep the local-counter separation, delta accounting, MAX fold and reset
semantics explicit without treating UpdatedAt as a last-push timestamp.

Address the nonblocking review feedback on PR #6931 without rewriting
published history.

Refs #6928
2026-10-25 08:44:05 +01:00
Шакирзянов Амир 73fad424ed perf(sub): cache each inbound's settings without clients for subscription bodies (#6758)
Every subscription fetch read and scanned each inbound's whole settings
blob, about 25 MB at 100k clients, although subscription bodies resolve
their clients from the clients table and never use its clients array.

Each inbound's settings minus the clients array are now cached, keyed by
inbound and shared by all its subscribers; the inbound row, clients,
stats and hosts are still read per fetch, and WireGuard and AmneziaWG keep
full settings for their peers. Panel edits purge the cache through an
invalidator registry the subscription server releases on stop, with a
10 s TTL as the backstop. At 100k clients a hit takes 2.1 ms on SQLite
(72.5 ms before) and 20.7 ms on PostgreSQL (332 ms).
2026-10-24 22:45:46 +02:00
Pejman Yousefi 8da3b3c344 feat(hosts): give a host a pool of SNIs, random per config or shared (#6923)
Rotating SNIs meant one host entry per domain. A host now takes a pool of
SNIs: with more than one, every generated config gets a random entry,
or, with the shared switch on, the whole host group uses one pick per
subscription fetch. Raw, JSON and Clash output, the panel's link preview
and node adoption all carry the pool.

sni stays the pool's first entry, so a rolled-back binary and API callers
that only know the single field keep working; an sni outside the pool
becomes its primary. A host with one SNI renders exactly as before.
2026-10-24 21:05:42 +02:00
25f2002654 14c9bdff89 refactor(frontend): read the panel's navigation from one route manifest (#6922)
The router, the sidebar, the page titles and the command palette each kept
their own list of the panel's pages, so a page added to one could go
missing from another without any test noticing. They all read
src/routes/panelRoutes.tsx now, and a contract test holds the manifest to
the router, the translation bundle and the palette keywords.

The unused useZodForm helper goes, and CONTRIBUTING, frontend/CLAUDE.md and
the frontend README describe the useForm + rhfZodValidate pattern the
panel's forms actually use.
2026-10-24 20:48:31 +02:00
VAIO73 746bdbe9f4 fix(monitoring): read LXC CPU, swap and uptime from the container (#6920)
In an LXC container with LXCFS the Overview showed the container's RAM
beside the host's swap, the host's CPU topology and a CPU percentage from
the host's view. Swap and uptime now come from LXCFS's virtualised files
and CPU from the container's cgroup v2 counter, normalised by the visible
vCPUs and any quota; container CPU and swap history is kept apart from
the earlier host samples.

A value the reader cannot measure (the first CPU sample, or a layout it
cannot verify such as cgroup v1) is reported as unavailable instead of 0:
the dashboard, the node list, node CPU history and the Telegram and
Discord uptime show a dash, and nodes keep the flags in a new
unavailable_metrics column.
2026-10-24 20:24:34 +02:00
MHSanaei 2c18cbe1cd fix(sub): close a deleted host binding only on the inbound it served
Deleting the only host a client was bound to blanked that client on every
inbound. Once the host row was gone, the binding could no longer say which
inbound it guarded, so it failed closed everywhere: a client on inbounds A
and B, bound to one host on A, lost B's link the moment that host was
deleted, and nothing in the panel hinted at it.

Every host delete (host groups, the externalProxy sync, inbound deletion,
the depleted-inbound sweep and the orphan prune) now goes through
database.DeleteHosts, which keeps each deleted host's inbound in
host_tombstones in the same transaction. A binding to a deleted host closes
only that inbound; ids that resolve to no host at all still fail closed
everywhere. The Postgres Host step now runs the deletion cases as well.
2026-10-24 20:07:55 +02:00
25f2002654 e8feb9d641 ci: run the Postgres suites that never ran and lint the shell (#6917)
Eight Postgres-only tests skipped everywhere because no workflow named
them. The durable-first job now runs the service and outbound ones, and a
scratch-database job runs the migration tests that drop their own tables.
Every Postgres step counts top-level passes, so a renamed test cannot
leave a step green.

A shell-lint job runs actionlint on the workflows and shellcheck on the
shipped scripts (the legacy installers at error severity), golangci-lint
is pinned, and both the codegen job and make verify fail when
docs/public/openapi.json drifts from the panel's spec; docs-ci also fails
on a stale API reference. npm audit moves to its own script outside make
verify, since it depends on the network.
2026-10-24 19:55:23 +02:00
Jay Hemnani 180cc37503 fix(xray): allow a private DoH server on the port Xray dials (#6918)
The DNS allow rule opened port 53 for a private server given as https://
or h2c://, but Xray dials remote DoH on the URL's port, 443 by default,
so the rule never matched and the private-IP block dropped the queries.

Remote DoH now takes its port from the URL and ignores an object entry's
port field, which Xray ignores as well; the +local forms skip routing and
keep their previous handling.
2026-10-24 19:40:00 +02:00
25f2002654 f105132866 perf(runtime): pool the local xray gRPC connection (#6916)
A client per runtime operation made a bulk delete pay a TCP and HTTP/2
handshake for every client. The local runtime now keeps one gRPC client
per API port and replaces it when a core restart moves the port; the
traffic poll keeps its own client, since StatsLastValues is unlocked.

A pooled channel keeps grpc-go's reconnect backoff after the core
restarts, so a call that fails with Unavailable drops the client and
dials again, as a client per call did.
2026-10-24 19:30:09 +02:00
MHSanaei de21c128fc fix(storybook): hold the Storybook build to the app's chunk-size ceiling
The panel build allows 1500 kB chunks, but the Storybook build kept Vite's
500 kB default, so every build warned about the bundled UI and the axe engine
from the accessibility addon.
2026-10-24 13:00:54 +02:00
MHSanaei a46bed383c fix(security): build the scanner image with the Go go.mod requires
go.mod moved to go 1.27.3 in 775b2b0b, but the OSS Scanner image still started
from golang:1.27.2 with GOTOOLCHAIN=local, so its build refused the module.
2026-10-24 12:45:23 +02:00
DIMFLIX 15fee2a035 feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale
menus whose buttons still acted, and long client or inbound lists were paged
through dozens of buttons. Each chat now has one live screen that is edited in
place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists
open through inline search, the add-client flow starts from a draft card, QR
codes arrive as one labelled sheet, and repeated notices collapse into one with
a counter. A client's /start and /usage show only that client's own links and
usage, groups are refused, and the settings page warns when the bot's inline
mode is off. Admin topics, the indexed Telegram lookup and every locale,
Slovak included, carry over.
2026-10-24 12:39:56 +02:00
Nethmina Yasas bc1b750e00 feat(clients): bind host rules to individual clients in subscriptions (#6620)
Host rules applied per inbound only, so every client of an inbound got the same
subscription endpoints (#6618). A client can now be bound to one or more host
rules, and its raw, JSON and Clash subscriptions render only those hosts, in
either subscription order; an unbound client is unchanged. A binding whose hosts
are deleted, disabled or excluded from the format fails closed: the client gets
no links there but stays in traffic accounting. Host edits keep row ids, so
bindings survive them; nil leaves a stored binding alone and an empty list
clears it.
2026-10-24 12:33:02 +02:00
MHSanaei d103c0c012 fix(amneziawg): keep the fallback egress port below the panel's reserved ports
When 64900 was taken, the egress asked the OS for any free port. Where the
ephemeral range reaches above 62789 (Windows), that could be the Xray API port
or a node-egress, TUIC or AmneziaWG relay port, which then failed to bind. The
fallback now picks a random free loopback port below 62789.
2026-10-24 12:23:24 +02:00
MHSanaei e66d18fe05 fix(sub): expand CIDR hosts in the hosts-first subscription order
The hosts-first order (#6895) rendered each host through its own endpoint
builder, which skipped the CIDR expansion from #6784, so a range host went out
as the bare network address. One helper, hostEndpointMaps, now builds a host's
entries for both orders.
2026-10-24 07:38:32 +02:00
VAIO73 052b11d2bc feat(i18n): add the Slovak (sk-SK) panel locale (#6889)
Adds a complete Slovak translation of every panel and bot string, registers
sk-SK in the language picker (a browser set to sk now lands on it instead of
English) and loads Ant Design's Slovak locale. The locale count in the READMEs,
docs and contributor guides moves from 13 to 14.
2026-10-24 07:35:28 +02:00
Matt Van Horn e0d2a0c58c fix(fallback): derive a linked fallback's PROXY protocol header from its child (#6880)
Turning on Proxy Protocol for a local fallback target behind a VLESS/Trojan TLS
master broke the fallback: the row was saved with xver 0, so the master sent no
PROXY header and the child, now expecting one, rejected every connection (#6835).

A fallback without an explicit dest already takes its address from the child at
build time; it now also sends header v1 when the child accepts PROXY protocol
(sockopt or the raw/ws/httpupgrade transport flag) and the row sets no xver of
its own, so either order of enabling it works, from the panel or the API. Editing
a child's listen, port or PROXY flag rebuilds the masters that derive from it,
since re-adding only the child left them stale. Fixes #6835.
2026-10-24 07:30:09 +02:00
Hamed 136c1c4d50 feat(hosts): add drag-and-drop and global subscription ordering (#6895)
Hosts already had one saved order on the Hosts page, but subscriptions honoured
it only inside each inbound, so a host of a later inbound could never come before
one of an earlier inbound (#6894). A new opt-in setting makes raw/base64,
Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds;
the default keeps today's inbound-first output unchanged. The Hosts table gains
drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook
the Routing Rules table now shares.
2026-10-24 07:27:47 +02:00
MHSanaei a69ae2b0ea test(tuic): retry until one port is free for both TCP and UDP
The route marker took a free TCP port and bound UDP on the same number, which
fails whenever that UDP port is in use or reserved (Windows excludes port ranges
per protocol), so the TUIC routing tests failed at random on such hosts.
2026-10-24 07:20:16 +02:00
Emma Hermione 0b90dc2bed feat(sub): map shadowsocks TCP HTTP headers to Mihomo's obfs plugin (#6838)
A shadowsocks inbound with a TCP HTTP header was dropped from Mihomo
subscriptions, although Mihomo's simple-obfs speaks the same wire format
(#6735). Such a node is now emitted with plugin: obfs, mode http and the request
Host header. Mihomo always requests GET /, and xray-core rejects a request whose
path is not one of the header's paths, so only headers that accept / map;
others stay dropped, as do non-shadowsocks protocols and the legacy Clash
endpoint.
2026-10-24 07:10:30 +02:00
Rouzbeh 2b759f3916 feat(sub): export TLS cipher suites as the cs query param in share links (#6825)
Hosts and TLS inbounds can set cipher suites, but they reached only the JSON and
Clash subscriptions; raw share links never carried them, so clients that read cs
from links (v2rayNG forks used with fp=unsafe) had to enter them by hand. Share
links now emit cs from the host or inbound TLS settings, the panel's own link
generator and previews do the same, and both link parsers (the Go one and the
panel's outbound import) read cs back into tlsSettings.cipherSuites.
2026-10-24 06:44:24 +02:00
Rashid Yusubov 3af0f81181 style(frontend): use the Telegram icon for the Telegram bot settings (#6848)
The sidebar and command palette showed a generic message icon for the Telegram
bot settings; the Telegram mark makes the entry recognisable at a glance. The
icon stays aria-hidden, since both entries carry a translated label.
2026-10-24 06:37:44 +02:00
YoungReckless4 35e09191de feat(amneziawg): support persistent keepalive ranges (#6881)
AmneziaWG clients accept a randomised PersistentKeepalive range such as 15-25,
which makes keepalive timing harder to fingerprint; the panel only stored a
single number. A client's keepalive may now be a range, validated the way
AmneziaWG parses it. The downloadable .conf and vpn:// profile carry the range,
Clash/Mihomo output gets its lower bound, and plain WireGuard stays scalar-only.
The range lives in an additive wg_keep_alive_range column, so the integer
column and an older panel binary keep working after a rollback.
2026-10-24 06:36:49 +02:00
Egor 207c705300 fix(tuic): preserve client identity for Xray user routing (#6875)
TUIC traffic reached Xray through a loopback SOCKS relay that took no login,
so Xray never learned which client a connection belonged to and user routing
rules never matched TUIC (#6874). The relay now carries one account per client
email, so per-user rules match on TCP and both UDP modes; admission stays with
the native TUIC server.

Adding, removing or renaming a TUIC client swaps only that inbound's relay
instead of restarting Xray: new sessions wait behind a bounded gate while live
streams and UDP associations continue, accounts are verified after the swap and
rolled back on failure, one failing relay no longer strands the others or blocks
unrelated live changes, and emails over SOCKS5's 255-byte limit are excluded
with a warning.
2026-10-24 06:20:39 +02:00
Hamed 07910bf6c0 fix(outbound): dial IPv6 literals correctly in TCP probes (#6850)
The TCP probe built its dial address as host:port with string formatting, so
an outbound whose server is an unbracketed IPv6 literal got an invalid target
and was reported unreachable although it carried traffic. The address is now
built with net.JoinHostPort, and an already-bracketed IPv6 literal is unwrapped
first so it is not bracketed twice.
2026-10-24 06:09:36 +02:00
clearlove13 49c0600f35 fix(iplimit): keep recording client IPs with fail2ban disabled (#6830)
With XUI_ENABLE_FAIL2BAN off, the IP job returned early, so the panel stopped
recording and showing client IPs altogether, although only enforcement needs
fail2ban. Only enforcement is now gated on it: collection-only runs go through
the merge, 30-minute window and compare-and-swap path, so the IP list stays
current. Tied timestamps sort by IP, so an unchanged client no longer rewrites
its row every scan and the ban pick among tied IPs is stable. Closes #6722.
2026-10-24 06:09:01 +02:00
Yuri Khachaturyan 0729fc6dbb fix(sub): send no-store on every subscription route (#6862)
The raw, JSON and Clash bodies carry live traffic, expiry and routing headers
but went out without Cache-Control, so any cache in front of the sub server
could serve stale data, such as old routing rules after an admin edit. The rule
lived in scattered per-branch calls that new branches missed. setNoCacheHeaders
now runs as middleware on the sub, JSON, Clash, Mihomo and legacy Clash route
groups, covering every current and future response of those per-subscriber
routes.
2026-10-24 06:08:02 +02:00
55575455 f43645656c feat(tls): add a dynamic DoH helper for echConfigList, with CDN guidance (#6869)
echConfigList already accepted Xray's dynamic form, target domain + DoH URL,
but nothing in the inbound or host forms explained or built it. A helper now
builds that value from presets or a custom domain and resolver, accepts only the
schemes Xray dials (https, h2c, udp; scheme case normalised), warns that a key
generated locally does not work behind a CDN, and asks before overwriting local
ECH keys. The helper's controls carry accessible labels, and the resolver list
holds only identified public resolvers.
2026-10-24 06:07:11 +02:00
Aleksey 41bafb3fd5 feat(sub): make the host "Mihomo X25519" switch drop ML-KEM (#6803)
The per-host Mihomo X25519 switch has been stored since managed hosts landed,
but no renderer read it, while every REALITY proxy was forced to
support-x25519mlkem768: true. On some networks DPI drops the second segment of
an ML-KEM ClientHello and blackholes the server for that client. A host with the
switch on now gets support-x25519mlkem768: false and no ML-KEM hint in its vless
link; switches saved while they had no effect are cleared once, so upgrading
does not silently change existing subscriptions.

A REALITY host's fingerprint now reaches the Clash and JSON outputs, and every
host endpoint gets its own deep copy of the stream, so one host's blank SNI,
fingerprint, Host header or path no longer inherits another host's value.
2026-10-24 06:01:48 +02:00
Tuyến Trần a403faee23 feat(tgbot): route admin notifications to a forum topic (#6879)
Admins who run the bot in a forum supergroup got every notification in the
General topic. An admin chat ID may now carry a topic, chatId:topicId, and the
bot's admin messages and reports go to that topic. Repeated chats collapse into
one entry and a second, different topic for the same chat is rejected, in the
panel and in the docs setup helper alike. The chat ID hint in every locale and
the English, Persian, Russian and Chinese guides describe the form.
2026-10-24 05:55:55 +02:00
Hamed 7d45456feb fix(tgbot): look up a Telegram account's clients through the indexed client table (#6887)
Every bot usage command and the hourly report resolved a Telegram ID by
expanding every inbound's settings.clients, re-parsing the matched inbounds and
looking up each client again by email. With thousands of clients this ran
thousands of times per report and showed up as high PostgreSQL CPU, and an
account whose lookup came back empty was retried once per client carrying it.

The lookup now uses the normalized clients table and its Telegram-ID index in
one query per account, and the hourly pass checks each account once. Bot output
and thresholds are unchanged; link callbacks now authorize against the current
client records instead of possibly stale settings JSON.
2026-10-24 05:55:19 +02:00
25f2002654 d2a0216e01 fix(web): install the share-link provider before the panel serves (#6914)
The panel started answering requests before the share-link generator was
registered, so an "all links", client sub-links or Telegram link request that
arrived during start-up got "sub link provider not registered". NewServer now
takes the provider and installs it before anything listens, and the provider
registry is guarded by a lock, since start-up writes it while requests read it.
2026-10-24 05:50:46 +02:00
Rouzbeh† ad470681ef fix(web): stop clean shutdowns logging false errors, and let the panel stop its children (#6884)
A systemd stop or restart logged "Server shutdown failed: use of closed
network connection | xray is not running" on every clean exit. httpServer.Shutdown
already closes the listener, so the second Close's net.ErrClosed is now ignored,
and StopXray's not-running case is a sentinel (ErrXrayNotRunning) that Stop
treats as done.

The shipped units now use KillMode=mixed, so SIGTERM reaches only the panel,
which stops Xray and the sidecars itself; systemd still SIGKILLs whatever is left
after the stop timeout.
2026-10-24 05:15:34 +02:00
Pejman Yousefi 40cea0c2fe feat(sub): generate random IPs from CIDR host addresses (#6784)
Operators who front a server with an IP range (a CDN or anycast prefix) had to
list each address as its own host. A host address may now be a CIDR, and every
subscription fetch expands it into up to randomCount (1–100, default 1) unique
random host IPs from the range, IPv4 and IPv6 alike, skipping the network and
broadcast addresses. The panel's own previews, QR codes and tunnel config
exports (WireGuard, AmneziaWG, TUIC, MTProto) expand it the same way, and the
externalProxy importer writes the model's default count so the re-sync still
recognises untouched imported hosts.
2026-10-24 05:13:44 +02:00
25f2002654 f6a233220a perf(auth): look up api tokens by their hash index (#6915)
Every bearer-authenticated request, including each node-sync call, loaded all
enabled api_tokens rows and compared each stored SHA-256 hash in constant time.
Tokens are stored hashed, so the database can compare hashes directly: a
timing leak there reveals only a stored hash, which cannot authenticate. The
lookup is now one indexed seek on idx_api_tokens_token, which AutoMigrate
creates from the model tag on new and existing databases; scope and expiry
are still checked afterwards.
2026-10-24 05:06:49 +02:00
25f2002654 9f0f2b3802 fix(service): run inbound deletes and depleted-client purges on the serial traffic writer (#6912)
Inbound and traffic writes are serialised on one goroutine because the traffic
poll locks inbounds then client_traffics while an admin mutation does the
reverse, which Postgres aborts as a deadlock (40P01). Deleting an inbound and
purging depleted clients still opened their own transactions, so they could
deadlock the poll, and a delete committing between a concurrent edit's read
and its Save let GORM's Save fall back to CREATE and resurrect the inbound.
Both now go through runSerializedTx like every other inbound write.
2026-10-24 05:06:03 +02:00
Hamed 5a135d2adb fix(inbounds): preserve formatting in the advanced JSON editor (#6857)
In the inbound form's Advanced → All editor, pressing Enter or adding any
whitespace while the JSON stayed valid made the panel replace the operator's
text with its own formatting, dropping the new line and moving the caret.

Edits that only change formatting are now left alone, while changes made
elsewhere in the form still reach the editor, a tag change patches only the
tag, and an edit the form rejects (a non-numeric port, a removed section) is
replaced by what the form will actually save, so the editor never shows JSON
the panel will not submit.
2026-10-24 04:54:19 +02:00
CHANGAO MA 0516a1f87c feat(api): add client lookup by subscription ID (#6908)
Integrations could only find a client by subscription ID through a substring
search over the paged client list followed by a second lookup by email.
GET /panel/api/clients/get/subId/:subId now returns the full client records
(settings, attached inbounds, external links, traffic, tunnel addresses) for an
exact subId match, as a list because older rows may share an ID. It sits behind
the same admin login or admin-token check as the email lookup.
2026-10-24 04:54:14 +02:00
ericanannn-blip 3051aebb28 fix(middleware): log rejected CSRF requests with the trusted client address (#6692)
A panel or API request rejected for a missing or wrong CSRF token got a bare
403 and left nothing in the log, so an operator chasing failed logins from a
stale tab or a script had no trace. CSRFMiddleware now logs a warning with the
method, path and client address. The address comes from getRemoteIp, which
honours the trusted-proxy list, instead of gin's ClientIP, which trusts any
peer's X-Forwarded-For.
2026-10-24 04:51:21 +02:00
Murphy Shannon 864cbd234a fix(sub): pass numeric XMUX counts to Clash reuse-settings (#6843)
Inbound XMUX counts such as maxConnections are stored as JSON numbers, but
buildXhttpClashOpts kept only string values, so the panel default
maxConnections: 3 never reached Clash/Mihomo clients. Numeric values are now
rendered as plain integers (FormatFloat, so 1000000 does not become 1e+06) and
zero values are still dropped.
2026-10-24 04:51:16 +02:00
Grigoriy 2f62dadd25 feat(notifications): add a failed-check threshold for node.down alerts (#6768)
One lost heartbeat on a lossy link flipped a node offline and sent node.down,
and the next probe sent node.up, so a flaky node flooded Telegram, email and
Discord with alert pairs.

A new nodeDownThreshold setting (default 1, today's behaviour) works like
outboundDownThreshold: node.down fires once the failed-check streak reaches it,
once per outage, and node.up only after a node.down was sent. The announcement
is persisted in nodes.down_notified, so a restart inside the threshold window
neither drops the outage's node.down nor re-announces nodes already reported
offline; nodes offline at upgrade are seeded as announced.
2026-10-24 04:26:13 +02:00
almirus c3037fc716 fix(i18n): name the Russian download throughput label as a noun (#6890)
pages.index.download labels the download-speed figure on the dashboard next to
"Загрузка" (upload). The imperative "Скачать" read as a button; the noun
"Скачивание" matches what the label shows. CI does not run on translation-only
changes; the file still parses as JSON.
2026-10-24 04:23:46 +02:00
Jay Hemnani 1dbb37432c fix(traffic): re-enable quota-disabled clients on inbound and reset-all resets (#6833)
Running out of quota switches a client off in client_traffics, in its client
record and in the inbound settings JSON. The inbound's periodic reset and the
Reset All Client Traffic button only set client_traffics.enable back, so the
client showed zero usage but stayed out of the running core every period.

Both paths now read the disabled, depleted emails inside the transaction that
zeroes the counters, on the traffic writer, and re-enable them through
BulkSetEnable after the commit, like every other reset path. Reading them inside
that transaction keeps a traffic tick from disabling a client between the read
and the reset.
2026-10-24 04:21:11 +02:00
Duxxie c3394fefed feat(sub): opt-in dns and sniffer from remote Clash rules (#6868)
Public Mihomo templates ship rules, rule-providers and proxy-groups together
with a dns block those rules depend on (fake-ip ranges, nameserver policy).
Pasting a template inline keeps its dns, but pointing the global routing rules
at its URL dropped it, so URL users had to give up auto-refresh to keep it.

A new switch, subClashRemoteDNS (off by default), also takes the remote dns and
sniffer maps. It is opt-in because a remote dns block decides where clients send
every lookup. A malformed dns or sniffer value is skipped, not the whole template.
The component-test teardown now outlasts AntD Form's 10ms error debounce, which
otherwise fired after jsdom was gone.
2026-10-24 04:21:04 +02:00
Farhan Zare 3acdfd9dea fix(hosts): carry an inbound update's externalProxy change onto its imported hosts (#6831)
Subscriptions build an inbound's links from its Host rows, and those rows are
imported from streamSettings.externalProxy only when the inbound is added. An
update that moved the external proxy returned 200, but every subscription kept
handing out the old address, port and security.

An update now re-syncs only the externalProxy entries it changed onto hosts that
are still exactly as imported. Unchanged entries are set aside first, so a host
the admin edited, disabled, deleted or shared is never re-imported next to the
admin's version, and a stream without the externalProxy key leaves hosts alone.
2026-10-24 04:20:50 +02:00
MHSanaei d50c419e28 fix(web): read the client address from the proxy end of forwarded headers
getRemoteIp took a trusted proxy's X-Real-IP first, otherwise the leftmost
X-Forwarded-For entry. A proxy that forwards a client-sent X-Real-IP
unchanged (Caddy, HAProxy) or appends to X-Forwarded-For leaves both values
to the client. X-Forwarded-For is now read from the right, skipping trusted
proxies. X-Real-IP is believed only when it agrees with that hop or stands
alone, and a disagreement falls back to the proxy's own address.

The documented Caddyfile and the docs reverse-proxy generator now overwrite
X-Real-IP with the address Caddy saw.
2026-10-24 01:39:23 +02:00
MHSanaei 1df8ab29f8 fix(database): create the first panel user with random credentials
The first user is now created when the server starts, with a random
username and password printed once to its log (docker logs, the journal),
instead of a fixed pair seeded by InitDB. CLI calls that initialize the
database are often piped away, as the Docker entrypoint's port lookup is, so
only the server's own log reliably reaches the operator.

`x-ui setting -show` keeps reporting hasDefaultCredential: true until a user
exists, so install.sh still sets its own credentials on a fresh install.
Existing databases are not changed. Verified on a built binary: fresh
setting -show, install.sh-style credential set, first start, login and
restart. The PostgreSQL lane was not run locally; the change adds no query.
2026-10-24 01:26:05 +02:00
MHSanaei 0faad4f4f3 fix(wireguard): check every line of generated client configs
The WireGuard and AmneziaWG .conf builders (subscription service, clients
page, inbound modal and subscription page) checked only a few of the values
they write. Each builder now assembles the file line by line and renders
nothing when a line holds a control character, so every value is covered
whatever stored it. A decoded vpn:// payload is refused when it carries a
wg-quick hook directive, which no panel builder writes, and the subscription
page hides a config block that rendered nothing.

The inbound form now validates a WireGuard/AmneziaWG client's allowedIPs on
create and on update, including the stored clients a protocol switch keeps,
as the client endpoints already did.
2026-10-24 01:10:09 +02:00
MHSanaei 57fd61790c chore(docs): bump docs dependencies and pnpm
Update the docs site to fumadocs 16.17.x, next 16.4.3, react 19.3.1, zod 4.6.7, oxfmt/oxlint and vitest 5.0.5, and bump pnpm to 12.15.0.

Add a lightningcss>=1.33.0 override because @tailwindcss/node 4.3.3 pins 1.32.0, which rejects fumadocs-ui's ::highlight(fd-search) selector.

Refresh minimumReleaseAgeExclude for the new package versions and set minimumReleaseAgeExcludePrune. The lockfile is regenerated accordingly.
2026-10-24 00:25:48 +02:00
Jay Hemnani aedd8fac1c fix(traffic): re-enable a quota-disabled inbound on its periodic reset (#6834)
An inbound with a total traffic cap is switched off once up + down
reaches it. Its periodic traffic reset then zeroes the counters but
never sets enable back, so after the first used-up period the inbound
stays off for good and the reset schedule does nothing for it.

The reset job now notes, before resetting, whether a local inbound was
off because of its own cap (and not expired), and switches it back on
through SetInboundEnable once the counters are zero. An inbound switched
off below its cap, or past its expiry, stays off.
2026-10-23 19:59:41 +02:00
Farhan Zare 47be06358e fix(clients): withdraw delete tombstones for clients any serialized write re-creates
Deleting a client tombstones its email for 90s, so a node snapshot taken
before the delete reached the node cannot resurrect it. Client create and
bulk create withdrew that tombstone, but a client re-created through
InboundService.AddInbound (SyncInbound / syncInboundClients) did not. On a
master, re-adding a node inbound with a just-deleted email therefore let
the next node merge filter the new client out of the snapshot, unlink it
from its inbound and mark it a sync orphan; ReapSyncOrphans then
hard-deleted it 15 minutes later and the node lost it.

syncInboundClients now records the emails it creates on the serialized
transaction, and runSerializedTx withdraws their tombstones only after the
commit, still on the writer, so no merge runs in between and a rolled-back
create leaves the tombstone standing. The per-call withdraws in Create and
bulkCreate are covered by this and removed.

Co-authored-by: FZ1010 <FZ1010@users.noreply.github.com>
2026-10-23 19:30:13 +02:00
25f2002654 f172bc3f10 fix(node-sync): do not latch node disable when master allows (#6870) (#6876)
* fix(node-sync): do not latch node disable when master allows (#6870)

nodeDisableIsStale trusted any node-reported enable=false whose limits matched
the master's, treating it as a genuine verdict and latching it on the master.
But matching limits no longer prove the verdict: a single stray false
(a depleted snapshot the master later re-enabled, a stale
disableInvalidClients hit, or a push that lagged a quota reset) leaves the
client stuck disabled on the node, and every master tick flips the client's
state again. This is the loop reported in #6870.

Make nodeDisableIsStale a single rule: stale iff the master's own deadline
and quota still permit the client. A genuine combined-quota or expiry
disable (masterLimitsAllowClient == false) still latches, preserving #4917.
Mirror the same rule in ClientTrafficEnableMergeExpr so the SQL UPDATE and
the Go in-memory replay cannot drift.

* fix(node-sync): keep node usage over quota latching a node disable

Dropping the matching-limits latch (#6870) let the master re-enable a
client the node had genuinely counted past its quota whenever the
master's own counters lagged, e.g. after a seeded-at-zero adoption,
reopening #4917. A node's enable=false now stays latched when the
node's own usage already spends the master's quota; otherwise the
master's limits decide, so a stray disable no longer loops.

Restores TestNodeQuotaDisable_SameExpiryStillLatches as it was on main.

---------

Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
2026-10-23 19:04:46 +02:00
Alireza Ahmadi e2184777a0 fix(frontend): make the panel usable with screen readers (#6907)
* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
2026-10-23 18:29:47 +02:00
MHSanaei 775b2b0b26 chore(deps): update Go and frontend dependencies
Refresh frontend libraries and tooling, and update Go to 1.27.3 with current module dependencies.
2026-10-22 22:01:46 +02:00
MHSanaei 61b129d5b3 feat(xray): update xray-core to v26.10.21 and adapt panel
Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three
binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The
v26.10.10 release was pulled upstream, so the old pins already 404.

XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS
encryption, with no flow, and an outbound only over Mux; it refuses the
whole config otherwise. The panel offers XDRIVE only for VLESS, refuses
to save an inbound without decryption, never emits a client flow on an
XDRIVE stream (config, JSON subscription, forms), and turns Mux on when
an outbound picks XDRIVE. An inbound or template outbound stored before
the upgrade that the core would refuse is left out of the generated
config with a warning, so it no longer stops every other inbound. The
service "Google Drive" is now "google-drive" (old name read as a
deprecated alias); saves, the schema and both form adapters store the
new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it
instead of stripping it on an inbound save.

Lua scripts (#7124, #7136): the core refuses a routing script beside
rules or domainStrategy, and a dns script beside the fallback/parallel
switches or a domain-scoped server. The panel always keeps its api
routing rule, so a template save now refuses any routing script and a
dns script in a refused combination, and a stored one is dropped from
the generated config so the core still starts. Noise masks gain a Lua
script that the loader compiles inside Build: the in-process build
(template save check, hot add) now resolves a relative script the way
the xray child does, beside the xray binary, instead of failing it. The
mask editor gets a Script field that replaces the noise list.

XDNS (#7140): a resolver takes https:// DoH addresses with worker,
tlsConfig and quicConfig. The mask editor registered only addrs, so a
save dropped the rest; it now edits all four.

Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source
IPs. The inbound mask editor offers it with its allow and block lists;
it is a no-op on a dialer and an older client core refuses the type, so
the share link and JSON subscription drop it like the Go link already
does.
2026-10-22 17:10:11 +02:00
libmur-dev a8d0700bf2 fix(auth): add node-admin token scope for panel updates (#6666)
* feat(auth): add node-admin API token scope

Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel.

* style(docs): format API scope table

* fix(auth): preserve mTLS node-sync semantics

Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections.

* chore(ci): rerun flaky checks

---------

Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
2026-10-17 23:45:26 +02:00
Anthony Ilinykh a1f2589eb5 fix(frontend): remove unused MSW browser worker (#6859)
The frontend uses MSW only through its Node test interceptor, but the unused browser worker requires synchronization after dependency updates and currently breaks make verify.

Remove the unused asset, worker-directory configuration, and freshness checks while preserving Node MSW tests and the separate PWA worker. Update tooling descriptions to match.

Fixes #6858
2026-10-17 22:09:48 +02:00
MHSanaei 608ef4e401 v3.10.2 2026-10-14 17:18:43 +02:00
MHSanaei 35b5e0989a fix(sponsor): fetch sponsor data from raw.githubusercontent.com
The server-side fetch used sponsors.sanaei.dev, a hostname unique to
3x-ui. Without ECH it travels in plaintext SNI, so an on-path observer
could tag a server as running the panel from that one connection.
raw.githubusercontent.com is shared by countless tools and reveals
nothing. The path stays inside TLS. The data source is unchanged: the
same MHSanaei/sponsors repo.

Refs #6824
2026-10-14 17:10:02 +02:00
MHSanaei b177b98ce8 feat(mtproto): expose mtg-multi's per-IP pending-handshake limit
mtg-multi v1.16.0 adds [defense.pending-handshakes] max-per-ip, which
closes new connections from an IP already holding that many
unauthenticated handshakes, so one address cannot pin the workers other
clients need. The MTProto inbound form now carries it as
pendingHandshakesMaxPerIp; the key is written only when set above 0, so
existing inbounds render a byte-identical config and are not restarted.
A change to it moves the structural fingerprint and restarts the sidecar.

dry-run is left out on purpose: it only feeds the pending_handshake_limit
Prometheus metric, which the panel does not surface. The hint warns that
clients behind carrier NAT share the limit.
2026-10-14 16:46:20 +02:00
Jay Hemnani 6bd1b8676a fix(link): read the XHTTP fields a vmess link carries inline (#6808)
A vmess link has no extra blob: the sub emitter writes the XHTTP extra
fields (session/seq/uplink placement, padding obfs, xmux, ...) at the
top level of the vmess JSON. The Go importer only read xPaddingBytes,
scMaxEachPostBytes and scMinPostsIntervalMs from there, so a vmess
XHTTP inbound with, for example, sessionIDPlacement=header or
xPaddingObfsMode came back from an outbound subscription or a client
external link as an outbound that cannot connect.

Read the same keys the TS importer already reads from vmess JSON,
including the pre-rename sessionPlacement/sessionKey aliases.
2026-10-14 15:34:35 +02:00
Jay Hemnani ff3f42ee2a fix(link): read fm and the mKCP fields from vmess links (#6809)
#6480 taught the vless/trojan importers to rebuild the mKCP header and
seed and left the vmess importers ignoring fm. The vmess emitter writes
the finalmask as fm, the mKCP header as type and the seed as path, plus
mtu/tti. Both importers dropped all of them, so a vmess mKCP inbound
with a seed, a header or any finalmask UDP mask (salamander, ...) came
back from an outbound subscription, a client external link or the
outbound form's link import as an outbound that cannot connect.

Feed those fields through the same finalmask and kcp helpers the
url-param protocols use, in the Go and TS importers.
2026-10-14 15:34:06 +02:00
MHSanaei 760f428c41 fix(xray): capture whole core crash reports instead of the first pipe chunk
LogWriter inspected each pipe chunk on its own. The Go runtime writes
"panic: " with a separate write before the message and traceback, so the
crash report was written from that first chunk alone and the panel left
7-byte core_crash_*.log files holding only "panic: ". Any ordinary log
line mentioning panic/exception/stack trace was also treated as a crash
and dropped from the panel log.

The writer now splits output into lines, starts a crash only on a line
beginning with the Go runtime's "panic: " / "fatal error: " header, keeps
everything after it, and writes one report from Flush, which
waitForCommand calls after cmd.Wait has delivered all output. The header
line becomes LastLine so GetResult shows the cause.

The report filename format "_000000000" is not a Go fractional-seconds
layout and always produced literal zeros, so reports from the same second
overwrote each other; os.CreateTemp now gives every report a unique name.
2026-10-14 15:31:09 +02:00
MHSanaei c45ffcdafa chore(windows): update OpenSSL installer
Replace the OpenSSL Light 3.6.0 executable with the 4.0.3 MSI installer.
2026-10-14 14:49:28 +02:00
MHSanaei cb4f2ec92f fix(sub): export Hysteria2 Gecko obfs in Clash/Mihomo subscriptions
A salamander finalmask carrying a packetSize range is Gecko obfs. The
hysteria2:// link generators already export it, but buildHysteriaProxy
copied only the password, so Clash/Mihomo received plain salamander and
could never complete the handshake against a Gecko server.

Emit mihomo's gecko form instead: obfs: gecko plus integer
obfs-min-packet-size / obfs-max-packet-size, validated by the same
parseHysteriaPacketSize bounds the URI export uses. A password-only mask
keeps the plain salamander output.

Closes #6822
2026-10-14 14:42:00 +02:00
MHSanaei db8b01b8ed feat(release): ship the Windows build as 3x-ui.exe with version info and icon
Kaspersky (Trojan.Win64.Agent.sb / .smhqgz) and Defender flag the
Windows build heuristically. A stripped, unsigned Go binary with no
VERSIONINFO, icon or manifest scores high on those heuristics, so the
release job now generates a resource .syso with go-winres from
winres/winres.json, stamped with internal/config/version, before
go build. The .syso is a build artifact and is gitignored.

The Windows zip now carries 3x-ui.exe instead of x-ui.exe, matching the
project name, and the executable embeds the 3X logo as its icon. The
icon is a square 256px crop of media/3x-ui-dark.png, since the source
logo is 400x200 and Windows icons must be square.
2026-10-14 14:16:49 +02:00
MHSanaei a66b360c09 fix(xray): apply shadowsocks client changes in place instead of rebuilding
The per-user hot path only covered vless, vmess, trojan, hysteria and
masque, so every client change on a multi-user shadowsocks inbound - a
node push for an auto-disable, renew, reset or add, a local Edit Inbound
save, or a config-diff hot apply - fell through to DelInbound +
AddInbound, closing the listener and dropping every live connection on
the port.

Shadowsocks joins userDiffableProtocols. The emitted user ops also carry
the inbound's method as "cipher": 2022 clients have no method of their
own, and without it AddUser cannot build the account, which would leave
2022 inbounds rebuilding anyway. Changes outside settings.clients
(method, 2022 server key, network) still differ in the rest of the
settings and still rebuild.

Verified against the pinned core: TestXrayAPI_E2E_Users and the
shadowsocks e2e tests pass with XRAY_E2E_BINARY set.

Closes #6820
2026-10-14 14:16:48 +02:00
MHSanaei f8b72de995 fix(node-sync): write only changed client rows in the node traffic merge
Every 5s node merge rewrote one client_traffics row and one
node_client_traffics baseline per (inbound, client) pair the node
reported, even when nothing had moved: a client is reported once per
inbound it is on, and idle clients every tick. On top of that each
inbound's SyncInbound wrote its own per-inbound values onto the shared
clients record, so a client on a flow and a non-flow inbound (or on a
WireGuard tunnel plus any other inbound) had its record flipped back
and forth twice per merge. With 7 nodes and ~20k client links that is
tens of thousands of statements per tick, all inside transactions on
the single serialized writer, holding row locks for seconds. A renew
of a client on 38 inbounds takes one writer turn per inbound, queued
behind those merges each time, and outlived the 30s HTTP write timeout
even though it committed; last_sub_fetch updates waited on the same
locks.

The merge now computes the row its UPDATE would leave, mirroring the
SQL merge expressions, and skips the write when nothing changes; the
baseline upsert is skipped when the node counter has not moved. The
record's per-inbound fields get one owner each, so repeated syncs
agree: flow is settled to the effective flow (lowest-id link carrying
one) for clients whose links changed, and WireGuard/AmneziaWG peer
fields come from the client's lowest-id tunnel. A repeated unchanged
snapshot now writes no client row; one active client writes two.

Verified beyond make verify: the CI Postgres lane, the merge and flow
tests run against PostgreSQL 16, and make node-e2e.

Closes #6815
2026-10-14 00:37:00 +02:00
MHSanaei 6f1dd0d757 feat(sponsors): drop the sidebar slot and simplify the Sponsors page
The sidebar no longer carries a sponsor card. The backend stops serving
the "sidebar" slot, so a sponsors.json entry that still lists it keeps
only its other slots and one listing nothing else is dropped. Its
rotation, three-sponsor cap and collapsed-rail logo rendering go with it.

The Sponsors page now lists only real sponsors: the "Your brand here"
card shows only as the empty state instead of sitting in the grid and
repeating the header's Become a sponsor button, and the Available
placements section and its slot-occupancy helper are removed. Cards are
larger, two per row on wide screens. Unused i18n keys are dropped from
every locale.
2026-10-13 23:32:56 +02:00
MHSanaei 319e693276 fix(sponsor): keep long sponsor copy inside every slot
The Sponsors page card sized its body to max-content because the title
never wrapped, so a long title spilled out of the card onto the next one.
The compact card (sidebar, login) put logo, copy and close button in one
row, leaving the copy ~110px of the 220px sidebar and clamping it to two
lines; the dashboard banner cut its title to a single line.

Titles and descriptions now wrap, breaking unbroken strings too. The
compact card stacks logo and tag above copy that spans the full card
width, keeping a bounded height in the sidebar with a 2-line title and
4-line description clamp and the full copy in the link tooltip.
2026-10-13 22:42:59 +02:00
Matt Van Horn 61885c9126 fix(sub): isolate Incy subscription routing from Happ headers (#6810)
* sub: isolate Incy subscription routing from Happ headers

The resolved Incy line is written to the body only when IsIncyClient
matches the subscription User-Agent, Incy routing is enabled, and the
resolved text is non-blank. ApplyCommonHeaders computes that same Incy
check once and, for a matching client, skips the Happ routing-header
block, leaving Routing-Enable, the Happ Routing value, and the JSON
routing header unset. ApplyIncyHeaders is called with that incyClient
flag. The new test requests the subscription as INCY/1.0.0/ios and as
Happ/1.2.0 and expects the Incy response to end with the incy:// line
and to have empty Routing headers, and the Happ response to keep the
happ:// Routing header with Routing-Enable true and no incy:// line in
the body.

Fixes #6062

* fix(sub): keep the shared JSON routing profile for INCY clients

The INCY isolation wrapped the whole Routing block, JSON fallback
included, in !incyClient. INCY therefore lost the subJsonRoutingRules
profile that #6402 sends as the Routing header so Happ and INCY fetch
the geo files the baked rules reference, on the raw, JSON and Clash
endpoints alike, while every other client kept it, incy:// deeplinks
included.

INCY accepts a routing link of any scheme in that header
(docs.incy.cc/en/routing), so only the Happ tab is Happ-only: an INCY
request now reads it as blank and falls back to the shared profile,
as the setting text promises. Where the raw body carries INCY's own
profile the header is dropped, because INCY ranks a Routing header
above a base64 body line (docs.incy.cc/en/app-management) and the
shared profile would override it, which is #6062 again.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-13 16:01:32 +02:00
872 changed files with 65032 additions and 10940 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
# LF in every checkout, Windows included: format-check, the msw worker check and
# tests that parse repo files compare bytes, so a CRLF working copy fails them.
# LF in every checkout, Windows included: format-check and tests that parse repo
# files compare bytes, so a CRLF working copy fails them.
* text=auto eol=lf
+2 -2
View File
@@ -89,7 +89,7 @@ question it already answers.
`cd docs && pnpm gen:api`, because `docs-ci.yml` fires only on `docs/**`.
Step 4 is the one that reaches production wrong.
- **i18n.** A new English key goes in EVERY locale JSON in
`internal/web/translation/` (13 files) AND must be referenced from
`internal/web/translation/` (14 files) AND must be referenced from
`frontend/src` or Go in the SAME change.
`frontend/src/test/i18n-dead-keys.test.ts` fails on a missing locale file and
on an orphan key alike.
@@ -152,7 +152,7 @@ It is paths-filtered, so a docs-only or workflow-only change produces no run.
| `golangci` | `golangci-lint` |
| `fuzz-smoke` | 30s each on `FuzzParseLink` and `FuzzDecodeCertPin` |
| `codegen` | `npm run gen` then `git diff --exit-code` on the generated files |
| `frontend` | MSW worker drift, lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
| `frontend` | lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
**What CI does NOT prove.** These test families `t.Skip` unless an environment
variable is set, and CI sets only the PostgreSQL ones above:
+150 -10
View File
@@ -9,6 +9,8 @@ on:
- "frontend/**"
- ".nvmrc"
- "Makefile"
- "install.sh"
- "deploy/test/installer-panel-port.sh"
- ".github/workflows/ci.yml"
push:
branches:
@@ -20,6 +22,8 @@ on:
- "frontend/**"
- ".nvmrc"
- "Makefile"
- "install.sh"
- "deploy/test/installer-panel-port.sh"
- ".github/workflows/ci.yml"
permissions:
@@ -71,24 +75,133 @@ jobs:
- name: PostgreSQL durable-first tests
run: |
set -o pipefail
go test ./internal/web/service -run 'PostgresCommitFailure' -count=1 -v | tee /tmp/postgres-durable-first.log
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$|^TestVlessManagedFallbackConflictPostgres$' -count=1 -v | tee /tmp/postgres-durable-first.log
# Count passes rather than assert no SKIP: a renamed or deleted test
# prints "no tests to run" and exits 0, leaving the step green for nothing.
passed=$(grep -c -- '--- PASS' /tmp/postgres-durable-first.log || true)
passed=$(grep -c -- '^--- PASS' /tmp/postgres-durable-first.log || true)
if [ "$passed" -lt 5 ]; then
echo "expected at least 5 passing durable-first tests, got $passed" >&2
exit 1
fi
- name: PostgreSQL batched stats identities
run: |
set -o pipefail
go test ./internal/web/service -run '^TestClientStatsIdentitiesPostgres$' -count=1 -v | tee /tmp/postgres-stats-identities.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-stats-identities.log || true)
if [ "$passed" -ne 1 ]; then
echo "expected 1 passing PostgreSQL stats identities test, got $passed" >&2
exit 1
fi
- name: PostgreSQL client IP sync regressions
run: |
set -o pipefail
go test -p=1 ./internal/web/service ./internal/web/job -run '^TestClientIpSyncPostgres(Scanner)?$' -count=1 -v | tee /tmp/postgres-client-ip-sync.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-client-ip-sync.log || true)
if [ "$passed" -ne 2 ]; then
echo "expected 2 passing PostgreSQL client IP sync tests, got $passed" >&2
exit 1
fi
- name: PostgreSQL global-traffic heartbeat regressions
run: |
set -o pipefail
go test ./internal/web/service -run '^TestGlobalTrafficHeartbeatPostgres$' -count=1 -v | tee /tmp/postgres-global-traffic-heartbeat.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-global-traffic-heartbeat.log || true)
if [ "$passed" -ne 1 ]; then
echo "expected 1 passing global-traffic heartbeat test, got $passed" >&2
exit 1
fi
- name: PostgreSQL deferred-commit test
run: |
set -o pipefail
go test ./internal/web/service/outbound -run '^TestAddTrafficReturnsDeferredCommitFailure$' -count=1 -v | tee /tmp/postgres-outbound.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-outbound.log || true)
if [ "$passed" -lt 1 ]; then
echo "expected at least 1 passing durable-first test, got $passed" >&2
echo "expected at least 1 passing deferred-commit test, got $passed" >&2
exit 1
fi
- name: PostgreSQL schema and migration tests
run: |
set -o pipefail
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres|TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres|TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres|TestClientKeepAlivePostgresMigrationPreservesLegacyScalars|TestPostgresModelSettled_TracksSchemaPresence)$' -count=1 -v | tee /tmp/postgres-schema.log
# All must pass. Counting, not SKIP-matching: renaming a test would
# otherwise leave this step green while testing nothing.
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
if [ "$passed" -lt 6 ]; then
echo "expected at least 6 passing PostgreSQL schema tests, got $passed" >&2
exit 1
fi
- name: PostgreSQL subscription Host tests
run: |
set -o pipefail
go test ./internal/sub -run '^TestSubHosts_Postgres$' -count=1 -v | tee /tmp/postgres-hosts.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-hosts.log || true)
if [ "$passed" -ne 1 ]; then
echo "expected passing PostgreSQL Host coverage, got $passed" >&2
exit 1
fi
- name: PostgreSQL Telegram lookup and legacy migration tests
run: |
set -o pipefail
go test ./internal/web/service -run '^(TestGetClientTrafficTgBot_IndexedPostgres|TestGetClientTrafficTgBot_SettingsSerializationStyles)$' -count=1 -v | tee /tmp/postgres-tgbot.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-tgbot.log || true)
if [ "$passed" -ne 2 ]; then
echo "expected 2 passing PostgreSQL Telegram lookup tests, got $passed" >&2
exit 1
fi
# The migration tests write and drop their own tables, so they need a
# scratch database instead of the durable one the panel tests use.
postgres-migration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: xui_scratch
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d xui_scratch"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
XUI_TEST_PG_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=xui_scratch sslmode=disable"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: PostgreSQL migration tests
run: |
set -o pipefail
go test ./internal/database -run '^(TestMigrateData_CompositeKeyTableLargerThanBatch|TestMigrateData_PreservesFalseDefaultedColumns|TestMigrateData_FailedCopyLeavesDestinationUntouched|TestClientHwidSchemaPostgres)$' -count=1 -v | tee /tmp/postgres-migration.log
# Count passes rather than assert no SKIP: a renamed or deleted test
# prints "no tests to run" and exits 0, leaving the step green for nothing.
passed=$(grep -c -- '^--- PASS' /tmp/postgres-migration.log || true)
if [ "$passed" -lt 4 ]; then
echo "expected at least 4 passing PostgreSQL schema tests, got $passed" >&2
echo "expected at least 4 passing PostgreSQL migration tests, got $passed" >&2
exit 1
fi
- name: SQLite import host settings
run: |
set -o pipefail
go test ./internal/web/service -run '^TestImportSQLiteIntoPostgresHostSettings$' -count=1 -v | tee /tmp/postgres-import-host-settings.log
passed=$(grep -c -- '^--- PASS: TestImportSQLiteIntoPostgresHostSettings ' /tmp/postgres-import-host-settings.log || true)
if [ "$passed" -ne 1 ]; then
echo "expected the SQLite import integration test to pass, got $passed" >&2
exit 1
fi
@@ -108,6 +221,8 @@ jobs:
working-directory: frontend
- name: Fail if generated files are stale (run 'npm run gen' and commit)
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
- name: Fail if the docs spec is not the panel's
run: cmp frontend/public/openapi.json docs/public/openapi.json
govulncheck:
runs-on: ubuntu-latest
@@ -187,7 +302,35 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
# Pinned: "latest" let the lint result drift between runs, so the same
# commit could pass today and fail tomorrow on a new upstream release.
version: v2.15.0
# Lint the shell scripts and the workflows themselves. The three legacy
# scripts carry a warning backlog (unquoted expansions, masked return codes)
# and are gated on errors only; the scripts that are clean today are gated on
# everything.
shell-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: actionlint
# The shellcheck integration is off: it reports the stylistic rules
# (SC2086, SC2046) that main's own workflows trip. The shell scripts
# the panel ships are linted by the shellcheck step below.
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color -shellcheck=
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: shellcheck
run: |
shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh deploy/test/installer-panel-port.sh
shellcheck --severity=error install.sh update.sh x-ui.sh
- name: Installer panel port regression tests
run: bash deploy/test/installer-panel-port.sh
frontend:
runs-on: ubuntu-latest
@@ -201,9 +344,6 @@ jobs:
- name: Install
run: npm ci
working-directory: frontend
- name: Verify generated MSW worker is current
run: git diff --exit-code -- public/mockServiceWorker.js package-lock.json
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
@@ -226,5 +366,5 @@ jobs:
run: npm run build-storybook
working-directory: frontend
- name: Audit
run: npm audit --omit=dev --audit-level=high
run: npm run audit
working-directory: frontend
+17
View File
@@ -5,10 +5,14 @@ on:
branches: [main]
paths:
- 'docs/**'
- 'frontend/public/openapi.json'
- 'frontend/src/pages/api-docs/endpoints.ts'
- '.github/workflows/docs-ci.yml'
pull_request:
paths:
- 'docs/**'
- 'frontend/public/openapi.json'
- 'frontend/src/pages/api-docs/endpoints.ts'
- '.github/workflows/docs-ci.yml'
permissions:
@@ -48,5 +52,18 @@ jobs:
- name: Test
run: pnpm test
# The API reference is generated from the panel's own spec. The copy into
# docs/public and the generated pages both have to be in step with it.
- name: The docs spec must be the panel's
run: cmp frontend/public/openapi.json docs/public/openapi.json
working-directory: .
- name: Regenerate the API reference
run: pnpm gen:api
- name: Fail if the generated API reference is stale
run: git diff --exit-code -- docs/content/docs/en/reference/api
working-directory: .
- name: Build
run: pnpm build
+8 -3
View File
@@ -115,7 +115,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -261,6 +261,11 @@ jobs:
go version
gcc --version
# Version info + manifest resource: a bare stripped Go exe scores high on AV heuristics.
PANEL_VERSION=$(tr -d '[:space:]' < internal/config/version)
go run github.com/tc-hib/go-winres@v0.3.3 make --arch amd64 \
--product-version "$PANEL_VERSION" --file-version "$PANEL_VERSION"
# Stamp the commit into per-commit (dev channel) builds only.
LDFLAGS="-w -s"
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
@@ -273,12 +278,12 @@ jobs:
run: |
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
mkdir x-ui
Copy-Item xui-release.exe x-ui\x-ui.exe
Copy-Item xui-release.exe x-ui\3x-ui.exe
mkdir x-ui\bin
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
+1
View File
@@ -32,6 +32,7 @@ Thumbs.db
# Ignore Go build files
*.exe
*.syso
x-ui.db
x-ui.db-shm
x-ui.db-wal
+1 -1
View File
@@ -4,7 +4,7 @@
FROM node:26-trixie-slim AS node
FROM golang:1.27.2-trixie
FROM golang:1.27.3-trixie
ENV DEBIAN_FRONTEND=noninteractive \
CGO_ENABLED=1 \
+2 -2
View File
@@ -69,8 +69,8 @@ responses for limited tokens, or node sync payloads.
- Frontend: `cd /src/frontend && npx vitest run --project unit --project components`.
The Storybook browser project needs Chromium and is not provisioned.
- Running a live panel: `XUI_DB_FOLDER=/tmp/xui XUI_LOG_FOLDER=/tmp/xui
XUI_BIN_FOLDER=/src/build/bin x-ui` (creates `admin`/`admin` on first start;
`x-ui setting -h` changes port, base path and credentials).
XUI_BIN_FOLDER=/src/build/bin x-ui` (logs a generated username and password on
first start; `x-ui setting -h` changes port, base path and credentials).
- Baseline logs from the image build: `/src/.oss-scanner-go-test.log` and
`/src/.oss-scanner-vitest.log`. A built image does not prove the tests passed.
- PostgreSQL is not installed: tests gated on `XUI_TEST_PG_DSN` skip, so a
+6 -5
View File
@@ -63,7 +63,7 @@ file locations when it can answer in one hop.
CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
`runtime/` (master/sub-node over mTLS), `websocket/`.
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
- `locale/` + `translation/` — i18n, 14 embedded locale JSON files.
- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
@@ -102,12 +102,13 @@ file locations when it can answer in one hop.
never hand-write them. A new struct must be added to openapigen's `StructAllow`
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
- A new or renamed endpoint has a FOURTH step nothing checks: copy
- A new or renamed endpoint has a FOURTH step: copy
`frontend/public/openapi.json` → `docs/public/openapi.json`, then
`cd docs && pnpm gen:api` to refresh the MDX under
`docs/content/docs/en/reference/api/`. `docs-ci.yml` fires only on `docs/**`.
`docs/content/docs/en/reference/api/`. The `codegen` job and `make verify`
fail on a stale copy, and `docs-ci.yml` fails on stale MDX.
- A new English i18n key goes in EVERY locale JSON in `internal/web/translation/`
(13 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
(14 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
`frontend/src/test/i18n-dead-keys.test.ts` fails both ways. It is a frontend
test, so run `npm test`, not just `make test-go`. At runtime the frontend falls
back to en-US; Go (`internal/web/locale/`) returns "" for an unknown key.
@@ -189,7 +190,7 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
`make` Go target already depends on it, which is why `make test-go` beats
`go test ./...`. Run `make help` for all targets. The local gate:
make verify # gen-check + lint + typecheck + test + build + build-storybook
make verify # gen-check + lint + typecheck + test + build + build-storybook + docs-openapi-check
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure),
+2 -2
View File
@@ -84,7 +84,7 @@ Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on
go run .
```
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
Open [http://localhost:2053](http://localhost:2053) and log in with the username and password the first start prints to the terminal (`No panel user existed, so one was generated…`). Lost them? Set new ones with `go run . setting -username <user> -password <pass>`.
### Inside VS Code
@@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
- **Function components + hooks** everywhere. No class components.
- **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `<!-- ... -->` is fine for template structure.
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` rather than inline `z.string()` in rules.
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
+43 -29
View File
@@ -1,29 +1,48 @@
#!/bin/sh
set -e
# $1 is Docker's TARGETARCH and $2 its TARGETVARIANT (v6/v7 on arm). The
# platform names match the release archives and the mtg-multi packages.
case $1 in
amd64)
ARCH="64"
FNAME="amd64"
amd64 | "")
PLATFORM="amd64"
;;
i386)
ARCH="32"
FNAME="i386"
arm64 | armv8 | aarch64)
PLATFORM="arm64"
;;
armv8 | arm64 | aarch64)
ARCH="arm64-v8a"
FNAME="arm64"
;;
armv7 | arm | arm32)
ARCH="arm32-v7a"
FNAME="arm32"
arm | arm32 | armv7)
PLATFORM="armv7"
if [ "$2" = "v6" ]; then
PLATFORM="armv6"
fi
;;
armv6)
ARCH="arm32-v6"
FNAME="armv6"
PLATFORM="armv6"
;;
386 | i386)
PLATFORM="386"
;;
*)
ARCH="64"
FNAME="amd64"
echo "DockerInit: unsupported architecture '$1'" >&2
exit 1
;;
esac
case $PLATFORM in
amd64) XRAY_ZIP="64" ;;
arm64) XRAY_ZIP="arm64-v8a" ;;
armv7) XRAY_ZIP="arm32-v7a" ;;
armv6) XRAY_ZIP="arm32-v6" ;;
386) XRAY_ZIP="32" ;;
esac
# The panel looks binaries up by GOARCH; on arm Xray's is "arm32"
# (internal/xray/process.go) and mtg's plain "arm", as install.sh renames them.
case $PLATFORM in
armv7 | armv6)
XRAY_BIN="xray-linux-arm32"
MTG_BIN="mtg-linux-arm"
;;
*)
XRAY_BIN="xray-linux-${PLATFORM}"
MTG_BIN="mtg-linux-${PLATFORM}"
;;
esac
MTG_MULTI_VER=$(curl -sfL "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
@@ -33,23 +52,18 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-${XRAY_ZIP}.zip"
unzip "Xray-linux-${XRAY_ZIP}.zip"
rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat
mv xray "${XRAY_BIN}"
# mtg-multi (MTProto sidecar) ships prebuilt release binaries for every target
# we package, so download and unpack the matching one instead of compiling.
case $FNAME in
i386) MTGARCH="386" ;;
arm32) MTGARCH="armv7" ;;
*) MTGARCH="$FNAME" ;;
esac
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${MTGARCH}"
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${PLATFORM}"
curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
tar -xzf "${MTG_PKG}.tar.gz"
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${FNAME}"
mv "${MTG_PKG}/mtg-multi" "${MTG_BIN}"
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
chmod +x "mtg-linux-${FNAME}"
chmod +x "${MTG_BIN}"
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
+2 -1
View File
@@ -15,6 +15,7 @@ RUN npm run build
FROM golang:1.27-alpine AS builder
WORKDIR /app
ARG TARGETARCH
ARG TARGETVARIANT
RUN apk --no-cache --update add \
build-base \
@@ -28,7 +29,7 @@ COPY --from=frontend /src/internal/web/dist ./internal/web/dist
ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
RUN go build -ldflags "-w -s" -o build/x-ui main.go
RUN ./DockerInit.sh "$TARGETARCH"
RUN ./DockerInit.sh "$TARGETARCH" "$TARGETVARIANT"
# ========================================================
# Stage: Final Image of 3x-ui
+13 -6
View File
@@ -34,6 +34,10 @@ lint-go: dist-stub ## golangci-lint on Go sources
lint-fe: ## oxlint on frontend sources
cd $(FRONTEND) && npm run lint
.PHONY: audit-fe
audit-fe: ## Audit the frontend's production dependencies (needs the network)
cd $(FRONTEND) && npm run audit
.PHONY: lint
lint: lint-go lint-fe ## All linters
@@ -45,10 +49,6 @@ format-check: ## oxfmt in check mode on frontend sources
typecheck: ## tsc --noEmit
cd $(FRONTEND) && npm run typecheck
.PHONY: msw-worker-check
msw-worker-check: ## Verify the tracked worker matches the installed MSW runtime
cmp $(FRONTEND)/public/mockServiceWorker.js $(FRONTEND)/node_modules/msw/lib/mockServiceWorker.js
.PHONY: test-go
test-go: dist-stub ## Go tests (shuffle, no cache)
go test -shuffle=on -count=1 $(GO_PKGS)
@@ -76,6 +76,10 @@ test: test-go test-fe ## All tests
vulncheck: dist-stub ## govulncheck
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
.PHONY: docs-openapi-check
docs-openapi-check: ## Fail if the docs OpenAPI spec is not the panel's
cmp frontend/public/openapi.json docs/public/openapi.json
.PHONY: build-fe
build-fe: ## Build the Vite bundles into internal/web/dist
cd $(FRONTEND) && npm run build
@@ -89,7 +93,10 @@ build-storybook: ## Build the static Storybook (compile-checks all stories)
cd $(FRONTEND) && npm run build-storybook
# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter,
# typecheck, both test suites, a full build, and the Storybook compile-check.
# typecheck, both test suites, a full build, the Storybook compile-check and the
# docs spec check. The dependency audit is a separate target: it needs the
# network and a live advisory database, so an unchanged commit could pass today
# and fail tomorrow.
.PHONY: verify
verify: gen-check lint format-check typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI)
verify: gen-check lint format-check typecheck test build build-storybook docs-openapi-check ## Full local gate (mirrors CI)
@echo "verify: OK"
+3 -3
View File
@@ -41,7 +41,7 @@
- **واجهة RESTful API** مع رموز وصول محدودة النطاق وقابلة لانتهاء الصلاحية، ومرجع API داخل اللوحة.
- **لوحة قابلة للتثبيت (PWA)** — ثبّت 3X-UI على سطح المكتب أو شاشة هاتفك الرئيسية.
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
- **13 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
- **14 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
- **تكامل مع Fail2ban** لفرض حدود IP لكل عميل.
## لقطات الشاشة
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
## اللغات المدعومة
تتوفر واجهة اللوحة بـ 13 لغة:
تتوفر واجهة اللوحة بـ 14 لغة:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
## المساهمة
+3 -3
View File
@@ -41,7 +41,7 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
- **API RESTful** con tokens de alcance limitado y caducidad opcional, y una referencia de la API dentro del panel.
- **Panel instalable (PWA)** — ancla 3X-UI al escritorio o a la pantalla de inicio del móvil.
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
- **13 idiomas de interfaz** con temas oscuro y claro.
- **14 idiomas de interfaz** con temas oscuro y claro.
- **Integración con Fail2ban** para aplicar límites de IP por cliente.
## Capturas de pantalla
@@ -177,9 +177,9 @@ La lista completa está en la [referencia de variables de entorno](https://docs.
## Idiomas Compatibles
La interfaz del panel está disponible en 13 idiomas:
La interfaz del panel está disponible en 14 idiomas:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
## Contribuir
+3 -3
View File
@@ -41,7 +41,7 @@
- **‏RESTful API** با توکن‌های محدودشده (scoped) و دارای انقضای اختیاری، به‌همراه مرجع API درون‌پنل.
- **پنل قابل نصب (PWA)** — 3X-UI را به دسکتاپ یا صفحه‌ی اصلی گوشی خود سنجاق کنید.
- **ذخیره‌سازی منعطف** — SQLite (پیش‌فرض) یا PostgreSQL.
- **‏۱۳ زبان رابط کاربری** با تم‌های تیره و روشن.
- **‏۱۴ زبان رابط کاربری** با تم‌های تیره و روشن.
- **یکپارچگی با Fail2ban** برای اعمال محدودیت IP به‌ازای هر کلاینت.
## اسکرین‌شات‌ها
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
## زبان‌های پشتیبانی‌شده
رابط کاربری پنل به ۱۳ زبان در دسترس است:
رابط کاربری پنل به ۱۴ زبان در دسترس است:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
## مشارکت
+3 -3
View File
@@ -41,7 +41,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
- **RESTful API** with scoped, optionally expiring tokens and an in-panel API reference.
- **Installable panel (PWA)** — pin 3X-UI to a desktop or phone home screen.
- **Flexible storage** — SQLite (default) or PostgreSQL.
- **13 UI languages** with dark and light themes.
- **14 UI languages** with dark and light themes.
- **Fail2ban integration** for enforcing per-client IP limits.
## Screenshots
@@ -177,9 +177,9 @@ The complete list is on the [environment variables reference](https://docs.sanae
## Supported Languages
The panel UI is available in 13 languages:
The panel UI is available in 14 languages:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
## Contributing
+3 -3
View File
@@ -41,7 +41,7 @@
- **RESTful API** с токенами ограниченной области действия и необязательным сроком действия, а также справочником API внутри панели.
- **Устанавливаемая панель (PWA)** — закрепите 3X-UI на рабочем столе или главном экране телефона.
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
- **13 языков интерфейса** с тёмной и светлой темами.
- **14 языков интерфейса** с тёмной и светлой темами.
- **Интеграция с Fail2ban** для применения лимитов IP по каждому клиенту.
## Скриншоты
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
## Поддерживаемые языки
Интерфейс панели доступен на 13 языках:
Интерфейс панели доступен на 14 языках:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
## Участие в разработке
+3 -3
View File
@@ -41,7 +41,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
- Kapsamı sınırlanmış, isteğe bağlı olarak süresi dolan token'lar ve panel içi API referansı sunan **RESTful API**.
- **Kurulabilir panel (PWA)** — 3X-UI'yi masaüstüne veya telefon ana ekranına sabitleyin.
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
- Koyu ve açık tema seçenekleriyle **13 farklı UI dili**.
- Koyu ve açık tema seçenekleriyle **14 farklı UI dili**.
- Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
## Ekran Görüntüleri
@@ -177,9 +177,9 @@ Tam liste [ortam değişkenleri referansında](https://docs.sanaei.dev/docs/refe
## Desteklenen Diller
Panel arayüzü 13 farklı dilde mevcuttur:
Panel arayüzü 14 farklı dilde mevcuttur:
İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya)
İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya) · Slovakça
## Katkıda Bulunma
+3 -3
View File
@@ -41,7 +41,7 @@
- **RESTful API**,支持带作用域、可设置有效期的令牌,并提供面板内置的 API 参考文档。
- **可安装面板 (PWA)** — 将 3X-UI 固定到桌面或手机主屏幕。
- **灵活的存储** — SQLite(默认)或 PostgreSQL。
- **13 种界面语言**,支持深色和浅色主题。
- **14 种界面语言**,支持深色和浅色主题。
- **Fail2ban 集成**,用于强制执行按客户端的 IP 限制。
## 截图
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
## 支持的语言
面板界面提供 13 种语言:
面板界面提供 14 种语言:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
## 贡献
+1 -1
View File
@@ -132,7 +132,7 @@ the code already handles is not a finding at all.
- Missing tests for getters, constants, renames or pure map lookups —
`CLAUDE.md` rejects such tests outright.
- A missing or unreferenced i18n key.
`frontend/src/test/i18n-dead-keys.test.ts` pins the 13 locale files in
`frontend/src/test/i18n-dead-keys.test.ts` pins the 14 locale files in
`internal/web/translation/` in both directions, so the `frontend` job is
already red. Report the failing check, not the key.
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env bash
# Exercise only installer function definitions, never its installation entry point.
set -euo pipefail
repo_root=$(cd "$(dirname "$0")/../.." && pwd)
installer=${INSTALLER_UNDER_TEST:-"$repo_root/install.sh"}
test_dir=$(mktemp -d)
trap 'rm -rf -- "$test_dir"' EXIT
load_function() {
local definition
definition=$(awk -v name="$1" '
$0 == name "() {" {copy = 1}
copy {print}
copy && /^}$/ {exit}
' "$installer")
[[ -n "$definition" ]] || { echo "Missing installer function: $1" >&2; return 1; }
eval "$definition"
}
load_function is_port_in_use
load_function config_after_install
# The original installer has no selection helpers; its integration tests still run.
if grep -q '^select_panel_port() {' "$installer"; then
load_function normalize_panel_port
load_function select_panel_port
fi
# These commands are invoked by the extracted installer functions in a subshell.
# shellcheck disable=SC2317,SC2329
curl() { printf '192.0.2.1\n200\n'; }
# shellcheck disable=SC2317,SC2329
gen_random_string() { printf 'test-credential-%s\n' "$1"; }
# shellcheck disable=SC2317,SC2329
prompt_and_setup_ssl() {
echo ssl >> "$CASE_DIR/events"
export SSL_SCHEME=http SSL_HOST=192.0.2.1
}
# shellcheck disable=SC2317,SC2329
write_install_result() { printf '%s\n' "$@" > "$CASE_DIR/result"; }
# shellcheck disable=SC2317,SC2329
read() {
echo read >> "$CASE_DIR/events"
[[ "$NONINTERACTIVE" != 1 ]] || return 90
builtin read -r "$@"
}
# shellcheck disable=SC2317,SC2329
shuf() {
echo random >> "$CASE_DIR/events"
local count=0
[[ ! -f "$CASE_DIR/random-count" ]] || count=$(cat "$CASE_DIR/random-count")
echo "$((count + 1))" > "$CASE_DIR/random-count"
[[ "${SHUF_FAIL:-0}" == 0 ]] || return 1
if ((count > 0)) && [[ -n "${RANDOM_NEXT:-}" ]]; then
printf '%s\n' "$RANDOM_NEXT"
else
printf '54321\n'
fi
}
# Select the availability backend deterministically, regardless of host packages.
# shellcheck disable=SC2317,SC2329
command() {
if [[ "${1:-}" == -v && "${2:-}" =~ ^(ss|netstat|lsof)$ ]]; then
[[ "$2" == "${PORT_BACKEND:-ss}" ]]
else
builtin command "$@"
fi
}
# shellcheck disable=SC2317,SC2329
ss() {
printf 'State Recv-Q Send-Q Local\ Address:Port Peer\ Address:Port\n'
if [[ -n "${BUSY_PORT:-}" ]]; then
printf 'LISTEN 0 128 0.0.0.0:%s 0.0.0.0:*\n' "$BUSY_PORT"
printf 'LISTEN 0 128 [::]:%s [::]:*\n' "$BUSY_PORT"
fi
}
# shellcheck disable=SC2317,SC2329
netstat() {
if [[ -n "${BUSY_PORT:-}" ]]; then
printf 'tcp 0 0 0.0.0.0:%s 0.0.0.0:* LISTEN\n' "$BUSY_PORT"
printf 'tcp6 0 0 :::%s :::* LISTEN\n' "$BUSY_PORT"
fi
}
# shellcheck disable=SC2317,SC2329
lsof() { [[ -n "${BUSY_PORT:-}" && "$*" == *"-iTCP:$BUSY_PORT "* ]]; }
new_case() {
CASE_DIR=$(mktemp -d "$test_dir/case.XXXXXXXX")
export CASE_DIR
: > "$CASE_DIR/events"
xui_folder="$CASE_DIR/bin"
mkdir -p "$xui_folder"
cat > "$xui_folder/x-ui" <<'MOCK'
#!/usr/bin/env bash
set -eu
if [[ "${1:-}" == migrate ]]; then
echo migrate >> "$CASE_DIR/events"
exit 0
fi
case "${2:-}" in
-show) printf 'hasDefaultCredential: true\nwebBasePath: /\nport: 2053\n' ;;
-getCert) printf 'cert: \n' ;;
-getApiToken) printf 'apiToken: test-token\n' ;;
-username)
printf '%s\n' "$@" > "$CASE_DIR/settings-args"
case "${SETTING_FAILURE:-}" in
exit) echo 'controlled settings failure' >&2; exit 23 ;;
zero) echo 'Database initialization failed: controlled failure'; exit 0 ;;
esac
while (($#)); do
case "$1" in -port) port=$2 ;; esac
shift
done
printf 'Port set successfully: %s\n' "$port"
[[ "${SETTING_FAILURE:-}" != partial ]] || exit 0
printf 'Username and password updated successfully\nBase URI path set successfully\n'
;;
*) echo 'Unexpected configuration command' >&2; exit 1 ;;
esac
MOCK
chmod +x "$xui_folder/x-ui"
export red='' green='' blue='' yellow='' plain='' release=ubuntu
export XUI_USERNAME=test-user XUI_PASSWORD=test-pass XUI_WEB_BASE_PATH=test-path
unset XUI_PANEL_PORT SETTING_FAILURE BUSY_PORT RANDOM_NEXT SHUF_FAIL
PORT_BACKEND=ss
NONINTERACTIVE=0
}
run_config() {
local input=$1
# The initial newline selects the existing SQLite default. All later input
# belongs to the port prompts; SSL and persistence are side-effect-free mocks.
printf '\n%s' "$input" > "$CASE_DIR/input"
status=0
(config_after_install) < "$CASE_DIR/input" > "$CASE_DIR/output" 2>&1 || status=$?
}
expect_port() {
[[ "$status" == 0 ]] || { cat "$CASE_DIR/output"; return 1; }
local actual
actual=$(awk 'last == "-port" {print; exit} {last = $0}' "$CASE_DIR/settings-args")
[[ "$actual" == "$1" ]] || { echo "Port $actual, expected $1"; return 1; }
grep -Fxq 'Panel Installation Complete!' < <(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' "$CASE_DIR/output")
[[ -f "$CASE_DIR/result" ]] || return 1
[[ $(sed -n '3p' "$CASE_DIR/result") == "$1" ]] || return 1
[[ $(sed -n '1p' "$CASE_DIR/result") == test-user ]] || return 1
[[ $(sed -n '2p' "$CASE_DIR/result") == test-pass ]] || return 1
[[ $(sed -n '4p' "$CASE_DIR/result") == test-path ]] || return 1
}
expect_failure() {
[[ "$status" != 0 ]] || { echo 'Configuration unexpectedly succeeded'; return 1; }
[[ ! -f "$CASE_DIR/result" ]] || return 1
! grep -q 'Panel Installation Complete!\|SSL Certificate Setup\|^ssl$' "$CASE_DIR/output" "$CASE_DIR/events"
}
affirmative() {
new_case
run_config "$1"$'\n2053\n'
expect_port 2053
! grep -q '^random$' "$CASE_DIR/events"
}
negative() {
new_case
run_config "$1"$'\n'
expect_port 54321
[[ $(grep -c '^random$' "$CASE_DIR/events") == 1 ]]
}
invalid_port() {
new_case
run_config $'y\n'"$1"$'\n2053\n'
expect_port 2053
grep -q 'Invalid port' "$CASE_DIR/output"
! grep -q '^random$' "$CASE_DIR/events"
}
valid_port() {
new_case
run_config $'y\n'"$1"$'\n'
expect_port "$2"
}
confirmation_retry() {
new_case
run_config $'maybe\nyes please\nYES\n2053\n'
expect_port 2053
grep -q 'Please answer yes or no' "$CASE_DIR/output"
! grep -q '^random$' "$CASE_DIR/events"
}
busy_port() {
new_case
BUSY_PORT=2053
PORT_BACKEND=$1
run_config $'y\n2053\n2054\n'
expect_port 2054
grep -q 'already in use' "$CASE_DIR/output"
}
random_retry() {
new_case
BUSY_PORT=54321 RANDOM_NEXT=54322
run_config $'no\n'
expect_port 54322
[[ $(grep -c '^random$' "$CASE_DIR/events") == 2 ]]
}
random_failure() {
new_case
SHUF_FAIL=1
run_config $'no\n'
expect_failure
[[ ! -f "$CASE_DIR/settings-args" ]]
}
random_exhaustion() {
new_case
BUSY_PORT=54321
run_config $'no\n'
expect_failure
[[ ! -f "$CASE_DIR/settings-args" ]]
[[ $(grep -c '^random$' "$CASE_DIR/events") == 100 ]]
}
noninteractive() {
new_case
NONINTERACTIVE=1
case "$1" in unset) ;; *) export XUI_PANEL_PORT=$1 ;; esac
run_config ''
expect_port "$2"
! grep -q '^read$' "$CASE_DIR/events"
}
noninteractive_invalid() {
new_case
NONINTERACTIVE=1
export XUI_PANEL_PORT=$1
run_config ''
expect_failure
[[ ! -f "$CASE_DIR/settings-args" ]]
! grep -q '^read$\|^random$' "$CASE_DIR/events"
}
noninteractive_busy() {
new_case
NONINTERACTIVE=1 BUSY_PORT=2053
export XUI_PANEL_PORT=2053
run_config ''
expect_failure
[[ ! -f "$CASE_DIR/settings-args" ]]
! grep -q '^read$\|^random$' "$CASE_DIR/events"
}
settings_failure() {
new_case
export SETTING_FAILURE=$1
run_config $'y\n2053\n'
expect_failure
[[ -f "$CASE_DIR/settings-args" ]]
grep -q 'Failed to configure panel settings' "$CASE_DIR/output"
}
input_eof() {
new_case
run_config "$1"
expect_failure
[[ ! -f "$CASE_DIR/settings-args" ]]
}
passed=0 failed=0
check() {
local name=$1
shift
# Keep the test body out of an if/|| condition: those disable errexit
# throughout called functions and can hide a failed intermediate assertion.
set +e
(set -e; "$@")
local result=$?
set -e
if [[ "$result" == 0 ]]; then
printf 'PASS %s\n' "$name"
passed=$((passed + 1))
else
printf 'FAIL %s\n' "$name" >&2
failed=$((failed + 1))
fi
}
for answer in y Y yes Yes YES ' yEs ' $'\tYES\t'; do check "affirmative [$answer]" affirmative "$answer"; done
for answer in n N no No NO '' ' nO ' $' \t \r '; do check "negative [$answer]" negative "$answer"; done
check 'invalid confirmation retries' confirmation_retry
for port in '' abc 0 -1 65536 00065536 70000 1.5 +2053 '20 53' 0x805 999999999999999999999999 00000 '1+2' 'a[0]'; do
check "invalid port [$port]" invalid_port "$port"
done
for port in 1 2053 65535; do check "valid port [$port]" valid_port "$port" "$port"; done
check 'leading zeros use decimal' valid_port 00002053 2053
check 'leading zeros containing eight use decimal' valid_port 008080 8080
check 'leading-zero boundary uses decimal' valid_port 065535 65535
for backend in ss netstat lsof; do check "busy port via $backend" busy_port "$backend"; done
check 'busy random port retried' random_retry
check 'random generator failure' random_failure
check 'random search exhaustion' random_exhaustion
check 'noninteractive supplied port' noninteractive 2053 2053
check 'noninteractive decimal normalization' noninteractive 00002053 2053
check 'noninteractive random default' noninteractive unset 54321
for port in '' abc 0 -1 65536 1.5 999999999999999999999; do
check "noninteractive invalid [$port]" noninteractive_invalid "$port"
done
check 'noninteractive busy port fails' noninteractive_busy
for failure in exit zero partial; do check "settings failure [$failure]" settings_failure "$failure"; done
check 'EOF at confirmation' input_eof ''
check 'EOF at custom port' input_eof $'y\n'
printf '%s passed, %s failed\n' "$passed" "$failed"
[[ "$failed" == 0 ]]
+36 -3
View File
@@ -231,7 +231,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
│ │ ├── 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
│ │ ├── locale/ + translation/ # i18n middleware + 14 locale JSON catalogs
│ │ ├── entity/ # Shared request/response DTOs
│ │ └── dist/ # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
│ │
@@ -320,6 +320,12 @@ Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
**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).
Inbound-list statistics read only UUID, email and subscription ID from `clients`, joined
through `client_inbounds` in batches of up to 400 distinct inbound IDs. Full client records
remain the source for Xray and subscriptions. Missing shared-client traffic is fetched by
email, including rows owned by an inbound outside the requested list; enrichment preserves
the stored traffic, lifecycle and last-online fields.
### 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
@@ -340,6 +346,22 @@ code_ works whether the target is the local Xray or a remote node.
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
Bearer tokens use the narrowest scope that matches the caller's duties:
| Scope | Access |
| ------------ | ---------------------------------------------------------------------- |
| `monitor` | Read-only status, version, and metric endpoints. |
| `node-sync` | Node configuration, inbound/client synchronization, traffic, and Xray. |
| `node-admin` | All `node-sync` access plus `POST /panel/api/server/updatePanel`. |
| `admin` | Every panel API endpoint; intended for fully trusted automation. |
`node-admin` remains a node synchronization identity when inbound rules are applied. This
preserves the node-specific validation path while keeping software-update authority separate
from ordinary `node-sync` credentials. On an mTLS connection, only a valid `node-admin` bearer
elevates the request; other bearer scopes retain the historical mTLS `node-sync` behavior.
Existing tokens retain their stored scope, and legacy tokens without one continue to migrate to
`admin`.
**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.
@@ -370,6 +392,17 @@ A client reset is queued per hosting node in `model.NodePendingReset` (`service/
and replayed by the node sync until the node accepts it.
Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
Cross-panel totals are stored per master GUID and hosted email, separately from local
`client_traffics`. Directional counter changes, including lower values and resets, persist
on the next push. Identical totals refresh only when the persisted timestamp is at least
one hour old; pushes still send the same batches. Display and quota reads retain the
inclusive 24-hour persisted-row freshness window. Full-list snapshots remain local-only,
while slim/detail reads and downstream forwarding retain their global overlays.
The heartbeat coarsens liveness, not counter propagation: an idle master can expire up to
one hour earlier relative to its final unchanged push. Active hourly refresh leaves roughly
23 hours of missed-push margin. No cache, external service or configurable interval is added.
### 5.4 Background jobs (cron)
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
@@ -381,7 +414,7 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
| `@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` | `check_client_ip_job` | Record client IPs (30-min window) and enforce per-client IP limits |
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
| `@every 10s` | `amneziawg_job` | Reconcile embedded AmneziaWG interfaces against enabled local inbounds |
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
@@ -607,7 +640,7 @@ them with a comment starting `@claude review` on a PR or `@claude analyze` on an
(`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
- **i18n:** backend catalogs in `internal/web/translation/` (14 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
@@ -77,7 +77,7 @@ export function TelegramSetupHelper() {
/>
{adminIds ? (
idsV.invalid.length > 0 ? (
<Status ok={false}>Not numeric: {idsV.invalid.join(', ')}</Status>
<Status ok={false}>Invalid: {idsV.invalid.join(', ')}</Status>
) : (
<Status ok>
{idsV.ids.length} admin id{idsV.ids.length === 1 ? '' : 's'}
+56 -8
View File
@@ -1,6 +1,6 @@
---
title: AmneziaWG
description: Set up an AmneziaWG inbound in 3x-ui — obfuscation parameters, native IPv6, per-client port-forwarding, and routing client traffic through Xray.
description: Configure embedded or optional Linux kernel AmneziaWG inbounds in 3x-ui.
icon: Lock
---
@@ -116,13 +116,61 @@ the server.
</Steps>
Every AmneziaWG inbound's traffic already goes through Xray — each peer
relays into its own loopback SOCKS5 inbound, tagged with the AmneziaWG
inbound's own tag, so it shows up as a normal source on the
[Routing](/docs/operations/outbounds-routing) page like any other protocol's
inbound. There is no separate toggle for this: unlike a kernel tunnel,
there's no other way for a peer's traffic to reach the internet once it's
decapsulated.
In the **embedded** backend, each peer relays into its own loopback SOCKS5
inbound, tagged with the AmneziaWG inbound's tag. Its traffic therefore uses
Xray routing and appears on the [Routing](/docs/operations/outbounds-routing)
page, like other Xray inbounds.
## Optional Linux kernel backend
The embedded backend remains the default. On a supported Linux host, an
administrator can opt an individual inbound into the kernel backend with
**Kernel mode**. The panel then creates an `xui-awg<id>` interface on the
AmneziaWG kernel module, configures it with `awg setconf`, and masquerades
peer traffic out of the host NIC with an nftables table of its own
(`xui_awg<id>`). Peer traffic bypasses Xray. Nothing runs through a shell:
every step is a direct `ip`, `awg` or `nft` command, and the interface's
config lives in a panel-private directory (`/etc/x-ui/amneziawg` on a standard
install), never next to your own `awg-quick` configs.
Kernel mode requires a Linux host running the panel as root, with the
`amneziawg` module installed and `awg`, `ip` and `nft` available. The
readiness panel reports missing host requirements and provides the install
command. On a standard install, run
`sudo /usr/local/x-ui/x-ui install-awg-kernel`. The bundled command builds a
pinned, commit-verified release of the module and tools on Debian and Ubuntu;
kernel headers and, when Secure Boot is enabled, module signing must be
available. Review the script and host changes before running it.
The panel enables IPv4 forwarding and, when `iptables` is installed, adds two
`FORWARD` accept rules per interface (commented `3x-ui:xui-awg<id>`), so a host
whose `FORWARD` policy is `DROP` — as Docker and ufw set it — still forwards
the tunnel. A firewall that drops forwarded traffic in its own nftables table,
such as firewalld, still needs a rule from you.
Kernel mode is not supported in Docker. Embedded mode continues to work in
containers. The kernel backend does not support Xray routing or sniffing,
per-client IPv6, client port forwarding, or a pinned listen address; the panel
rejects saves that request those features. On a multi-node setup the master
checks that the target node supports kernel mode before pushing such an
inbound, and the node checks its own host when it applies it.
Stopping the panel records the interfaces' final traffic and then tears them
down, exactly as the embedded backend stops: a stopped panel enforces no quota
or expiry, so nothing keeps forwarding. A panel-only restart (`SIGHUP`) leaves
them running. If the panel process dies instead, the next start adopts the
surviving interfaces; traffic between the crash and the first counter read
after it is not charged. Interfaces and configs the panel cannot prove are its
own — a same-named link with a different key, or an unmarked, non-regular or
symlinked config file — are never changed or removed.
## Keepalive ranges
AmneziaWG clients accept `keepAlive` as a number or an ordered range from 0 to 65535, such as `15-25`. Omitted values keep the existing setting; explicit `0` disables keepalive. Native `.conf` files preserve ranges, while Clash/Mihomo scalar-only output uses the lower bound (`0-25` therefore disables keepalive). Plain WireGuard clients support only a scalar value. Upgrade sub-nodes before sending them range values because older nodes parse keepalive as an integer.
Parser support is present in the native tools and the panel embedded engine: [the native `awg-tools` v3.1.20260812 config parser](https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L596-L599) sends `PersistentKeepalive` to its [ordered-range parser](https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.c#L40-L59), and this panel pins [amneziawg-go v3.1.20260828](https://github.com/amnezia-vpn/amneziawg-go/tree/v3.1.20260828) in `go.mod`; its [UAPI parser](https://github.com/amnezia-vpn/amneziawg-go/blob/v3.1.20260828/device/uapi.go#L663-L674) reads the interval as a `UintRange`. This confirms parser support for these code paths, not interoperability with every third-party client. The panel enforces the documented 0-65535 limit on new values; the native parser's syntax support is not a substitute for that validation.
![AmneziaWG client editor with an ordered keepalive range](/images/amneziawg-keepalive-range.png)
## What the configuration looks like
+5 -3
View File
@@ -131,9 +131,11 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint.
Raw `vless://` links carry the equivalent `support-x25519mlkem768=true` hint;
clients that support this URI extension, including current Mihomo builds, apply
it on import. Very old REALITY servers that reject ML-KEM require a per-node
client override setting this option to `false`, or a server upgrade. Clearing
the version limit alone does not fix the handshake.
it on import. Servers on Xray-core older than 26.9.8 accept a plain `X25519`
hello, and very old ones reject ML-KEM outright. For those, or where a network
drops the larger ML-KEM ClientHello, enable **Mihomo X25519** on a Host: its
Clash proxy gets `support-x25519mlkem768: false` and its `vless://` link drops
the hint. Clearing the version limit alone does not fix the handshake.
</Callout>
+37 -1
View File
@@ -51,7 +51,7 @@ The **format is chosen by path**, each with its own enable toggle:
| **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, MTProto, TUIC, or Hysteria2**
appear in a subscription, ordered by their sub-sort index (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
appear in a subscription, ordered by their sub-sort index by default (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
page instead of the raw body.
@@ -74,6 +74,42 @@ default matcher so existing subscription URLs continue returning YAML.
It does not distinguish legacy clients from Mihomo-based clients; Clash for
Windows users must use `/clash-legacy/<sub-id>` for a compatible profile.
### Subscription node ordering
In **Settings → Subscription → Panel settings**, select **Subscription Node Ordering**
(`subHostSortMode`). **Inbound order (default)** preserves the existing subscription
sort index, then inbound ID, with each inbound's Hosts ordered inside that block.
**Hosts order** (`hosts-first`) uses the global order on **Hosts**, including Host
groups attached to different inbounds. Drag a group's grip above or below another
row to save its position, or use the existing Up/Down buttons. A failed save restores
the previous displayed order. The next subscription fetch uses the saved order;
no inbound edit or Xray restart is needed.
For example, these rows start with Host A (Inbound #5) above Host B (Inbound #20):
![Hosts before reordering, with Host A above Host B](/images/hosts-order-before.png)
Dragging Host B above Host A saves the order across both inbounds:
![Hosts after reordering, with Host B above Host A](/images/hosts-order-after.png)
These screenshots use example Hosts and synthetic API responses.
Host groups sort by their persisted sort order. Ties use the group remark and group
ID, matching the Hosts table. Within a group, inbound subscription sort index,
inbound ID and Host row ID provide deterministic ordering for attachments and
addresses. Inbounds without applicable Hosts and legacy externalProxy entries
follow all Host entries in their previous relative order. External client links
remain appended, and the subscription info node remains first.
This applies to Raw/Base64, Clash/Mihomo and JSON subscriptions. JSON balancer
documents keep their slots from the existing numeric balancer/inbound sort; only
the endpoint slots are rearranged, preserving the relative order of Host entries.
Eligibility, exclusions, credentials and protocol settings are unchanged.
Standalone share-link and QR previews retain their existing behavior. Client apps
may apply their own sorting after importing a subscription.
### Base64 vs JSON
The **Base64** body is just the newline-joined share links, standard-base64
+17 -5
View File
@@ -23,7 +23,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`,
| **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. |
| **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. |
| **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. |
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, a local folder or any HTTP storage API). |
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, S3, a local folder or any HTTP storage API). |
| **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). |
<Callout type="info">
@@ -125,12 +125,18 @@ that folder instead of accepting connections on its port. Both sides must use th
**same** service, folder and secrets, so the JSON subscription ships them to every
client; XDRIVE inbounds get no share link or Clash entry.
Since Xray 26.10.17 XDRIVE carries only **VLESS** inside **VLESS encryption**, with
no flow: an inbound needs a `decryption` value, an outbound its `encryption` and Mux
enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound without
`decryption`, and leaves one stored before the upgrade out of the running config.
| Field | Default | Meaning |
| -------------- | -------------- | ----------------------------------------------------------------------- |
| `service` | `Google Drive` | `Google Drive`, `local` (a folder on disk) or `template` (any HTTP storage API). |
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) `template` (any HTTP storage API) or `s3` (S3-compatible storage). The old `Google Drive` spelling is still read. |
| `remoteFolder` | — | Folder that holds the session files. |
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. |
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey and an optional SessionToken. |
| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. |
| `s3` | — | S3 service only: `endpoint` (an http(s) URL), `region`, `bucket` and `pathStyle` (default `true`; `false` puts the bucket in the host name). `remoteFolder` is the object prefix. |
| tuning | core defaults | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
The optional **Front address/port** (the stream-level `address`/`port`) is a domain
@@ -147,9 +153,10 @@ TLS. Masks are configured per direction:
- **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the
stream as Minecraft protocol traffic; requires a password, with optional
hostname and player usernames).
hostname and player usernames), plus the inbound-only `filter`.
- **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
`noise`, `sudoku`, `realm`, plus the client-only `udphop`. (`mkcp-legacy`
`noise`, `sudoku`, `realm`, plus the client-only `udphop` and the inbound-only
`filter`. (`mkcp-legacy`
reproduces the old mKCP header obfuscation; `udphop` rotates the remote port —
and optionally the remote IP — on an interval or per connection to dodge port
blocking.)
@@ -160,6 +167,11 @@ TLS. Masks are configured per direction:
Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts
an older client `quicParams.udpHop` to it.
`filter` (Xray 26.10.21) drops an inbound's connections by source IP:
`allowedSourceIPs` admits only the listed sources, `blockedSourceIPs` drops its
sources, and a blocked match wins. Both take CIDRs or `geoip:` entries. Share links
and JSON subscriptions leave it out, since it only acts on the listener.
FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray
builds exposed.
+5 -5
View File
@@ -10,7 +10,7 @@ and custom congestion control algorithms to maintain stable connections over los
unstable networks.
<Callout type="info">
TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and zero-downtime hot user updates without restarting the port.
TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and in-memory client management.
</Callout>
## Key settings
@@ -55,7 +55,7 @@ The panel fills recommended defaults (`bbr`, `h3`, `native` UDP relay). Adjust t
<Step>
### Add clients
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. The panel automatically generates secure random credentials when creating clients.
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. TUIC email identifiers must contain 1–255 UTF-8 bytes. Existing clients with invalid identifiers are excluded from TUIC service with a warning; shorten their identifiers to restore access. The panel automatically generates secure random credentials when creating clients.
</Step>
<Step>
@@ -103,10 +103,10 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
<Callout type="info">
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. The client **Email** is preserved for `user` rules on TCP and both UDP modes. Inbound tags (`in-<port>-udp`), domain/IP rules, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.) are supported. Put specific `inboundTag` + `user` rules before broader `user` rules; Xray uses the first matching rule.
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay. A separate relay policy disables Xray's per-user traffic and loopback online-IP counters; native TUIC remains the source for client accounting, speed, and activity.
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
- **Client updates**: Native TUIC enforces client enable/disable, quotas, and expiration; resetting a quota restores access without restarting Xray. Adding, removing, or renaming clients replaces only that inbound's internal SOCKS relay. Accounts are validated before replacement and checked again against the running relay afterward. A failed update restores the previous relay and schedules a retry. Failures are isolated to related relays: healthy TUIC inbounds and independent Xray updates continue to apply. Routing changes that depend on a failed tag change wait for that relay to recover. Related client and inbound changes also wait; new TUIC sessions are admitted only after their routing policy is applied. New relay connections wait up to 10 seconds while replacement or rollback is in progress; existing TCP streams and UDP associations continue running. New UDP associations use a bounded packet queue so they do not block existing flows on the same QUIC connection. Waiting is canceled on disconnect. Queue overflow or an unavailable relay can still reject a new request; other protocols keep running. TUIC password-only changes and client-profile edits do not change relay accounts.
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
</Callout>
+1 -1
View File
@@ -31,7 +31,7 @@ Import a client's share link or QR code, or use a
3x-ui is an enhanced fork of the original X-UI project. It adds broader protocol
support, improved stability, per-client traffic accounting, multi-node
management, a 13-language UI, and many quality-of-life features.
management, a 14-language UI, and many quality-of-life features.
## How do I update?
@@ -38,7 +38,9 @@ and it just works:
```text title="Caddyfile"
panel.example.com {
reverse_proxy 127.0.0.1:2053
reverse_proxy 127.0.0.1:2053 {
header_up X-Real-IP {remote_host}
}
}
```
@@ -37,6 +37,11 @@ connected). This is your **admin** ID.
In Panel Settings, enable the Telegram bot and set the **token** and **admin chat
ID(s)** (comma-separated). Save, then message your bot.
To send notifications into a specific topic of a forum supergroup, append the
topic ID after a colon, e.g. `-1001234567890:42`. The topic ID is the number
after the chat in a topic message link (`https://t.me/c/1234567890/42/…`). Leave
the topic off (or use `1`) for the General topic.
</Step>
</Steps>
@@ -71,8 +76,11 @@ admins are notified when one runs out.
Admins also get inline-button flows for server usage, sorted traffic reports,
resetting traffic, DB backups, ban logs, listing inbounds/clients, online
clients, "depleting soon", and a full **add-client** wizard. Regular users get
buttons for their own usage, subscription links, individual links, and QR codes.
clients, "depleting soon", and a full **add-client** wizard. The admin's
**client card** carries the client's subscription link, individual links and QR
code, so a config can be handed to a client who cannot reach Telegram yet.
Regular users get buttons for their own usage, subscription links, individual
links, and QR codes.
## Reports & alerts
@@ -101,7 +109,7 @@ The warning lead times come from `expireDiff` (days before expiry) and
| -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | Master on/off. |
| `tgBotToken` | _(secret)_ | Bot API token. |
| `tgBotChatId` | _(none)_ | Comma-separated **admin** Telegram IDs. |
| `tgBotChatId` | _(none)_ | Comma-separated **admin** Telegram IDs; `chatId:topicId` targets a forum topic. |
| `tgBotProxy` | _(none)_ | `socks5://`, `http://`, or `https://` proxy. |
| `tgBotAPIServer` | _(default)_ | Custom Telegram Bot API server. |
| `tgRunTime` | `@daily` | Report schedule (cron / `@daily` / `@every …`).|
@@ -1,8 +1,10 @@
---
title: API Tokens
description: Manage scoped Bearer tokens for programmatic auth. Tokens grant
admin, monitor, or node-sync access, may expire, and are stored as SHA-256
hashes. The plaintext is returned only once at creation.
description: Manage scoped Bearer tokens for programmatic auth. admin grants the
full panel API; monitor is read-only for status and metrics; node-sync manages
node configuration without panel updates; node-admin adds panel self-update to
node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext
is returned only once at creation.
full: true
_openapi:
preload:
@@ -44,13 +46,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/apiTokens","method":"get"},{"path":"/panel/api/setting/apiTokens/create","method":"post"},{"path":"/panel/api/setting/apiTokens/delete/{id}","method":"post"},{"path":"/panel/api/setting/apiTokens/setEnabled/{id}","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/apiTokens","method":"get"},{"path":"/panel/api/setting/apiTokens/create","method":"post"},{"path":"/panel/api/setting/apiTokens/delete/{id}","method":"post"},{"path":"/panel/api/setting/apiTokens/setEnabled/{id}","method":"post"}]} showTitle />
</>
);
}
@@ -59,13 +59,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
</>
);
}
@@ -21,13 +21,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/backuptotgbot","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/backuptotgbot","method":"post"}]} showTitle />
</>
);
}
+39 -23
View File
@@ -28,6 +28,9 @@ _openapi:
title: Fetch one client by email, including the inbound IDs and external config
IDs it is attached to.
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
- depth: 2
title: Fetch clients by subscription ID.
url: '#fetch-clients-by-subscription-id'
- depth: 2
title: Fetch clients by Telegram user ID. Returns an array since multiple
clients can share the same Telegram ID.
@@ -179,10 +182,11 @@ _openapi:
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
- depth: 2
title: Zero up/down counters for many clients in one call. Loops the
single-reset path so each client is re-enabled across its attached
inbounds and pushed to Xray/remote nodes. Returns the count of
successfully reset clients.
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients'
single-reset path so each client its quota had disabled is re-enabled
across its attached inbounds and pushed to Xray/remote nodes; a client
disabled by hand stays disabled. Returns the count of successfully reset
clients.
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients'
- depth: 2
title: List all client groups with their member counts. Merges persisted groups
(rows in client_groups, including empty placeholders) with the distinct
@@ -220,10 +224,11 @@ _openapi:
client_groups row if the group exists only as a derived label.
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
- depth: 2
title: Zero out a single client’s up/down counters. Re-enables the client across
every attached inbound and pushes the change to Xray (or the remote
node) so depleted users can connect again immediately.
url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately'
title: Zero out a single client’s up/down counters. Re-enables a client its
quota had disabled across every attached inbound and pushes the change
to Xray (or the remote node) so depleted users can connect again
immediately; a client disabled by hand stays disabled.
url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled'
- depth: 2
title: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems.
@@ -319,6 +324,8 @@ _openapi:
- content: Fetch one client by email, including the inbound IDs and external
config IDs it is attached to.
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
- content: Fetch clients by subscription ID.
id: fetch-clients-by-subscription-id
- content: Fetch clients by Telegram user ID. Returns an array since multiple
clients can share the same Telegram ID.
id: fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id
@@ -452,10 +459,11 @@ _openapi:
any target inbound was running."
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
- content: Zero up/down counters for many clients in one call. Loops the
single-reset path so each client is re-enabled across its attached
inbounds and pushed to Xray/remote nodes. Returns the count of
successfully reset clients.
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients
single-reset path so each client its quota had disabled is re-enabled
across its attached inbounds and pushed to Xray/remote nodes; a client
disabled by hand stays disabled. Returns the count of successfully
reset clients.
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients
- content: List all client groups with their member counts. Merges persisted
groups (rows in client_groups, including empty placeholders) with the
distinct group_name values currently set on clients. Sorted
@@ -487,10 +495,11 @@ _openapi:
Creates the client_groups row if the group exists only as a derived
label.
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
- content: Zero out a single client’s up/down counters. Re-enables the client
across every attached inbound and pushes the change to Xray (or the
remote node) so depleted users can connect again immediately.
id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately
- content: Zero out a single client’s up/down counters. Re-enables a client its
quota had disabled across every attached inbound and pushes the change
to Xray (or the remote node) so depleted users can connect again
immediately; a client disabled by hand stays disabled.
id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled
- content: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems.
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
@@ -555,6 +564,13 @@ _openapi:
tunnel) contribute nothing.'
id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
contents:
- content: Matches the exact subscription ID and orders results by client ID.
Returns an array of the same payloads as GET /clients/get/:email,
since multiple clients can share a subscription ID. Includes disabled,
expired and unattached clients. An unknown ID returns HTTP 200 with
success:false, obj:null and a record-not-found message, matching the
email lookup. Requires an admin token or a panel login session.
heading: fetch-clients-by-subscription-id
- content: >-
Fields the server fills in when they are omitted — a valid value sent
by the caller is never overwritten. Re-adding an email that already
@@ -597,9 +613,11 @@ _openapi:
different client of that same inbound holds, or `... used by a client
on <inbound>` when the holder sits on another WireGuard or AmneziaWG
inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides
with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no
address. Allocation likewise skips every address inside a prefix
another client holds. The same validation runs on POST
with `10.0.0.5/32`; a `/0` entry collides with every address of its
family another client holds, and only a `0.0.0.0/0` or `::/0` default
route that is already stored (legacy peers) keeps nobody out.
Allocation likewise skips every address inside a prefix another client
holds. The same validation runs on POST
/panel/api/clients/{email}/attach, where a client that already carries
an address brings it along.
@@ -663,13 +681,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
</>
);
}
+15 -6
View File
@@ -12,6 +12,10 @@ _openapi:
title: List every host across all inbounds, grouped by inbound then ordered by
sort order.
url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
- depth: 2
title: List every host row with its numeric id, for id-based pickers such as the
per-client host rule override.
url: '#list-every-host-row-with-its-numeric-id-for-id-based-pickers-such-as-the-per-client-host-rule-override'
- depth: 2
title: Fetch a single host group by Group ID.
url: '#fetch-a-single-host-group-by-group-id'
@@ -35,7 +39,9 @@ _openapi:
url: '#enable-or-disable-a-host-group'
- depth: 2
title: Set host group sort order by the position of each groupId in the array.
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array'
IDs must be unique and refer to existing groups; omitted groups follow
in their previous order.
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array-ids-must-be-unique-and-refer-to-existing-groups-omitted-groups-follow-in-their-previous-order'
- depth: 2
title: Add a host group to inbounds (same as /add).
url: '#add-a-host-group-to-inbounds-same-as-add'
@@ -50,6 +56,9 @@ _openapi:
- content: List every host across all inbounds, grouped by inbound then ordered by
sort order.
id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order
- content: List every host row with its numeric id, for id-based pickers such as
the per-client host rule override.
id: list-every-host-row-with-its-numeric-id-for-id-based-pickers-such-as-the-per-client-host-rule-override
- content: Fetch a single host group by Group ID.
id: fetch-a-single-host-group-by-group-id
- content: Fetch one inbound's hosts, grouped by host group.
@@ -65,7 +74,9 @@ _openapi:
- content: Enable or disable a host group.
id: enable-or-disable-a-host-group
- content: Set host group sort order by the position of each groupId in the array.
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
IDs must be unique and refer to existing groups; omitted groups follow
in their previous order.
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array-ids-must-be-unique-and-refer-to-existing-groups-omitted-groups-follow-in-their-previous-order
- content: Add a host group to inbounds (same as /add).
id: add-a-host-group-to-inbounds-same-as-add
- content: Enable or disable many host groups in one call.
@@ -78,13 +89,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/raw","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
</>
);
}
+45 -20
View File
@@ -33,6 +33,13 @@ _openapi:
clientStats so the payload stays small even on panels with thousands of
clients.
url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients'
- depth: 2
title: Whether this host can run an AmneziaWG inbound on the kernel module.
Returns ready, host blocker codes (root/tools/module/nft), and the
command that fixes them. Saving an inbound also checks its features and
rejects IPv6, port forwarding, routeThroughXray, or a pinned listen
address that the kernel backend cannot honor.
url: '#whether-this-host-can-run-an-amneziawg-inbound-on-the-kernel-module-returns-ready-host-blocker-codes-roottoolsmodulenft-and-the-command-that-fixes-them-saving-an-inbound-also-checks-its-features-and-rejects-ipv6-port-forwarding-routethroughxray-or-a-pinned-listen-address-that-the-kernel-backend-cannot-honor'
- depth: 2
title: Return every protocol URL (vless://, vmess://, trojan://, ss://,
hysteria://, mtproto) across all inbounds and all of their clients.
@@ -49,8 +56,10 @@ _openapi:
title: Create a new inbound. Send the full inbound payload (protocol, port,
settings, streamSettings, sniffing, remark, expiryTime, total, enable).
settings, streamSettings, and sniffing may be sent as nested JSON
objects (preferred) or as JSON-encoded strings (legacy).
url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy'
objects (preferred) or as JSON-encoded strings (legacy). VLESS empty
fallback arrays are normalized away; nonempty fallbacks cannot be
combined with active server decryption.
url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption'
- depth: 2
title: Delete an inbound by ID. Also removes its associated client stats rows.
url: '#delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows'
@@ -63,8 +72,10 @@ _openapi:
title: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
inbound keeps its stored client list and enable flag: settings.clients
and enable in the body are ignored. Manage clients through the
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable'
/panel/api/clients endpoints and toggle the inbound with /setEnable.
Remove effective fallback rules before enabling VLESS server
decryption.'
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption'
- depth: 2
title: Toggle only the enable flag without serialising the whole settings JSON.
Recommended for UI switches on large inbounds.
@@ -89,8 +100,9 @@ _openapi:
url: '#reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost'
- depth: 2
title: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
The body uses form encoding with a single "data" field.
url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field'
The body uses form encoding with a single "data" field. VLESS fallback
validation is identical to /add.
url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field-vless-fallback-validation-is-identical-to-add'
- depth: 2
title: Receive a master panel's aggregated per-client usage, keyed by the
master's GUID. Stored in a side table used only for the UI display
@@ -106,8 +118,10 @@ _openapi:
url: '#list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used'
- depth: 2
title: Replace the entire fallback list for a master inbound. Body is JSON.
Triggers an Xray restart.
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
Nonempty lists require TCP with TLS/REALITY and no active VLESS server
decryption. An empty list can remove rules even from an incompatible
inbound. Triggers an Xray restart.
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart'
structuredData:
headings:
- content: List every inbound owned by the authenticated user, including each
@@ -131,6 +145,12 @@ _openapi:
clientStats so the payload stays small even on panels with thousands
of clients.
id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
- content: Whether this host can run an AmneziaWG inbound on the kernel module.
Returns ready, host blocker codes (root/tools/module/nft), and the
command that fixes them. Saving an inbound also checks its features
and rejects IPv6, port forwarding, routeThroughXray, or a pinned
listen address that the kernel backend cannot honor.
id: whether-this-host-can-run-an-amneziawg-inbound-on-the-kernel-module-returns-ready-host-blocker-codes-roottoolsmodulenft-and-the-command-that-fixes-them-saving-an-inbound-also-checks-its-features-and-rejects-ipv6-port-forwarding-routethroughxray-or-a-pinned-listen-address-that-the-kernel-backend-cannot-honor
- content: Return every protocol URL (vless://, vmess://, trojan://, ss://,
hysteria://, mtproto) across all inbounds and all of their clients.
Links are rendered through the subscription engine, so the configured
@@ -144,8 +164,10 @@ _openapi:
- content: Create a new inbound. Send the full inbound payload (protocol, port,
settings, streamSettings, sniffing, remark, expiryTime, total,
enable). settings, streamSettings, and sniffing may be sent as nested
JSON objects (preferred) or as JSON-encoded strings (legacy).
id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy
JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS
empty fallback arrays are normalized away; nonempty fallbacks cannot
be combined with active server decryption.
id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption
- content: Delete an inbound by ID. Also removes its associated client stats rows.
id: delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows
- content: Delete many inbounds in one call. Processes the list sequentially;
@@ -155,8 +177,10 @@ _openapi:
- content: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
inbound keeps its stored client list and enable flag: settings.clients
and enable in the body are ignored. Manage clients through the
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable
/panel/api/clients endpoints and toggle the inbound with /setEnable.
Remove effective fallback rules before enabling VLESS server
decryption.'
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption
- content: Toggle only the enable flag without serialising the whole settings
JSON. Recommended for UI switches on large inbounds.
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
@@ -175,8 +199,9 @@ _openapi:
accounting history is lost.
id: reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
- content: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
The body uses form encoding with a single "data" field.
id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field
The body uses form encoding with a single "data" field. VLESS fallback
validation is identical to /add.
id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field-vless-fallback-validation-is-identical-to-add
- content: Receive a master panel's aggregated per-client usage, keyed by the
master's GUID. Stored in a side table used only for the UI display
overlay and local quota enforcement — never folded into the local
@@ -189,21 +214,21 @@ _openapi:
inbound's listen+port is used.
id: list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used
- content: Replace the entire fallback list for a master inbound. Body is JSON.
Triggers an Xray restart.
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
Nonempty lists require TCP with TLS/REALITY and no active VLESS server
decryption. An empty list can remove rules even from an incompatible
inbound. Triggers an Xray restart.
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart
contents: []
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/amneziawgKernelReadiness","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
</>
);
}
+2 -4
View File
@@ -145,13 +145,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
</>
);
}
@@ -114,8 +114,8 @@ _openapi:
url: '#download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release'
- depth: 2
title: Self-update the panel to the latest version. The server restarts on
success.
url: '#self-update-the-panel-to-the-latest-version-the-server-restarts-on-success'
success. Bearer-token callers need admin or node-admin scope.
url: '#self-update-the-panel-to-the-latest-version-the-server-restarts-on-success-bearer-token-callers-need-admin-or-node-admin-scope'
- depth: 2
title: Toggle the panel update channel between stable and the rolling per-commit
dev release. Only effective on dev builds.
@@ -263,8 +263,8 @@ _openapi:
newest release.
id: download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release
- content: Self-update the panel to the latest version. The server restarts on
success.
id: self-update-the-panel-to-the-latest-version-the-server-restarts-on-success
success. Bearer-token callers need admin or node-admin scope.
id: self-update-the-panel-to-the-latest-version-the-server-restarts-on-success-bearer-token-callers-need-admin-or-node-admin-scope
- content: Toggle the panel update channel between stable and the rolling
per-commit dev release. Only effective on dev builds.
id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
@@ -321,13 +321,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/amneziawglogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/amneziawglogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
</>
);
}
@@ -48,6 +48,10 @@ _openapi:
title: Test Telegram bot connection by sending a test message to the configured
chat.
url: '#test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat'
- depth: 2
title: Report what the configured bot can do. Inline mode is a BotFather switch,
so the panel warns when it is off.
url: '#report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off'
- depth: 2
title: Test Discord bot connection by sending a test embed to the configured
channel.
@@ -90,6 +94,9 @@ _openapi:
- content: Test Telegram bot connection by sending a test message to the
configured chat.
id: test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat
- content: Report what the configured bot can do. Inline mode is a BotFather
switch, so the panel warns when it is off.
id: report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off
- content: Test Discord bot connection by sending a test embed to the configured
channel.
id: test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel
@@ -102,13 +109,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/tgBotCapabilities","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
</>
);
}
@@ -50,13 +50,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
</>
);
}
@@ -98,13 +98,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{subPath}{subid}/hwid-status","method":"get"},{"path":"/{subPath}{subid}/hwid-status","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{subPath}{subid}/hwid-status","method":"get"},{"path":"/{subPath}{subid}/hwid-status","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
</>
);
}
@@ -29,13 +29,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/ws","method":"get"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/ws","method":"get"}]} showTitle />
</>
);
}
@@ -213,13 +213,11 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
const { OpenAPIPage } = props.components ?? {};
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
</>
);
}
+9 -3
View File
@@ -140,9 +140,15 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
راهنمای معادل
`support-x25519mlkem768=true`
را منتقل می‌کند؛ کلاینت‌هایی که این افزونهٔ URI را پشتیبانی می‌کنند، از جمله نسخه‌های فعلی Mihomo، آن را هنگام ورود اعمال می‌کنند.
برای سرورهای بسیار قدیمی که ML-KEM را رد می‌کنند، گزینه را برای همان گره در کلاینت روی
`false`
بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند.
سرورهای Xray-core قدیمی‌تر از 26.9.8 دست‌دهی سادهٔ
`X25519`
را می‌پذیرند و سرورهای بسیار قدیمی ML-KEM را رد می‌کنند. برای آن‌ها، یا اگر شبکه ClientHello بزرگ‌تر ML-KEM را حذف می‌کند، گزینهٔ
**Mihomo X25519**
را در میزبان (Host) فعال کنید: پروکسی Clash آن مقدار
`support-x25519mlkem768: false`
را می‌گیرد و لینک
`vless://`
آن راهنما را ندارد. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند.
</Callout>
+16 -4
View File
@@ -126,12 +126,18 @@ XDRIVE جریان را به‌صورت فایل‌هایی در پوشه‌ای
و مقادیر محرمانهٔ **یکسان** داشته باشند، بنابراین اشتراک JSON آن‌ها را به همهٔ کلاینت‌ها
می‌فرستد؛ inboundهای XDRIVE لینک اشتراک‌گذاری یا ورودی Clash ندارند.
از Xray 26.10.17، XDRIVE فقط **VLESS** را درون **رمزنگاری VLESS** و بدون flow حمل می‌کند:
inbound به مقدار `decryption` نیاز دارد و outbound به `encryption` و Mux فعال. پنل XDRIVE
را فقط برای VLESS ارائه می‌کند، ذخیرهٔ inbound بدون `decryption` را رد می‌کند و inboundی را
که پیش از به‌روزرسانی ذخیره شده بیرون از پیکربندی در حال اجرا نگه می‌دارد.
| فیلد | پیش‌فرض | معنا |
| -------------- | -------------- | ---- |
| `service` | `Google Drive` | `Google Drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). |
| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک)، `template` (هر API ذخیره‌سازی HTTP) یا `s3` (ذخیره‌ساز سازگار با S3). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. |
| `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. |
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. |
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. S3: ‏AccessKeyID، SecretAccessKey و در صورت نیاز SessionToken. |
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیره‌ساز. |
| `s3` | — | فقط برای سرویس s3: `endpoint` (یک URL از نوع http(s))، `region`، `bucket` و `pathStyle` (پیش‌فرض `true`؛ با `false` نام باکت در نام میزبان می‌آید). `remoteFolder` پیشوند اشیاء است. |
| تنظیمات دقیق | پیش‌فرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). |
**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
@@ -148,9 +154,10 @@ ALPN آن را می‌بیند.
- **ماسک‌های TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل
پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن
اختیاری‌اند).
اختیاری‌اند) و `filter` که فقط برای inbound است.
- **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
`noise`، `sudoku`، `realm` و `udphop` که فقط سمت کلاینت است. (`mkcp-legacy`
`noise`، `sudoku`، `realm`، `udphop` که فقط سمت کلاینت است و `filter` که فقط برای
inbound است. (`mkcp-legacy`
همان مبهم‌سازی قدیمی هدر mKCP را بازتولید می‌کند؛ `udphop` پورت مقصد — و در صورت
نیاز IP مقصد — را در فواصل زمانی یا به‌ازای هر اتصال عوض می‌کند تا مسدودسازی پورت
دور زده شود.)
@@ -161,6 +168,11 @@ ALPN آن را می‌بیند.
26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض می‌کند، برای همین پنل `quicParams.udpHop`
قدیمی کلاینت را به آن تبدیل می‌کند.
‏`filter` (Xray 26.10.21) اتصال‌های یک inbound را بر اساس IP مبدأ رد می‌کند:
`allowedSourceIPs` فقط مبدأهای فهرست‌شده را می‌پذیرد، `blockedSourceIPs` مبدأهایش را رد
می‌کند و تطابق با فهرست مسدود اولویت دارد. هر دو CIDR یا مدخل `geoip:` می‌پذیرند. لینک‌های
اشتراک و اشتراک JSON آن را حذف می‌کنند، چون فقط روی شنونده اثر دارد.
‏FinalMask جایگزین مبهم‌سازی `header`/`seed` به‌ازای هر انتقال می‌شود که بیلدهای
قدیمی‌تر Xray نمایش می‌دادند.
+1 -1
View File
@@ -31,7 +31,7 @@ SQLite گزینه پیش‌فرض است و برای بیشتر استقراره
3x-ui یک فورک ارتقایافته از پروژه اصلی X-UI است. این پروژه پشتیبانی گسترده‌تری از
پروتکل‌ها، پایداری بهبودیافته، حسابداری ترافیک به‌ازای هر کلاینت، مدیریت چندنودی،
یک رابط کاربری ۱۳ زبانه و بسیاری از امکانات رفاهی را اضافه می‌کند.
یک رابط کاربری ۱۴ زبانه و بسیاری از امکانات رفاهی را اضافه می‌کند.
## چگونه به‌روزرسانی کنم؟
@@ -38,7 +38,9 @@ Caddy گواهی‌ها را برای شما دریافت و تمدید می‌
```text title="Caddyfile"
panel.example.com {
reverse_proxy 127.0.0.1:2053
reverse_proxy 127.0.0.1:2053 {
header_up X-Real-IP {remote_host}
}
}
```
@@ -37,6 +37,11 @@ icon: Send
در تنظیمات پنل، ربات Telegram را فعال کنید و **توکن** و **شناسه‌(های) چت ادمین** را
(با کاما جداشده) تنظیم نمایید. ذخیره کنید، سپس به ربات خود پیام بدهید.
برای ارسال اعلان‌ها به یک تاپیک مشخص از یک سوپرگروه فروم، شناسه‌ی تاپیک را پس از
دونقطه اضافه کنید، مثلاً `-1001234567890:42`. شناسه‌ی تاپیک عدد بعد از چت در لینک یک
پیام تاپیک است (`https://t.me/c/1234567890/42/…`). برای تاپیک General آن را ننویسید
(یا `1` بگذارید).
</Step>
</Steps>
@@ -72,6 +77,8 @@ icon: Send
ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده،
بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها،
کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
کارت **کلاینت** در اختیار ادمین است و لینک اشتراک، لینک‌های منفرد و کد QR کلاینت را
نشان می‌دهد، تا کانفیگ را بتوان به کلاینتی که هنوز به Telegram دسترسی ندارد هم داد.
کاربران عادی دکمه‌هایی برای مصرف خودشان، لینک‌های اشتراک، لینک‌های منفرد و کدهای QR دارند.
## گزارش‌ها و هشدارها
@@ -101,7 +108,7 @@ icon: Send
| -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | روشن/خاموش اصلی. |
| `tgBotToken` | _(محرمانه)_ | توکن API ربات. |
| `tgBotChatId` | _(هیچ‌کدام)_ | شناسه‌های Telegram **ادمین**، جداشده با کاما. |
| `tgBotChatId` | _(هیچ‌کدام)_ | شناسه‌های Telegram **ادمین**، جداشده با کاما؛ `chatId:topicId` به یک تاپیک فروم می‌فرستد. |
| `tgBotProxy` | _(هیچ‌کدام)_ | پروکسی `socks5://`، `http://` یا `https://`. |
| `tgBotAPIServer` | _(پیش‌فرض)_ | سرور سفارشی Telegram Bot API. |
| `tgRunTime` | `@daily` | زمان‌بندی گزارش (cron / `@daily` / `@every …`). |
+6 -4
View File
@@ -135,10 +135,12 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не
обновляет старые отпечатки. Исходные ссылки `vless://` передают эквивалентную
подсказку `support-x25519mlkem768=true`; клиенты, поддерживающие это расширение
URI, включая актуальные сборки Mihomo, применяют её при импорте. Для очень старых
серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего узла
в клиенте или обновите сервер. Снятие ограничения версии не исправляет это
рукопожатие.
URI, включая актуальные сборки Mihomo, применяют её при импорте. Серверы на
Xray-core ниже 26.9.8 принимают обычный `X25519`, а очень старые отвергают
ML-KEM. Для них, а также если сеть отбрасывает крупный ClientHello с ML-KEM,
включите **Mihomo X25519** в хосте: его прокси в Clash получит
`support-x25519mlkem768: false`, а ссылка `vless://` — без подсказки. Снятие
ограничения версии не исправляет это рукопожатие.
</Callout>
+17 -4
View File
@@ -128,12 +128,18 @@ XDRIVE передаёт поток файлами в папке, доступн
**одинаковые** сервис, папку и секреты, поэтому JSON-подписка передаёт их каждому
клиенту; для XDRIVE-инбаундов не создаются ссылки и записи Clash.
Начиная с Xray 26.10.17 XDRIVE передаёт только **VLESS** внутри **шифрования VLESS** и
без flow: инбаунду нужно значение `decryption`, исходящему — `encryption` и включённый
Mux. Панель предлагает XDRIVE только для VLESS, не сохраняет инбаунд без `decryption` и
не включает в работающую конфигурацию инбаунд, сохранённый до обновления.
| Поле | По умолчанию | Значение |
| -------------- | -------------- | -------- |
| `service` | `Google Drive` | `Google Drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). |
| `service` | `google-drive` | `google-drive`, `local` (папка на диске), `template` (любой HTTP API хранилища) или `s3` (S3-совместимое хранилище). Старое написание `Google Drive` по-прежнему читается. |
| `remoteFolder` | — | Папка с файлами сессий. |
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey и при необходимости SessionToken. |
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
| `s3` | — | Только для s3: `endpoint` (URL http(s)), `region`, `bucket` и `pathStyle` (по умолчанию `true`; `false` переносит бакет в имя хоста). `remoteFolder` — префикс объектов. |
| тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
@@ -150,9 +156,10 @@ SNI, отпечаток uTLS и ALPN.
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток
под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
опциональны).
опциональны), а также только инбаундная `filter`.
- **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
`noise`, `sudoku`, `realm`, а также только клиентская `udphop`. (`mkcp-legacy`
`noise`, `sudoku`, `realm`, а также только клиентская `udphop` и только инбаундная
`filter`. (`mkcp-legacy`
воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт —
и при желании удалённый IP — по интервалу или для каждого соединения, чтобы
обходить блокировку портов.)
@@ -163,6 +170,12 @@ SNI, отпечаток uTLS и ALPN.
порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`,
поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё.
`filter` (Xray 26.10.21) отбрасывает соединения инбаунда по IP источника:
`allowedSourceIPs` пропускает только перечисленные источники, `blockedSourceIPs`
отбрасывает свои, а совпадение с блок-листом имеет приоритет. Оба принимают CIDR или
записи `geoip:`. Ссылки и JSON-подписки её не содержат, ведь она действует только на
слушающей стороне.
FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта,
которую предоставляли старые сборки Xray.
+5 -5
View File
@@ -9,7 +9,7 @@ icon: Zap
и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
<Callout type="info">
TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и горячего обновления пользователей без обрыва соединений.
TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и управления клиентами в памяти.
</Callout>
## Ключевые параметры
@@ -54,7 +54,7 @@ icon: Zap
<Step>
### Добавьте клиентов
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Панель автоматически генерирует надёжные случайные данные при создании клиента.
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Идентификатор Email для TUIC должен занимать 1–255 байт UTF-8. Старые клиенты с некорректными идентификаторами исключаются из обслуживания TUIC с предупреждением; для восстановления доступа сократите идентификатор. Панель автоматически генерирует надёжные случайные данные при создании клиента.
</Step>
<Step>
@@ -102,10 +102,10 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
<Callout type="info">
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Идентификатор **Email** клиента передаётся в правила `user` для TCP и обоих режимов UDP. Поддерживаются теги инбаундов (`in-<port>-udp`), правила по доменам/IP и каскадирование через любой outbound. Ставьте специфичные правила `inboundTag` + `user` выше общих правил `user`: Xray выбирает первое совпадение.
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay. Отдельная policy отключает в relay пользовательские счётчики трафика и учёт loopback-IP: источником клиентского трафика, скорости и активности остаётся нативный TUIC.
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
- **Обновление клиентов**: Включённость, квоты и срок проверяет нативный TUIC; сброс квоты восстанавливает доступ без перезапуска Xray. Добавление, удаление или переименование клиента заменяет только внутренний SOCKS relay его inbound. Аккаунты проверяются до замены и повторно сверяются с работающим relay после неё. При ошибке восстанавливается прежний relay и планируется повторная попытка. Ошибки изолированы в группе связанных relay: исправные TUIC inbound и независимые изменения Xray продолжают применяться. Изменения маршрутизации, зависящие от неудачной смены тега, ожидают восстановления этого relay. Связанные изменения клиентов и inbound тоже откладываются; новые TUIC-сессии допускаются после применения их правил маршрутизации. Новые relay-соединения ожидают до 10 секунд во время замены или отката; открытые TCP-потоки и UDP-ассоциации продолжают работать. Пакеты новых UDP-ассоциаций помещаются в ограниченную очередь, не блокируя действующие потоки того же QUIC-соединения. При отключении клиента ожидание отменяется. Переполнение очереди или недоступность relay всё ещё могут привести к отказу нового запроса; остальные протоколы продолжают работать. Изменение только TUIC-пароля или параметров клиентского профиля не меняет relay-аккаунты.
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
</Callout>
+1 -1
View File
@@ -31,7 +31,7 @@ SQLite используется по умолчанию и подходит дл
3x-ui — это улучшенный форк оригинального проекта X-UI. Он добавляет более широкую поддержку
протоколов, повышенную стабильность, поклиентский учёт трафика, управление несколькими
узлами, интерфейс на 13 языках и множество улучшений для удобства работы.
узлами, интерфейс на 14 языках и множество улучшений для удобства работы.
## Как мне обновиться?
@@ -39,7 +39,9 @@ Caddy получает и продлевает сертификаты за ва
```text title="Caddyfile"
panel.example.com {
reverse_proxy 127.0.0.1:2053
reverse_proxy 127.0.0.1:2053 {
header_up X-Real-IP {remote_host}
}
}
```
@@ -39,6 +39,11 @@ icon: Send
В настройках панели включите Telegram-бота и задайте **токен** и **админские
chat ID** (через запятую). Сохраните, затем напишите своему боту.
Чтобы отправлять уведомления в определённую тему форумной супергруппы, добавьте
ID темы после двоеточия, например `-1001234567890:42`. ID темы — число после чата
в ссылке на сообщение темы (`https://t.me/c/1234567890/42/…`). Для темы General
не указывайте его (или укажите `1`).
</Step>
</Steps>
@@ -75,9 +80,11 @@ chat ID** (через запятую). Сохраните, затем напиш
Администраторам также доступны сценарии со встроенными кнопками: использование
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
«скоро исчерпают» и полноценный мастер **добавления клиента**. Обычным
пользователям доступны кнопки для своего расхода, ссылок на подписку, отдельных
ссылок и QR-кодов.
«скоро исчерпают» и полноценный мастер **добавления клиента**. В карточке
клиента администратор получает ссылку на подписку, отдельные ссылки и QR-код —
конфигурацию можно передать клиенту, который ещё не может открыть Telegram.
Обычным пользователям доступны кнопки для своего расхода, ссылок на подписку,
отдельных ссылок и QR-кодов.
## Отчёты и оповещения
@@ -108,7 +115,7 @@ chat ID** (через запятую). Сохраните, затем напиш
| -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | Главный переключатель вкл./выкл. |
| `tgBotToken` | _(секрет)_ | Токен Bot API. |
| `tgBotChatId` | _(нет)_ | **Админские** Telegram ID через запятую. |
| `tgBotChatId` | _(нет)_ | **Админские** Telegram ID через запятую; `chatId:topicId` — в тему форума. |
| `tgBotProxy` | _(нет)_ | Прокси `socks5://`, `http://` или `https://`. |
| `tgBotAPIServer` | _(по умолчанию)_ | Пользовательский сервер Telegram Bot API. |
| `tgRunTime` | `@daily` | Расписание отчётов (cron / `@daily` / `@every …`).|
+1 -1
View File
@@ -106,7 +106,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
- **私钥泄露。** 永远只把**公钥**分发给客户端。
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
- **客户端版本限制。** Xray-core v26.9.8+ 在**最小客户端版本**留空时不再设置默认下限,但已明确保存的限制仍生效。较早的内核可能使用内置下限(如 `26.3.27`),导致第三方客户端即使密钥正确也被拒绝。修改前先核对运行中的内核版本;降低限制也会放行较旧的指纹。
- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 Mihomo 版本)会在导入时应用它。对拒绝 ML-KEM 的很旧的 REALITY 服务端,需在客户端按节点将此项覆写为 `false`,或升级服务端。仅清空版本限制无法解决握手问题。
- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 Mihomo 版本)会在导入时应用它。Xray-core 26.9.8 以下的服务端接受普通 `X25519` 握手,很旧的服务端则会拒绝 ML-KEM。对这些服务端,或网络会丢弃较大的 ML-KEM ClientHello 时,可在主机(Host)上启用 **Mihomo X25519**:该主机的 Clash 代理会得到 `support-x25519mlkem768: false`,`vless://` 链接也不再携带该提示。仅清空版本限制无法解决握手问题。
</Callout>
+12 -4
View File
@@ -119,12 +119,16 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服务端轮询该文件夹,而不是在端口上接受连接。
双方必须使用**相同**的服务、文件夹和密钥,因此 JSON 订阅会把它们下发给每个客户端;XDRIVE 入站不生成分享链接或 Clash 条目。
自 Xray 26.10.17 起,XDRIVE 只在 **VLESS 加密**内承载 **VLESS**,且不支持 flow:入站需要设置 `decryption`,
出站需要 `encryption` 并启用 Mux。面板只为 VLESS 提供 XDRIVE,拒绝保存没有 `decryption` 的入站,并把升级前保存的此类入站排除在运行配置之外。
| 字段 | 默认值 | 含义 |
| -------------- | -------------- | ---- |
| `service` | `Google Drive` | `Google Drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。 |
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)、`template`(任意 HTTP 存储 API)或 `s3`(S3 兼容存储)。旧写法 `Google Drive` 仍可读取。 |
| `remoteFolder` | — | 存放会话文件的文件夹。 |
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。S3:AccessKeyID、SecretAccessKey,以及可选的 SessionToken。 |
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
| `s3` | — | 仅用于 s3 服务:`endpoint`(http(s) URL)、`region`、`bucket` 和 `pathStyle`(默认 `true`;为 `false` 时存储桶名放在主机名中)。`remoteFolder` 为对象前缀。 |
| 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
@@ -137,9 +141,9 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的
传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
Minecraft 协议;密码必填,主机名和玩家用户名可选)。
Minecraft 协议;密码必填,主机名和玩家用户名可选),以及仅限入站的 `filter`。
- **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
`noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop`。(`mkcp-legacy` 重现旧版
`noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop` 和仅限入站的 `filter`。(`mkcp-legacy` 重现旧版
mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避
端口封锁。)
- **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
@@ -148,6 +152,10 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的
26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的
`quicParams.udpHop` 转换为该掩码。
`filter`(Xray 26.10.21)按源 IP 丢弃入站连接:`allowedSourceIPs` 只放行所列来源,
`blockedSourceIPs` 丢弃所列来源,命中封锁列表优先。两者都接受 CIDR 或 `geoip:` 条目。
分享链接和 JSON 订阅会去掉它,因为它只作用于监听端。
FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。
## sockopt — 底层套接字选项
+1 -1
View File
@@ -30,7 +30,7 @@ SQLite 是默认选项,适用于大多数部署场景。对于规模更大的
## 3x-ui 与 x-ui 有什么区别?
3x-ui 是原版 X-UI 项目的增强分支。它增加了更广泛的协议支持、更佳的稳定性、
按客户端的流量统计、多节点管理、13 种语言的界面,以及众多提升使用体验的功能。
按客户端的流量统计、多节点管理、14 种语言的界面,以及众多提升使用体验的功能。
## 我该如何更新?
@@ -33,7 +33,9 @@ Caddy 会为你获取并续期证书——只需让 Caddyfile 指向面板,它
```text title="Caddyfile"
panel.example.com {
reverse_proxy 127.0.0.1:2053
reverse_proxy 127.0.0.1:2053 {
header_up X-Real-IP {remote_host}
}
}
```
@@ -36,6 +36,10 @@ icon: Send
在面板设置中启用 Telegram 机器人,并填写**令牌**和**管理员聊天 ID**
(多个时以逗号分隔)。保存后,再向你的机器人发送消息。
要把通知发送到论坛超级群组的某个话题,在聊天 ID 后加冒号和话题 ID,例如
`-1001234567890:42`。话题 ID 是话题消息链接中聊天之后的数字
(`https://t.me/c/1234567890/42/…`)。General 话题不填(或填 `1`)。
</Step>
</Steps>
@@ -67,7 +71,9 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看
“即将耗尽”,以及完整的**添加客户端**向导。管理员的**客户端卡片**上还带有该客户端的
订阅链接、单条链接和 QR 码,因此可以把配置交给暂时还无法打开 Telegram 的客户端。
普通用户则可以使用按钮查看
自己的用量、订阅链接、单条链接和 QR 码。
## 报告与告警
@@ -97,7 +103,7 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
| -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | 总开关。 |
| `tgBotToken` | _(机密)_ | 机器人 API 令牌。 |
| `tgBotChatId` | _(无)_ | 以逗号分隔的**管理员** Telegram ID。 |
| `tgBotChatId` | _(无)_ | 以逗号分隔的**管理员** Telegram ID;`chatId:topicId` 发送到论坛话题。 |
| `tgBotProxy` | _(无)_ | `socks5://`、`http://` 或 `https://` 代理。 |
| `tgBotAPIServer` | _(默认)_ | 自定义 Telegram Bot API 服务器。 |
| `tgRunTime` | `@daily` | 报告计划(cron / `@daily` / `@every …`)。 |
+9
View File
@@ -31,6 +31,15 @@ describe('buildProxyConfig — caddy', () => {
const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath: '/' });
expect(cfg).toContain('reverse_proxy 127.0.0.1:2053');
});
// Caddy forwards a client-sent X-Real-IP unchanged, and the panel's login
// limiter would key on it; only an overwrite gives the address Caddy saw.
it('overwrites X-Real-IP with the address Caddy saw', () => {
for (const panelPath of ['/', '/panel']) {
const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath });
expect(cfg).toContain('header_up X-Real-IP {remote_host}');
}
});
});
describe('buildCertCommand', () => {
+5 -7
View File
@@ -49,14 +49,12 @@ function buildNginx(o: ReverseProxyOptions): string {
function buildCaddy(o: ReverseProxyOptions): string {
const path = normalizePath(o.panelPath);
if (path === '/') {
return `${o.domain} {
reverse_proxy 127.0.0.1:${o.panelPort}
}`;
}
const matcher = `${path.replace(/\/$/, '')}/*`;
const matcher = path === '/' ? '' : `${path.replace(/\/$/, '')}/* `;
// Caddy forwards a client-sent X-Real-IP as is; the panel's login limiter reads it.
return `${o.domain} {
reverse_proxy ${matcher} 127.0.0.1:${o.panelPort}
reverse_proxy ${matcher}127.0.0.1:${o.panelPort} {
header_up X-Real-IP {remote_host}
}
}`;
}
+40 -2
View File
@@ -32,18 +32,47 @@ describe('validateBotToken', () => {
describe('parseAdminIds', () => {
it('splits a comma list into integer ids', () => {
expect(parseAdminIds('111, 222')).toEqual({ ids: [111, 222], invalid: [] });
expect(parseAdminIds('111, 222')).toEqual({
ids: [111, 222],
entries: ['111', '222'],
invalid: [],
});
});
it('accepts negative group ids and captures invalid entries', () => {
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({
ids: [-1001234567, 42],
entries: ['-1001234567', '42'],
invalid: ['abc'],
});
});
it('drops the General topic suffix, since the Bot API rejects thread id 1', () => {
expect(parseAdminIds('-1001234567:1')).toEqual({
ids: [-1001234567],
entries: ['-1001234567'],
invalid: [],
});
});
it('rejects a second topic for the same chat, as the panel does', () => {
expect(parseAdminIds('-1001:7, -1001:1, -1001:8')).toEqual({
ids: [-1001],
entries: ['-1001:7'],
invalid: ['-1001:8'],
});
});
it('keeps a forum topic suffix and rejects a malformed one', () => {
expect(parseAdminIds('-1001234567:42, 111:0, 222:x')).toEqual({
ids: [-1001234567],
entries: ['-1001234567:42'],
invalid: ['111:0', '222:x'],
});
});
it('returns empty for blank input', () => {
expect(parseAdminIds(' ')).toEqual({ ids: [], invalid: [] });
expect(parseAdminIds(' ')).toEqual({ ids: [], entries: [], invalid: [] });
});
});
@@ -103,4 +132,13 @@ describe('buildBotConfigSummary', () => {
expect(s.tgBotChatId).toBe('111,222');
expect(s.tgRunTime).toBe('@daily');
});
it('keeps forum topic suffixes in the chat id setting', () => {
const s = buildBotConfigSummary({
token: VALID_TOKEN,
adminIds: '111, -1001234567 : 42',
runTime: '@daily',
});
expect(s.tgBotChatId).toBe('111,-1001234567:42');
});
});
+22 -9
View File
@@ -1,6 +1,6 @@
// Pure validation + templating helpers for 3x-ui's Telegram bot settings.
// Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed with
// strconv.ParseInt(_,10,64); token handed to telego.NewBot → api.telegram.org)
// Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed by
// parseAdminChats as "chatId[:topicId]"; token handed to telego.NewBot → api.telegram.org)
// and the panel's tg* settings (tgRunTime uses robfig/cron). No React/DOM.
export interface BotConfig {
@@ -17,6 +17,7 @@ export interface TokenValidation {
export interface AdminIdsResult {
ids: number[];
entries: string[]; // normalized "chatId" or "chatId:topicId", as the panel stores them
invalid: string[];
}
@@ -41,17 +42,29 @@ export function validateBotToken(token: string): TokenValidation {
}
export function parseAdminIds(raw: string): AdminIdsResult {
const ids: number[] = [];
// Mirrors parseAdminChats in tgbot.go: one topic per chat, repeats collapse.
const topics = new Map<string, number>();
const invalid: string[] = [];
for (const part of raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
// Telegram chat ids are integers; group/channel ids are negative.
if (/^-?\d+$/.test(part)) ids.push(Number(part));
else invalid.push(part);
// Telegram chat ids are integers; group/channel ids are negative. An
// optional ":<topicId>" targets a forum topic inside a supergroup.
const m = /^(-?\d+)\s*(?::\s*(\d+))?$/.exec(part);
if (!m || (m[2] !== undefined && Number(m[2]) <= 0)) {
invalid.push(part);
continue;
}
// General (thread 1) is reached by omitting the topic; the Bot API rejects 1.
const topic = m[2] === undefined || Number(m[2]) === 1 ? 0 : Number(m[2]);
const prev = topics.get(m[1]) ?? 0;
if (topic && prev && prev !== topic) invalid.push(part);
else topics.set(m[1], topic || prev);
}
return { ids, invalid };
const ids = [...topics.keys()].map(Number);
const entries = [...topics].map(([chat, topic]) => (topic ? `${chat}:${topic}` : chat));
return { ids, entries, invalid };
}
// robfig/cron predefined macros (note: @reboot is NOT supported).
@@ -108,11 +121,11 @@ export function buildBotConfigSummary(c: BotConfig): {
tgBotChatId: string;
tgRunTime: string;
} {
const { ids } = parseAdminIds(c.adminIds);
const { entries } = parseAdminIds(c.adminIds);
return {
tgBotEnable: true,
tgBotToken: c.token.trim(),
tgBotChatId: ids.join(','),
tgBotChatId: entries.join(','),
tgRunTime: c.runTime.trim(),
};
}
+17 -17
View File
@@ -18,34 +18,34 @@
"test:watch": "vitest"
},
"dependencies": {
"fumadocs-core": "^16.16.2",
"fumadocs-docgen": "^3.1.1",
"fumadocs-mdx": "^15.4.6",
"fumadocs-openapi": "^12.3.0",
"fumadocs-ui": "^16.16.2",
"lucide-react": "^1.52.0",
"mermaid": "^12.1.0",
"next": "16.3.8",
"fumadocs-core": "^16.17.9",
"fumadocs-docgen": "^3.2.0",
"fumadocs-mdx": "^15.4.10",
"fumadocs-openapi": "^12.5.7",
"fumadocs-ui": "^16.17.9",
"lucide-react": "^1.66.0",
"mermaid": "^12.2.0",
"next": "16.4.3",
"next-themes": "^0.4.6",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-qr-code": "^2.2.0",
"tailwind-merge": "^3.7.0",
"zbsearch": "4.0.1",
"zod": "^4.6.5"
"zod": "^4.6.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14",
"@types/node": "^26.6.4",
"@types/node": "^26.6.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"postcss": "^8.5.29",
"oxfmt": "0.75.0",
"oxlint": "1.90.0",
"postcss": "^8.5.32",
"tailwindcss": "^4.3.3",
"typescript": "7.0.2",
"vitest": "^5.0.3"
"vitest": "^5.0.5"
},
"packageManager": "pnpm@12.9.1"
"packageManager": "pnpm@12.16.1"
}
+1161 -1477
View File
File diff suppressed because it is too large Load Diff
+15 -42
View File
@@ -7,46 +7,19 @@ allowBuilds:
overrides:
'postcss@<8.5.10': '^8.5.15'
'sharp@<0.35.0': '^0.35.3'
# @tailwindcss/node 4.3.3 pins 1.32.0, which rejects fumadocs-ui's ::highlight(fd-search).
'lightningcss@<1.33.0': '^1.33.0'
minimumReleaseAgeExcludePrune: true
minimumReleaseAgeExclude:
- fumadocs-core@16.16.2
- fumadocs-ui@16.16.2
- '@oxfmt/binding-android-arm-eabi@0.72.0'
- '@oxfmt/binding-android-arm64@0.72.0'
- '@oxfmt/binding-darwin-arm64@0.72.0'
- '@oxfmt/binding-darwin-x64@0.72.0'
- '@oxfmt/binding-freebsd-x64@0.72.0'
- '@oxfmt/binding-linux-arm-gnueabihf@0.72.0'
- '@oxfmt/binding-linux-arm-musleabihf@0.72.0'
- '@oxfmt/binding-linux-arm64-gnu@0.72.0'
- '@oxfmt/binding-linux-arm64-musl@0.72.0'
- '@oxfmt/binding-linux-ppc64-gnu@0.72.0'
- '@oxfmt/binding-linux-riscv64-gnu@0.72.0'
- '@oxfmt/binding-linux-riscv64-musl@0.72.0'
- '@oxfmt/binding-linux-s390x-gnu@0.72.0'
- '@oxfmt/binding-linux-x64-gnu@0.72.0'
- '@oxfmt/binding-linux-x64-musl@0.72.0'
- '@oxfmt/binding-openharmony-arm64@0.72.0'
- '@oxfmt/binding-win32-arm64-msvc@0.72.0'
- '@oxfmt/binding-win32-ia32-msvc@0.72.0'
- '@oxfmt/binding-win32-x64-msvc@0.72.0'
- '@oxlint/binding-android-arm-eabi@1.87.0'
- '@oxlint/binding-android-arm64@1.87.0'
- '@oxlint/binding-darwin-arm64@1.87.0'
- '@oxlint/binding-darwin-x64@1.87.0'
- '@oxlint/binding-freebsd-x64@1.87.0'
- '@oxlint/binding-linux-arm-gnueabihf@1.87.0'
- '@oxlint/binding-linux-arm-musleabihf@1.87.0'
- '@oxlint/binding-linux-arm64-gnu@1.87.0'
- '@oxlint/binding-linux-arm64-musl@1.87.0'
- '@oxlint/binding-linux-ppc64-gnu@1.87.0'
- '@oxlint/binding-linux-riscv64-gnu@1.87.0'
- '@oxlint/binding-linux-riscv64-musl@1.87.0'
- '@oxlint/binding-linux-s390x-gnu@1.87.0'
- '@oxlint/binding-linux-x64-gnu@1.87.0'
- '@oxlint/binding-linux-x64-musl@1.87.0'
- '@oxlint/binding-openharmony-arm64@1.87.0'
- '@oxlint/binding-win32-arm64-msvc@1.87.0'
- '@oxlint/binding-win32-ia32-msvc@1.87.0'
- '@oxlint/binding-win32-x64-msvc@1.87.0'
- oxfmt@0.72.0
- oxlint@1.87.0
- '@fumadocs/json-schema@0.3.5'
- '@next/env@16.4.3'
- '@next/swc-darwin-arm64@16.4.3'
- '@next/swc-darwin-x64@16.4.3'
- '@next/swc-linux-arm64-gnu@16.4.3'
- '@next/swc-linux-arm64-musl@16.4.3'
- '@next/swc-linux-x64-gnu@16.4.3'
- '@next/swc-linux-x64-musl@16.4.3'
- '@next/swc-win32-arm64-msvc@16.4.3'
- '@next/swc-win32-x64-msvc@16.4.3'
- fumadocs-mdx@15.4.10
- next@16.4.3
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+527 -25
View File
@@ -3,7 +3,7 @@
"info": {
"title": "3X-UI Panel API",
"version": "3.x",
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password."
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
},
"servers": [
{
@@ -16,7 +16,7 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
"description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
},
"cookieAuth": {
"type": "apiKey",
@@ -152,6 +152,11 @@
"ldapVlessField": {
"type": "string"
},
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
@@ -242,6 +247,9 @@
"subClashPath": {
"type": "string"
},
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": {
"type": "string"
},
@@ -342,6 +350,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -636,6 +648,7 @@
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
@@ -662,6 +675,7 @@
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRemoteDNS",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
@@ -695,6 +709,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -928,6 +943,11 @@
"ldapVlessField": {
"type": "string"
},
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
@@ -1018,6 +1038,9 @@
"subClashPath": {
"type": "string"
},
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": {
"type": "string"
},
@@ -1118,6 +1141,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -1420,6 +1447,7 @@
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
@@ -1446,6 +1474,7 @@
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRemoteDNS",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
@@ -1479,6 +1508,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -1673,6 +1703,23 @@
],
"type": "object"
},
"Blocker": {
"description": "Blocker is one unmet requirement.",
"properties": {
"code": {
"example": "tools",
"type": "string"
},
"detail": {
"example": "awg",
"type": "string"
}
},
"required": [
"code"
],
"type": "object"
},
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
@@ -1700,6 +1747,17 @@
"description": "Auth password (Hysteria)",
"type": "string"
},
"clientHostRuleIds": {
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"description": "Client comment",
"type": "string"
@@ -1727,7 +1785,8 @@
"type": "string"
},
"forwardedPorts": {
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
"nullable": true,
"type": "string"
},
"group": {
@@ -1739,9 +1798,15 @@
"type": "string"
},
"keepAlive": {
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
"nullable": true,
"type": "integer"
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitIp": {
"description": "IP limit for this client",
@@ -1931,6 +1996,16 @@
"auth": {
"type": "string"
},
"clientHostRuleIds": {
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"type": "string"
},
@@ -1961,7 +2036,15 @@
"type": "integer"
},
"keepAlive": {
"type": "integer"
"description": "Numeric values or AmneziaWG ranges.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitHwid": {
"type": "integer"
@@ -2738,6 +2821,10 @@
"minimum": 0,
"type": "integer"
},
"randomCount": {
"example": 1,
"type": "integer"
},
"remark": {
"example": "cdn-front",
"maxLength": 256,
@@ -2763,6 +2850,15 @@
"sni": {
"type": "string"
},
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": {},
"sortOrder": {
"type": "integer"
@@ -2810,11 +2906,13 @@
"path",
"pinnedPeerCertSha256",
"port",
"randomCount",
"remark",
"security",
"serverDescription",
"shuffleHost",
"sni",
"sniSharedRandom",
"sockoptParams",
"sortOrder",
"tags",
@@ -2919,6 +3017,11 @@
"minimum": 0,
"type": "integer"
},
"randomCount": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"remark": {
"maxLength": 256,
"type": "string"
@@ -2942,6 +3045,15 @@
"sni": {
"type": "string"
},
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": {
"type": "string"
},
@@ -2984,11 +3096,14 @@
"path",
"pinnedPeerCertSha256",
"port",
"randomCount",
"remark",
"security",
"serverDescription",
"shuffleHost",
"sni",
"sniSharedRandom",
"snis",
"sockoptParams",
"sortOrder",
"tags",
@@ -3664,6 +3779,13 @@
"transitive": {
"type": "boolean"
},
"unavailableMetrics": {
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": {
"example": 1700000000,
"format": "int64",
@@ -3972,6 +4094,12 @@
"example": false,
"type": "boolean"
},
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": {
"example": 1700003600,
"format": "int64",
@@ -4184,6 +4312,12 @@
"example": "online",
"type": "string"
},
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"uptimeSecs": {
"example": 86400,
"format": "int64",
@@ -4215,6 +4349,37 @@
],
"type": "object"
},
"Readiness": {
"description": "Readiness is the host's ability to run any kernel-mode inbound at all.",
"properties": {
"blockers": {
"example": [
{
"code": "tools",
"detail": "awg"
}
],
"items": {
"$ref": "#/components/schemas/Blocker"
},
"type": "array"
},
"installCommand": {
"example": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"type": "string"
},
"ready": {
"example": false,
"type": "boolean"
}
},
"required": [
"blockers",
"installCommand",
"ready"
],
"type": "object"
},
"RealityScanResult": {
"properties": {
"alpn": {
@@ -4397,6 +4562,10 @@
"keepaliveTimeout": {
"type": "string"
},
"kernelMode": {
"description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.",
"type": "boolean"
},
"maxHandshakeAttempts": {
"type": "string"
},
@@ -4651,6 +4820,35 @@
],
"type": "object"
},
"TgBotCapabilities": {
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
"properties": {
"groupPrivacy": {
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
"example": false,
"type": "boolean"
},
"inlineEnabled": {
"example": true,
"type": "boolean"
},
"running": {
"example": true,
"type": "boolean"
},
"username": {
"example": "xui_bot",
"type": "string"
}
},
"required": [
"groupPrivacy",
"inlineEnabled",
"running",
"username"
],
"type": "object"
},
"Traffic": {
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
"properties": {
@@ -4855,7 +5053,7 @@
},
{
"name": "API Tokens",
"description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
"description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
},
{
"name": "Xray Settings",
@@ -5319,6 +5517,51 @@
}
}
},
"/panel/api/inbounds/amneziawgKernelReadiness": {
"get": {
"tags": [
"Inbounds"
],
"summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.",
"operationId": "get_panel_api_inbounds_amneziawgKernelReadiness",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/Readiness"
}
}
},
"example": {
"success": true,
"obj": {
"blockers": [
{
"code": "tools",
"detail": "awg"
}
],
"installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"ready": false
}
}
}
}
}
}
}
},
"/panel/api/inbounds/allLinks": {
"get": {
"tags": [
@@ -5408,7 +5651,7 @@
"tags": [
"Inbounds"
],
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).",
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.",
"operationId": "post_panel_api_inbounds_add",
"requestBody": {
"required": true,
@@ -5432,8 +5675,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -5604,7 +5846,7 @@
"tags": [
"Inbounds"
],
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.",
"operationId": "post_panel_api_inbounds_update_id",
"parameters": [
{
@@ -5639,8 +5881,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -5915,7 +6156,7 @@
"tags": [
"Inbounds"
],
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field.",
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field. VLESS fallback validation is identical to /add.",
"operationId": "post_panel_api_inbounds_import",
"requestBody": {
"required": true,
@@ -6089,7 +6330,7 @@
"tags": [
"Inbounds"
],
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.",
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.",
"operationId": "post_panel_api_inbounds_id_fallbacks",
"parameters": [
{
@@ -7184,7 +7425,7 @@
"tags": [
"Server"
],
"summary": "Self-update the panel to the latest version. The server restarts on success.",
"summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
"operationId": "post_panel_api_server_updatePanel",
"requestBody": {
"required": false,
@@ -8481,6 +8722,81 @@
}
}
},
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch clients by subscription ID.",
"operationId": "get_panel_api_clients_get_subId_subId",
"description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.",
"parameters": [
{
"name": "subId",
"in": "path",
"required": true,
"description": "Exact subscription ID (URL-encoded).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"client": {
"$ref": "#/components/schemas/ClientRecord"
},
"inboundIds": {
"type": "array",
"items": {
"type": "integer"
}
},
"externalLinks": {
"type": "array",
"items": {
"type": "object"
}
},
"usedTraffic": {
"type": "integer",
"format": "int64"
},
"tunnelAllowedIPs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/tgId/{tgId}": {
"get": {
"tags": [
@@ -8529,7 +8845,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -9846,7 +10162,7 @@
"tags": [
"Clients"
],
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
"operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": {
"required": true,
@@ -10187,7 +10503,7 @@
"tags": [
"Clients"
],
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
"operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [
{
@@ -10983,6 +11299,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
@@ -11184,6 +11503,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
@@ -11334,6 +11656,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
@@ -11557,6 +11882,9 @@
"memPct": 45.2,
"panelVersion": "v3.x.x",
"status": "online",
"unavailableMetrics": [
""
],
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
@@ -11909,11 +12237,16 @@
""
],
"port": 0,
"randomCount": 1,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "",
"sortOrder": 0,
"tags": [
@@ -11930,6 +12263,98 @@
}
}
},
"/panel/api/hosts/raw": {
"get": {
"tags": [
"Hosts"
],
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
"operationId": "get_panel_api_hosts_raw",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"address": "cdn.example.com",
"allowInsecure": false,
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"groupId": "",
"hostHeader": "",
"id": 1,
"inboundId": 1,
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": null,
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
""
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": "443"
}
]
}
}
}
}
}
}
},
"/panel/api/hosts/get/{groupId}": {
"get": {
"tags": [
@@ -12004,11 +12429,16 @@
""
],
"port": 0,
"randomCount": 1,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "",
"sortOrder": 0,
"tags": [
@@ -12102,11 +12532,16 @@
""
],
"port": 0,
"randomCount": 1,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "",
"sortOrder": 0,
"tags": [
@@ -12250,11 +12685,16 @@
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
@@ -12372,11 +12812,16 @@
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
@@ -12494,7 +12939,7 @@
"tags": [
"Hosts"
],
"summary": "Set host group sort order by the position of each groupId in the array.",
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
"operationId": "post_panel_api_hosts_reorder",
"requestBody": {
"required": true,
@@ -12624,11 +13069,16 @@
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
@@ -13115,6 +13565,47 @@
}
}
},
"/panel/api/setting/tgBotCapabilities": {
"post": {
"tags": [
"Settings"
],
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
"operationId": "post_panel_api_setting_tgBotCapabilities",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/TgBotCapabilities"
}
}
},
"example": {
"success": true,
"obj": {
"groupPrivacy": false,
"inlineEnabled": true,
"running": true,
"username": "xui_bot"
}
}
}
}
}
}
}
},
"/panel/api/setting/testDiscord": {
"post": {
"tags": [
@@ -13208,9 +13699,11 @@
"obj": [
{
"id": 1,
"name": "default",
"name": "central-panel-a",
"enabled": true,
"createdAt": 1736000000
"createdAt": 1736000000,
"scope": "node-admin",
"expiresAt": 0
}
]
}
@@ -13240,7 +13733,13 @@
},
"scope": {
"type": "string",
"description": "admin (default), monitor, or node-sync."
"enum": [
"admin",
"monitor",
"node-sync",
"node-admin"
],
"description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
},
"expiresAt": {
"type": "integer",
@@ -13253,7 +13752,7 @@
},
"example": {
"name": "central-panel-a",
"scope": "node-sync",
"scope": "node-admin",
"expiresAt": 1798761600000
}
}
@@ -16387,6 +16886,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
+6
View File
@@ -86,6 +86,12 @@ fields) is pushed to child nodes verbatim, so the node's Xray records the real I
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
shows up on the parent automatically.
Reports retain each address's newest timestamp within the 30-minute retention window.
Flat and per-node IP blobs use a stable timestamp-ascending order, with IP text breaking
ties, so equivalent reports preserve the same bytes and skip redundant writes. Flat-row
merges use compare-and-set retries to preserve concurrent observations; advancing a
timestamp remains a real change. GUID attribution stays separate from the flat union.
## Security note
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
+32 -2
View File
@@ -9,7 +9,10 @@
"jsx-a11y"
],
"jsPlugins": [
"./tools/oxlint/input-number-guard.mjs"
"./tools/oxlint/input-number-guard.mjs",
"./tools/oxlint/control-label-guard.mjs",
"./tools/oxlint/icon-a11y-guard.mjs",
"./tools/oxlint/antd-a11y-guard.mjs"
],
"categories": {
"correctness": "error"
@@ -48,7 +51,11 @@
"react-hooks/exhaustive-deps": "error",
"jsx-a11y/no-autofocus": "off",
"input-number/no-synthetic-clear": "off",
"jsx-a11y/prefer-tag-over-role": "off"
"jsx-a11y/prefer-tag-over-role": "off",
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off",
"antd-a11y/spin-content-not-live": "off",
"antd-a11y/select-tag-remove": "off"
},
"overrides": [
{
@@ -67,6 +74,29 @@
"rules": {
"input-number/no-synthetic-clear": "off"
}
},
{
"files": [
"src/**/*.tsx"
],
"rules": {
"control-label/has-name": "error",
"antd-icon/explicit-a11y": "error",
"antd-a11y/spin-content-not-live": "error",
"antd-a11y/select-tag-remove": "error"
}
},
{
"files": [
"src/**/*.stories.tsx",
"src/test/**"
],
"rules": {
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off",
"antd-a11y/spin-content-not-live": "off",
"antd-a11y/select-tag-remove": "off"
}
}
]
}
+2
View File
@@ -15,6 +15,8 @@ const config: StorybookConfig = {
if (viteConfig.build) {
viteConfig.build.outDir = undefined;
viteConfig.build.emptyOutDir = false;
// Storybook bundles the whole UI plus the axe engine; hold it to the app's chunk ceiling.
viteConfig.build.chunkSizeWarningLimit = 1500;
if (viteConfig.build.rollupOptions) {
viteConfig.build.rollupOptions.input = undefined;
}
+2 -1
View File
@@ -5,6 +5,7 @@ import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { buildAntdThemeConfig } from '@/hooks/useTheme';
import { INTERPOLATION } from '@/i18n/react';
import enUS from '../../internal/web/translation/en-US.json';
if (!i18next.isInitialized) {
@@ -12,7 +13,7 @@ if (!i18next.isInitialized) {
lng: 'en-US',
fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
interpolation: INTERPOLATION,
returnNull: false,
});
}
+9 -6
View File
@@ -33,11 +33,12 @@ The `@` import alias maps to `src/`.
- Function components + hooks only; no class components.
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` +
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
validate through the `zodResolver` or per-field
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with React Hook
Form's `useForm` + `FormField` from `@/components/form/rhf` (wrap the tree in
`FormProvider`) and validate each field with
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays
issue keys resolved via `t()`, never inline `z.string()`. A whole-form
`zodResolver` is only for a form that submits one schema. AntD `<Form>` stays
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
`src/lib/xray/forms/fields/`, bound via a `Controller`.
@@ -54,8 +55,10 @@ The `@` import alias maps to `src/`.
## Adding a panel route
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
2. Register in `src/routes.tsx` under `/panel` (lazy import).
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
2. Add it to `src/routes/panelRoutes.tsx`. The router, the sidebar, the page
title and the command palette all read that one list;
`src/test/navigation-contract.test.tsx` holds it to the router and to the
translation bundle.
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
+23 -20
View File
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
| Command | What |
|---|---|
| `npm run dev` | Vite dev server with API + WS proxy to Go |
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
| `npm run build` | Regenerates the OpenAPI spec, then builds into `../internal/web/dist/` |
| `npm run preview` | Serve the built bundle locally |
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
@@ -42,7 +42,7 @@ production-style links work without round-tripping through Go.
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types,schemas,examples}.ts` |
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
@@ -156,9 +156,10 @@ Patterns:
security.
- **Three validation layers**, non-overlapping:
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
Query `queryFn` — warn-only in prod, throws in dev
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
blocks submit + per-field inline error
Query `queryFn` — warns on a mismatch and returns the payload; pass
`{ strict: true }` to throw instead
- Form input: `rules={{ validate: rhfZodValidate(schema.shape.field) }}` on
every `FormField` — blocks submit + per-field inline error
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
because a malformed payload here is always a developer bug
- **No `.loose()` or `[key: string]: any`** in production schemas.
@@ -166,27 +167,29 @@ Patterns:
## Form pattern (Pattern A)
All non-trivial modals use this single pattern:
All non-trivial modals use this single pattern — React Hook Form for the
state, AntD's `<Form>` only as the layout:
```tsx
const [form] = Form.useForm<InboundFormValues>();
const form = useForm<InboundFormValues>({ defaultValues: initialValues });
const onFinish = async () => {
const values = await form.validateFields();
await createInbound.mutateAsync(values);
};
const onSubmit = (values: InboundFormValues) => createInbound.mutateAsync(values);
<Form form={form} onFinish={onFinish}>
<Form.Item
name="port"
label="Port"
rules={[antdRule(InboundFormSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
</Form>
<FormProvider {...form}>
<Form layout="vertical" onFinish={form.handleSubmit(onSubmit)}>
<FormField
name="port"
label={t('pages.inbounds.port')}
rules={{ validate: rhfZodValidate(InboundFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} />
</FormField>
</Form>
</FormProvider>
```
`FormField` (from `@/components/form/rhf`) bridges a React Hook Form
`Controller` to an AntD input and renders the field's error as help text.
No `safeParse`-on-submit handlers, no `useRef<any>` for form
references, no inline `z.string().min(1)` in rules. Conditional
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
+1124 -1134
View File
File diff suppressed because it is too large Load Diff
+37 -37
View File
@@ -24,12 +24,13 @@
"build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node scripts/build-openapi.mjs",
"audit": "npm audit --omit=dev --audit-level=high",
"gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"oxfmt",
"oxfmt --no-error-on-unmatched-pattern",
"oxlint --fix"
]
},
@@ -37,53 +38,53 @@
"@ant-design/icons": "^6.3.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.9.1",
"@hookform/resolvers": "^7.0.1",
"@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-query-devtools": "^5.104.2",
"antd": "^6.6.5",
"@tanstack/react-query": "^5.105.1",
"@tanstack/react-query-devtools": "^5.105.1",
"antd": "^6.6.8",
"codemirror": "^6.0.2",
"dayjs": "^1.11.23",
"i18next": "^26.4.3",
"i18next": "^26.6.0",
"otpauth": "^9.5.2",
"persian-calendar-suite": "^1.5.6",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hook-form": "^7.90.0",
"react-i18next": "^17.0.16",
"react-router": "^8.4.0",
"swagger-ui-react": "^5.33.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.11",
"react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32",
"zod": "^4.6.6"
"zod": "^4.6.7"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.6.1",
"@storybook/addon-docs": "^10.6.1",
"@storybook/addon-vitest": "^10.6.1",
"@storybook/react-vite": "^10.6.1",
"@storybook/addon-a11y": "^10.6.5",
"@storybook/addon-docs": "^10.6.5",
"@storybook/addon-vitest": "^10.6.5",
"@storybook/react-vite": "^10.6.5",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.1.2",
"@vitest/browser-playwright": "5.0.3",
"@vitest/coverage-v8": "^5.0.3",
"@vitest/browser-playwright": "5.0.5",
"@vitest/coverage-v8": "^5.0.5",
"husky": "^9.1.7",
"jsdom": "^30.1.2",
"jsdom": "^30.1.3",
"lint-staged": "^17.6.0",
"msw": "^3.0.3",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"oxlint-tsgolint": "^7.0.2004",
"playwright": "^1.64.0",
"storybook": "^10.6.1",
"oxfmt": "0.75.0",
"oxlint": "1.90.0",
"oxlint-tsgolint": "^7.0.2005",
"playwright": "^1.64.1",
"storybook": "^10.6.5",
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "^5.0.3"
"vite": "8.3.5",
"vitest": "^5.0.5"
},
"overrides": {
"dompurify": "^3.4.15",
"dompurify": "^3.4.17",
"react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0",
"seroval": "^1.6.8",
@@ -93,15 +94,19 @@
},
"swagger-ui-react": {
"js-yaml": "^4.3.2",
"brace-expansion": "^5.0.9"
"brace-expansion": "^5.0.9",
"minimatch": "^11.0.3"
},
"@joshwooding/vite-plugin-react-docgen-typescript": {
"glob": "^14.0.3"
},
"@typeschema/valibot": {
"valibot": "^1.1.0"
},
"@storybook/addon-vitest": {
"vitest": "^5.0.0",
"@vitest/browser-playwright": "^5.0.0",
"@vitest/browser": "^5.0.0"
"vitest": "5.0.5",
"@vitest/browser-playwright": "5.0.5",
"@vitest/browser": "5.0.5"
},
"remarkable": {
"argparse": "^2.0.1"
@@ -115,10 +120,5 @@
"tree-sitter-json": false,
"msw": false,
"esbuild": false
},
"msw": {
"workerDirectory": [
"public"
]
}
}
-361
View File
@@ -1,361 +0,0 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+527 -25
View File
@@ -3,7 +3,7 @@
"info": {
"title": "3X-UI Panel API",
"version": "3.x",
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password."
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
},
"servers": [
{
@@ -16,7 +16,7 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
"description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
},
"cookieAuth": {
"type": "apiKey",
@@ -152,6 +152,11 @@
"ldapVlessField": {
"type": "string"
},
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
@@ -242,6 +247,9 @@
"subClashPath": {
"type": "string"
},
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": {
"type": "string"
},
@@ -342,6 +350,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -636,6 +648,7 @@
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
@@ -662,6 +675,7 @@
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRemoteDNS",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
@@ -695,6 +709,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -928,6 +943,11 @@
"ldapVlessField": {
"type": "string"
},
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
@@ -1018,6 +1038,9 @@
"subClashPath": {
"type": "string"
},
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": {
"type": "string"
},
@@ -1118,6 +1141,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -1420,6 +1447,7 @@
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
@@ -1446,6 +1474,7 @@
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRemoteDNS",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
@@ -1479,6 +1508,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -1673,6 +1703,23 @@
],
"type": "object"
},
"Blocker": {
"description": "Blocker is one unmet requirement.",
"properties": {
"code": {
"example": "tools",
"type": "string"
},
"detail": {
"example": "awg",
"type": "string"
}
},
"required": [
"code"
],
"type": "object"
},
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
@@ -1700,6 +1747,17 @@
"description": "Auth password (Hysteria)",
"type": "string"
},
"clientHostRuleIds": {
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"description": "Client comment",
"type": "string"
@@ -1727,7 +1785,8 @@
"type": "string"
},
"forwardedPorts": {
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
"nullable": true,
"type": "string"
},
"group": {
@@ -1739,9 +1798,15 @@
"type": "string"
},
"keepAlive": {
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
"nullable": true,
"type": "integer"
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitIp": {
"description": "IP limit for this client",
@@ -1931,6 +1996,16 @@
"auth": {
"type": "string"
},
"clientHostRuleIds": {
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"type": "string"
},
@@ -1961,7 +2036,15 @@
"type": "integer"
},
"keepAlive": {
"type": "integer"
"description": "Numeric values or AmneziaWG ranges.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitHwid": {
"type": "integer"
@@ -2738,6 +2821,10 @@
"minimum": 0,
"type": "integer"
},
"randomCount": {
"example": 1,
"type": "integer"
},
"remark": {
"example": "cdn-front",
"maxLength": 256,
@@ -2763,6 +2850,15 @@
"sni": {
"type": "string"
},
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": {},
"sortOrder": {
"type": "integer"
@@ -2810,11 +2906,13 @@
"path",
"pinnedPeerCertSha256",
"port",
"randomCount",
"remark",
"security",
"serverDescription",
"shuffleHost",
"sni",
"sniSharedRandom",
"sockoptParams",
"sortOrder",
"tags",
@@ -2919,6 +3017,11 @@
"minimum": 0,
"type": "integer"
},
"randomCount": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"remark": {
"maxLength": 256,
"type": "string"
@@ -2942,6 +3045,15 @@
"sni": {
"type": "string"
},
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": {
"type": "string"
},
@@ -2984,11 +3096,14 @@
"path",
"pinnedPeerCertSha256",
"port",
"randomCount",
"remark",
"security",
"serverDescription",
"shuffleHost",
"sni",
"sniSharedRandom",
"snis",
"sockoptParams",
"sortOrder",
"tags",
@@ -3664,6 +3779,13 @@
"transitive": {
"type": "boolean"
},
"unavailableMetrics": {
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": {
"example": 1700000000,
"format": "int64",
@@ -3972,6 +4094,12 @@
"example": false,
"type": "boolean"
},
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": {
"example": 1700003600,
"format": "int64",
@@ -4184,6 +4312,12 @@
"example": "online",
"type": "string"
},
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"uptimeSecs": {
"example": 86400,
"format": "int64",
@@ -4215,6 +4349,37 @@
],
"type": "object"
},
"Readiness": {
"description": "Readiness is the host's ability to run any kernel-mode inbound at all.",
"properties": {
"blockers": {
"example": [
{
"code": "tools",
"detail": "awg"
}
],
"items": {
"$ref": "#/components/schemas/Blocker"
},
"type": "array"
},
"installCommand": {
"example": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"type": "string"
},
"ready": {
"example": false,
"type": "boolean"
}
},
"required": [
"blockers",
"installCommand",
"ready"
],
"type": "object"
},
"RealityScanResult": {
"properties": {
"alpn": {
@@ -4397,6 +4562,10 @@
"keepaliveTimeout": {
"type": "string"
},
"kernelMode": {
"description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.",
"type": "boolean"
},
"maxHandshakeAttempts": {
"type": "string"
},
@@ -4651,6 +4820,35 @@
],
"type": "object"
},
"TgBotCapabilities": {
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
"properties": {
"groupPrivacy": {
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
"example": false,
"type": "boolean"
},
"inlineEnabled": {
"example": true,
"type": "boolean"
},
"running": {
"example": true,
"type": "boolean"
},
"username": {
"example": "xui_bot",
"type": "string"
}
},
"required": [
"groupPrivacy",
"inlineEnabled",
"running",
"username"
],
"type": "object"
},
"Traffic": {
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
"properties": {
@@ -4855,7 +5053,7 @@
},
{
"name": "API Tokens",
"description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
"description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
},
{
"name": "Xray Settings",
@@ -5319,6 +5517,51 @@
}
}
},
"/panel/api/inbounds/amneziawgKernelReadiness": {
"get": {
"tags": [
"Inbounds"
],
"summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.",
"operationId": "get_panel_api_inbounds_amneziawgKernelReadiness",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/Readiness"
}
}
},
"example": {
"success": true,
"obj": {
"blockers": [
{
"code": "tools",
"detail": "awg"
}
],
"installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"ready": false
}
}
}
}
}
}
}
},
"/panel/api/inbounds/allLinks": {
"get": {
"tags": [
@@ -5408,7 +5651,7 @@
"tags": [
"Inbounds"
],
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).",
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.",
"operationId": "post_panel_api_inbounds_add",
"requestBody": {
"required": true,
@@ -5432,8 +5675,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -5604,7 +5846,7 @@
"tags": [
"Inbounds"
],
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.",
"operationId": "post_panel_api_inbounds_update_id",
"parameters": [
{
@@ -5639,8 +5881,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -5915,7 +6156,7 @@
"tags": [
"Inbounds"
],
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field.",
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field. VLESS fallback validation is identical to /add.",
"operationId": "post_panel_api_inbounds_import",
"requestBody": {
"required": true,
@@ -6089,7 +6330,7 @@
"tags": [
"Inbounds"
],
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.",
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.",
"operationId": "post_panel_api_inbounds_id_fallbacks",
"parameters": [
{
@@ -7184,7 +7425,7 @@
"tags": [
"Server"
],
"summary": "Self-update the panel to the latest version. The server restarts on success.",
"summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
"operationId": "post_panel_api_server_updatePanel",
"requestBody": {
"required": false,
@@ -8481,6 +8722,81 @@
}
}
},
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch clients by subscription ID.",
"operationId": "get_panel_api_clients_get_subId_subId",
"description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.",
"parameters": [
{
"name": "subId",
"in": "path",
"required": true,
"description": "Exact subscription ID (URL-encoded).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"client": {
"$ref": "#/components/schemas/ClientRecord"
},
"inboundIds": {
"type": "array",
"items": {
"type": "integer"
}
},
"externalLinks": {
"type": "array",
"items": {
"type": "object"
}
},
"usedTraffic": {
"type": "integer",
"format": "int64"
},
"tunnelAllowedIPs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/tgId/{tgId}": {
"get": {
"tags": [
@@ -8529,7 +8845,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -9846,7 +10162,7 @@
"tags": [
"Clients"
],
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
"operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": {
"required": true,
@@ -10187,7 +10503,7 @@
"tags": [
"Clients"
],
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
"operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [
{
@@ -10983,6 +11299,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
@@ -11184,6 +11503,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
@@ -11334,6 +11656,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
@@ -11557,6 +11882,9 @@
"memPct": 45.2,
"panelVersion": "v3.x.x",
"status": "online",
"unavailableMetrics": [
""
],
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
@@ -11909,11 +12237,16 @@
""
],
"port": 0,
"randomCount": 1,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "",
"sortOrder": 0,
"tags": [
@@ -11930,6 +12263,98 @@
}
}
},
"/panel/api/hosts/raw": {
"get": {
"tags": [
"Hosts"
],
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
"operationId": "get_panel_api_hosts_raw",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"address": "cdn.example.com",
"allowInsecure": false,
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"groupId": "",
"hostHeader": "",
"id": 1,
"inboundId": 1,
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": null,
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
""
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": "443"
}
]
}
}
}
}
}
}
},
"/panel/api/hosts/get/{groupId}": {
"get": {
"tags": [
@@ -12004,11 +12429,16 @@
""
],
"port": 0,
"randomCount": 1,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "",
"sortOrder": 0,
"tags": [
@@ -12102,11 +12532,16 @@
""
],
"port": 0,
"randomCount": 1,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "",
"sortOrder": 0,
"tags": [
@@ -12250,11 +12685,16 @@
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
@@ -12372,11 +12812,16 @@
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
@@ -12494,7 +12939,7 @@
"tags": [
"Hosts"
],
"summary": "Set host group sort order by the position of each groupId in the array.",
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
"operationId": "post_panel_api_hosts_reorder",
"requestBody": {
"required": true,
@@ -12624,11 +13069,16 @@
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
@@ -13115,6 +13565,47 @@
}
}
},
"/panel/api/setting/tgBotCapabilities": {
"post": {
"tags": [
"Settings"
],
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
"operationId": "post_panel_api_setting_tgBotCapabilities",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/TgBotCapabilities"
}
}
},
"example": {
"success": true,
"obj": {
"groupPrivacy": false,
"inlineEnabled": true,
"running": true,
"username": "xui_bot"
}
}
}
}
}
}
}
},
"/panel/api/setting/testDiscord": {
"post": {
"tags": [
@@ -13208,9 +13699,11 @@
"obj": [
{
"id": 1,
"name": "default",
"name": "central-panel-a",
"enabled": true,
"createdAt": 1736000000
"createdAt": 1736000000,
"scope": "node-admin",
"expiresAt": 0
}
]
}
@@ -13240,7 +13733,13 @@
},
"scope": {
"type": "string",
"description": "admin (default), monitor, or node-sync."
"enum": [
"admin",
"monitor",
"node-sync",
"node-admin"
],
"description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
},
"expiresAt": {
"type": "integer",
@@ -13253,7 +13752,7 @@
},
"example": {
"name": "central-panel-a",
"scope": "node-sync",
"scope": "node-admin",
"expiresAt": 1798761600000
}
}
@@ -16387,6 +16886,9 @@
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
+2 -2
View File
@@ -23,7 +23,7 @@ const SECURITY_SCHEMES = {
type: 'http',
scheme: 'bearer',
description:
'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
'Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
},
cookieAuth: {
type: 'apiKey',
@@ -342,7 +342,7 @@ export function buildSpec() {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password.',
},
servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
components: {
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { ReadinessSchema, type Readiness } from '@/generated/zod';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
export type KernelReadiness = Readiness;
async function fetchKernelReadiness(): Promise<KernelReadiness> {
const msg = await HttpUtil.get('/panel/api/inbounds/amneziawgKernelReadiness', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch kernel readiness');
return ReadinessSchema.parse(msg.obj);
}
// Probing the module is cheap but not free, and the answer only changes when an
// admin installs something, so a short stale window is plenty.
export function useAmneziawgKernelReadiness(enabled: boolean) {
return useQuery({
queryKey: keys.inbounds.amneziawgKernelReadiness(),
queryFn: fetchKernelReadiness,
enabled,
staleTime: 30_000,
});
}
+9 -4
View File
@@ -20,14 +20,19 @@ export function useHostMutations() {
const updateMut = useMutation({
mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
HttpUtil.post(
`/panel/api/hosts/update/${encodeURIComponent(groupId)}`,
payload,
JSON_HEADERS,
),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const removeMut = useMutation({
mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
mutationFn: (groupId: string) =>
HttpUtil.post(`/panel/api/hosts/del/${encodeURIComponent(groupId)}`),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
@@ -35,7 +40,7 @@ export function useHostMutations() {
const setEnableMut = useMutation({
mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
HttpUtil.post(`/panel/api/hosts/setEnable/${encodeURIComponent(groupId)}`, { enable }),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
@@ -45,7 +50,7 @@ export function useHostMutations() {
mutationFn: (groupIds: string[]) =>
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
if (msg?.success) return invalidate();
},
});
@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { z } from 'zod';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
// One row of the raw hosts table (GET /panel/api/hosts/raw), carrying the
// numeric id that per-client host rule bindings reference.
export const RawHostSchema = z
.object({
id: z.number(),
inboundId: z.number().optional(),
remark: z.string().optional(),
address: z.string().optional(),
port: z.number().optional(),
isDisabled: z.boolean().optional(),
})
.loose();
export type RawHost = z.infer<typeof RawHostSchema>;
const RawHostListSchema = z
.array(RawHostSchema)
.nullable()
.transform((v) => v ?? []);
async function fetchRawHosts(): Promise<RawHost[]> {
const msg = await HttpUtil.get('/panel/api/hosts/raw', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
const validated = parseMsg(msg, RawHostListSchema, 'hosts/raw');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useRawHostsQuery() {
const query = useQuery({
queryKey: keys.hosts.raw(),
queryFn: fetchRawHosts,
});
const hosts = useMemo(() => query.data ?? [], [query.data]);
return {
hosts,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}
+2
View File
@@ -11,6 +11,7 @@ export const keys = {
hosts: {
root: () => ['hosts'] as const,
list: () => ['hosts', 'list'] as const,
raw: () => ['hosts', 'raw'] as const,
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const,
},
@@ -28,6 +29,7 @@ export const keys = {
root: () => ['inbounds'] as const,
slim: () => ['inbounds', 'slim'] as const,
options: () => ['inbounds', 'options'] as const,
amneziawgKernelReadiness: () => ['inbounds', 'amneziawgKernelReadiness'] as const,
},
clients: {
root: () => ['clients'] as const,
@@ -44,7 +44,12 @@ export default function ClientHwidListModal({
zIndex={zIndex}
onCancel={onClose}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}>
<Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={loading}
onClick={onRefresh}
>
{t('refresh')}
</Button>,
<Popconfirm
@@ -122,7 +127,7 @@ export default function ClientHwidListModal({
type="text"
size="small"
aria-label={t('pages.clients.deleteHwid')}
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
loading={deletingId === entry.id}
/>
</Popconfirm>
@@ -38,12 +38,17 @@ export default function ConfigBlock({
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
<Button
size="small"
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={copy}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
onClick={() => FileManager.downloadTextFile(text, fileName)}
/>
@@ -56,7 +61,11 @@ export default function ConfigBlock({
content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
<Button
size="small"
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
)}
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CheckCircleFilled,
ClockCircleOutlined,
@@ -13,17 +12,14 @@ import {
ClusterOutlined,
CodeOutlined,
CopyOutlined,
CrownOutlined,
DashboardOutlined,
DatabaseOutlined,
DiscordOutlined,
ExportOutlined,
FileTextOutlined,
GlobalOutlined,
ImportOutlined,
LoadingOutlined,
MailOutlined,
MessageOutlined,
MoonOutlined,
PlusOutlined,
ReloadOutlined,
@@ -32,8 +28,7 @@ import {
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
TelegramFilled,
ToolOutlined,
} from '@ant-design/icons';
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { useTheme } from '@/hooks/useTheme';
import { PANEL_ROUTES } from '@/routes/panelRoutes';
import type { ClientRecord, InboundOption } from '@/schemas/client';
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
import './CommandPalette.css';
@@ -62,6 +58,34 @@ interface PaletteItem {
};
}
// The search terms of every panel page, so the palette finds it by what it
// does rather than by its title.
export const PALETTE_KEYWORDS: Record<string, string[]> = {
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
'/inbounds': [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
'/groups': ['groups', 'tags', 'batch'],
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
'/hosts': ['hosts', 'sni', 'domains'],
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
'/xray': ['xray', 'templates', 'balancer', 'dns'],
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
'/sponsors': ['sponsors', 'sponsor', 'partners'],
};
export default function CommandPalette() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
@@ -262,9 +286,9 @@ export default function CommandPalette() {
title: c.email,
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
icon: isOnline ? (
<CheckCircleFilled style={{ color: '#52c41a' }} />
<CheckCircleFilled aria-hidden style={{ color: '#52c41a' }} />
) : (
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
<CloseCircleFilled aria-hidden style={{ color: '#ff4d4f' }} />
),
action: () => {
close();
@@ -274,7 +298,7 @@ export default function CommandPalette() {
c.subId && allSetting.subURI
? {
label: t('commandPalette.copySubscription'),
icon: <CopyOutlined />,
icon: <CopyOutlined aria-hidden />,
execute: (e) => {
e.stopPropagation();
copySubscription(c);
@@ -331,7 +355,7 @@ export default function CommandPalette() {
category: 'inbounds',
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
subtitle: `Port ${ib.port || ''}`,
icon: <ImportOutlined style={{ color: '#1677ff' }} />,
icon: <ImportOutlined aria-hidden style={{ color: '#1677ff' }} />,
tag:
tags.length > 0 ? (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
@@ -343,91 +367,12 @@ export default function CommandPalette() {
});
});
const pages = [
{
path: '/',
title: t('menu.dashboard'),
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
icon: <DashboardOutlined />,
},
{
path: '/inbounds',
title: t('menu.inbounds'),
keywords: [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
icon: <ImportOutlined />,
},
{
path: '/clients',
title: t('menu.clients'),
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
icon: <TeamOutlined />,
},
{
path: '/groups',
title: t('menu.groups'),
keywords: ['groups', 'tags', 'batch'],
icon: <TagsOutlined />,
},
{
path: '/nodes',
title: t('menu.nodes'),
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
icon: <ClusterOutlined />,
},
{
path: '/hosts',
title: t('menu.hosts'),
keywords: ['hosts', 'sni', 'domains'],
icon: <GlobalOutlined />,
},
{
path: '/outbound',
title: t('menu.outbounds'),
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
icon: <ExportOutlined />,
},
{
path: '/routing',
title: t('menu.routing'),
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
icon: <SwapOutlined />,
},
{
path: '/settings',
title: t('menu.settings'),
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
icon: <SettingOutlined />,
},
{
path: '/xray',
title: t('menu.xray'),
keywords: ['xray', 'templates', 'balancer', 'dns'],
icon: <ToolOutlined />,
},
{
path: '/api-docs',
title: t('menu.apiDocs'),
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined />,
},
{
path: '/sponsors',
title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined />,
},
];
const pages = PANEL_ROUTES.map((route) => ({
path: route.path,
title: t(route.titleKey),
keywords: PALETTE_KEYWORDS[route.path] ?? [],
icon: <route.icon aria-hidden />,
}));
pages
.filter((p) => matches(p.title, undefined, p.keywords))
@@ -451,63 +396,63 @@ export default function CommandPalette() {
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
subtitle: t('pages.settings.panelSettings'),
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
icon: <SettingOutlined />,
icon: <SettingOutlined aria-hidden />,
},
{
path: '/settings#security',
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
subtitle: t('pages.settings.securitySettings'),
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
icon: <SafetyOutlined />,
icon: <SafetyOutlined aria-hidden />,
},
{
path: '/settings#telegram',
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
subtitle: t('pages.settings.TGBotSettings'),
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
icon: <MessageOutlined />,
icon: <TelegramFilled aria-hidden />,
},
{
path: '/settings#email',
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
subtitle: t('pages.settings.emailSettings'),
keywords: ['email', 'smtp', 'mail', 'crash alerts'],
icon: <MailOutlined />,
icon: <MailOutlined aria-hidden />,
},
{
path: '/settings#discord',
title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
subtitle: t('pages.settings.discordSettings'),
keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
icon: <DiscordOutlined />,
icon: <DiscordOutlined aria-hidden />,
},
{
path: '/settings#subscription',
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
subtitle: t('pages.settings.subSettings'),
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
icon: <CloudServerOutlined />,
icon: <CloudServerOutlined aria-hidden />,
},
{
path: '/settings#subscription-formats',
title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
subtitle: t('menu.subFormats'),
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
icon: <CodeOutlined />,
icon: <CodeOutlined aria-hidden />,
},
{
path: '/settings#subscription-balancers',
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
subtitle: t('pages.settings.subBalancers.menu'),
keywords: ['balancers', 'sub balancers', 'balancer nodes'],
icon: <ApartmentOutlined />,
icon: <ApartmentOutlined aria-hidden />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
subtitle: t('pages.xray.basicTemplate'),
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
icon: <ToolOutlined />,
icon: <ToolOutlined aria-hidden />,
},
{
path: '/xray#basic',
@@ -522,56 +467,56 @@ export default function CommandPalette() {
'statsOutbound',
'metrics_out',
],
icon: <DashboardOutlined />,
icon: <DashboardOutlined aria-hidden />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
subtitle: t('pages.xray.connectionLimits'),
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
icon: <ClockCircleOutlined />,
icon: <ClockCircleOutlined aria-hidden />,
},
{
path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
subtitle: t('pages.xray.logConfigs'),
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
icon: <FileTextOutlined />,
icon: <FileTextOutlined aria-hidden />,
},
{
path: '/xray#balancer',
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
subtitle: t('pages.xray.Balancers'),
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
icon: <ClusterOutlined />,
icon: <ClusterOutlined aria-hidden />,
},
{
path: '/xray#dns',
title: `${t('menu.xray')} · DNS`,
subtitle: 'DNS',
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
icon: <DatabaseOutlined />,
icon: <DatabaseOutlined aria-hidden />,
},
{
path: '/xray#outbound',
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
subtitle: t('pages.xray.Outbounds'),
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
icon: <ExportOutlined />,
icon: <ExportOutlined aria-hidden />,
},
{
path: '/xray#routing',
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
subtitle: t('pages.xray.basicRouting'),
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
icon: <SwapOutlined />,
icon: <SwapOutlined aria-hidden />,
},
{
path: '/xray#advanced',
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
subtitle: t('pages.xray.advancedTemplate'),
keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
icon: <CodeOutlined />,
icon: <CodeOutlined aria-hidden />,
},
];
@@ -599,7 +544,7 @@ export default function CommandPalette() {
title: t('commandPalette.restartXray'),
subtitle: t('pages.index.restartXray'),
keywords: ['restart', 'xray restart', 'reboot xray'],
icon: <ReloadOutlined style={{ color: '#faad14' }} />,
icon: <ReloadOutlined aria-hidden style={{ color: '#faad14' }} />,
action: restartXray,
},
{
@@ -608,7 +553,7 @@ export default function CommandPalette() {
title: t('menu.theme'),
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
keywords: ['theme', 'light', 'dark', 'ultra'],
icon: isDark ? <SunOutlined /> : <MoonOutlined />,
icon: isDark ? <SunOutlined aria-hidden /> : <MoonOutlined aria-hidden />,
action: cycleTheme,
},
{
@@ -617,7 +562,7 @@ export default function CommandPalette() {
title: t('pages.inbounds.addInbound'),
subtitle: t('menu.inbounds'),
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
action: () => {
close();
navigate('/inbounds');
@@ -629,7 +574,7 @@ export default function CommandPalette() {
title: t('pages.clients.addClient'),
subtitle: t('menu.clients'),
keywords: ['add client', 'create user', 'new client', 'new user'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
action: () => {
close();
navigate('/clients');
@@ -705,9 +650,9 @@ export default function CommandPalette() {
>
<div className="command-palette-header">
{isClientSearching ? (
<LoadingOutlined className="command-palette-search-icon spinning" />
<LoadingOutlined aria-hidden className="command-palette-search-icon spinning" />
) : (
<SearchOutlined className="command-palette-search-icon" />
<SearchOutlined aria-hidden className="command-palette-search-icon" />
)}
<input
ref={inputRef}
@@ -69,11 +69,11 @@ export default function TextModal({
footer={
<>
{fileName && (
<Button icon={<DownloadOutlined />} onClick={download}>
<Button icon={<DownloadOutlined aria-hidden />} onClick={download}>
{fileName}
</Button>
)}
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>
<Button type="primary" icon={<CopyOutlined aria-hidden />} onClick={copy}>
{t('copy')}
</Button>
</>
@@ -1,10 +1,18 @@
import { Select } from 'antd';
import type { SelectProps } from 'antd';
import { useTranslation } from 'react-i18next';
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
import { selectTagRender } from './SelectTag';
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
const PRESET_PREFIX = 'preset:';
const PRESET_OPTIONS = Object.keys(TLS_CIPHER_PRESETS).map((name) => ({
value: PRESET_PREFIX + name,
label: name,
}));
type CipherSuitesSelectProps = Omit<
SelectProps<string[]>,
'value' | 'onChange' | 'mode' | 'options'
@@ -14,6 +22,13 @@ type CipherSuitesSelectProps = Omit<
onChange?: (value: string) => void;
};
function expandPreset(next: string[]): string[] {
const picked = [...next].reverse().find((v) => v.startsWith(PRESET_PREFIX));
if (!picked) return next;
const name = picked.slice(PRESET_PREFIX.length) as keyof typeof TLS_CIPHER_PRESETS;
return TLS_CIPHER_PRESETS[name].split(':');
}
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
// the stored value stays the single colon-joined string xray reads.
export default function CipherSuitesSelect({
@@ -21,6 +36,7 @@ export default function CipherSuitesSelect({
onChange,
...rest
}: CipherSuitesSelectProps) {
const { t } = useTranslation();
const suites = value
.split(':')
.map((s) => s.trim())
@@ -31,9 +47,13 @@ export default function CipherSuitesSelect({
tokenSeparators={[':', ',']}
{...rest}
mode="tags"
options={CIPHER_SUITE_OPTIONS}
tagRender={selectTagRender}
options={[
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
]}
value={suites}
onChange={(next) => onChange?.(next.join(':'))}
onChange={(next) => onChange?.(expandPreset(next).join(':'))}
/>
);
}
@@ -19,6 +19,9 @@ interface DateTimePickerProps {
disabled?: boolean;
allowClear?: boolean;
maxDate?: Dayjs;
id?: string;
'aria-label'?: string;
'aria-describedby'?: string;
}
const LIGHT_THEME = {
@@ -57,6 +60,9 @@ export default function DateTimePicker({
disabled = false,
allowClear = true,
maxDate,
id,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}: DateTimePickerProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
@@ -97,7 +103,11 @@ export default function DateTimePicker({
useEffect(() => {
if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder;
if (!input) return;
input.placeholder = placeholder;
if (id) input.id = id;
if (ariaLabel) input.setAttribute('aria-label', ariaLabel);
if (ariaDescribedBy) input.setAttribute('aria-describedby', ariaDescribedBy);
});
if (datepicker === 'jalalian') {
@@ -137,7 +147,7 @@ export default function DateTimePicker({
setClearNonce((n) => n + 1);
}}
>
<CloseCircleFilled />
<CloseCircleFilled aria-hidden />
</button>
)}
</div>
@@ -156,6 +166,9 @@ export default function DateTimePicker({
disabled={disabled}
allowClear={allowClear}
maxDate={maxDate}
id={id}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
style={{ width: '100%' }}
/>
);
@@ -0,0 +1,274 @@
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Divider, Form, Input, Select, Space, Typography } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import {
DEFAULT_DOH_ENDPOINT,
DEFAULT_ECH_DOMAIN,
DEFAULT_ECH_PAIR,
dohEndpointOptions,
echDomainOptions,
formatDynamicEch,
isSupportedDohUrl,
pairPresetOptions,
parseDynamicEch,
} from '@/lib/xray/ech-presets';
interface DynamicEchHelperProps {
/** Current echConfigList, so an existing dynamic pair can be recovered. */
value?: string;
/** Node SNI, used by the "resolve from the proxied hostname" target. */
sni?: string;
/** True when a locally generated ECH keypair is present. */
hasLocalKeys?: boolean;
onApply?: (value: string) => void;
/** Called when the operator accepts dropping CDN-invalid local keys. */
onClearLocalKeys?: () => void;
disabled?: boolean;
}
const CUSTOM = '__custom__';
const AUTO = '__auto__';
const ADVANCED = '__advanced__';
/* Only echConfigList can hold the dynamic `domain+DoH` form; clearing local keys
* is a separate, explicitly confirmed step because behind a CDN they are dead. */
export default function DynamicEchHelper({
value = '',
sni = '',
hasLocalKeys = false,
onApply,
onClearLocalKeys,
disabled = false,
}: DynamicEchHelperProps) {
const { t } = useTranslation();
const uid = useId();
const parsed = parseDynamicEch(value);
const presetValues = new Set(
pairPresetOptions(t).flatMap((group) => group.options.map((o) => o.value)),
);
const [preset, setPreset] = useState<string>(() => {
const current = value.trim();
if (presetValues.has(current)) return current;
if (!current || !parsed) return DEFAULT_ECH_PAIR;
return ADVANCED;
});
const [domainChoice, setDomainChoice] = useState<string>(
() => parsed?.domain ?? DEFAULT_ECH_DOMAIN,
);
const [domainInput, setDomainInput] = useState<string>(() => parsed?.domain ?? '');
const [dohChoice, setDohChoice] = useState<string>(() => parsed?.dohUrl ?? DEFAULT_DOH_ENDPOINT);
const [dohInput, setDohInput] = useState<string>(parsed?.dohUrl ?? '');
const [preview, setPreview] = useState<string | null>(null);
const domain =
domainChoice === CUSTOM ? domainInput : domainChoice === AUTO ? sni.trim() : domainChoice;
const doh = dohChoice === CUSTOM ? dohInput : dohChoice;
const advancedBuilt = formatDynamicEch(domain, doh);
const built = preset === ADVANCED ? advancedBuilt : preset;
const apply = () => {
if (!built) return;
if (hasLocalKeys) setPreview(built);
else onApply?.(built);
};
const pickPreset = (next: string) => {
setPreset(next);
setPreview(null);
};
const pickDomain = (next: string) => {
setDomainChoice(next);
setPreview(null);
};
const pickDoh = (next: string) => {
setDohChoice(next);
setPreview(null);
};
const confirmOverwrite = () => {
if (!built) return;
onApply?.(built);
onClearLocalKeys?.();
setPreview(null);
};
return (
<div data-testid="ech-dynamic-helper" style={{ maxWidth: 640 }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, marginBottom: 4 }}
>
<ThunderboltOutlined aria-hidden />
{t('pages.inbounds.form.echDynamicTitle')}
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicIntro')}
</Typography.Paragraph>
{value.trim() !== '' && !parsed && (
<Typography.Paragraph
type="warning"
data-testid="ech-existing-not-dynamic"
style={{ fontSize: 12, marginBottom: 8 }}
>
{t('pages.inbounds.form.echExistingNotDynamic')}
</Typography.Paragraph>
)}
{/* component={false}: rendered inside the inbound/host <form>, and a nested
<form> element is invalid HTML. */}
<Form layout="vertical" size="small" colon={false} component={false}>
<Form.Item htmlFor={`${uid}-preset`} label={t('pages.inbounds.form.echPresetLabel')}>
<Select
id={`${uid}-preset`}
value={preset}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
data-testid="ech-preset-select"
onChange={pickPreset}
options={[
...pairPresetOptions(t),
{
label: t('pages.inbounds.form.addCustomOption'),
options: [{ value: ADVANCED, label: t('pages.inbounds.form.echPresetAdvanced') }],
},
]}
/>
</Form.Item>
{preset === ADVANCED && (
<>
<Form.Item htmlFor={`${uid}-domain`} label={t('pages.inbounds.form.echDynamicDomain')}>
<Select
id={`${uid}-domain`}
value={domainChoice}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
onChange={pickDomain}
options={[
{ value: AUTO, label: t('pages.inbounds.form.echDomainAuto') },
...echDomainOptions(t),
{ value: CUSTOM, label: t('pages.inbounds.form.addCustomOption') },
]}
/>
</Form.Item>
{domainChoice === CUSTOM && (
<Form.Item>
<Input
aria-label={t('pages.inbounds.form.echDynamicDomain')}
value={domainInput}
disabled={disabled}
placeholder={DEFAULT_ECH_DOMAIN}
onChange={(e) => {
setDomainInput(e.target.value);
setPreview(null);
}}
/>
</Form.Item>
)}
<Form.Item htmlFor={`${uid}-doh`} label={t('pages.inbounds.form.echDynamicDoh')}>
<Select
id={`${uid}-doh`}
value={dohChoice}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
onChange={pickDoh}
options={[
...dohEndpointOptions(t),
{
label: t('pages.inbounds.form.addCustomOption'),
options: [{ value: CUSTOM, label: t('pages.inbounds.form.echDynamicCustom') }],
},
]}
/>
</Form.Item>
{dohChoice === CUSTOM && (
<Form.Item>
<Input
aria-label={t('pages.inbounds.form.echDynamicDoh')}
value={dohInput}
disabled={disabled}
placeholder={DEFAULT_DOH_ENDPOINT}
status={dohInput && !isSupportedDohUrl(dohInput) ? 'error' : undefined}
onChange={(e) => {
setDohInput(e.target.value);
setPreview(null);
}}
/>
</Form.Item>
)}
{dohChoice === CUSTOM && dohInput && !isSupportedDohUrl(dohInput) && (
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicUnsupportedScheme')}
</Typography.Paragraph>
)}
</>
)}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echMainlandAdvisory')}
</Typography.Paragraph>
</Form>
{built ? (
<Typography.Paragraph
code
copyable={{ text: built }}
data-testid="ech-dynamic-preview"
style={{ fontSize: 12, marginBottom: 8, wordBreak: 'break-all' }}
>
{built}
</Typography.Paragraph>
) : (
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicInvalid')}
</Typography.Paragraph>
)}
<Button
type="primary"
size="small"
icon={<ThunderboltOutlined aria-hidden />}
disabled={disabled || !built}
data-testid="ech-dynamic-apply"
onClick={apply}
>
{t('pages.inbounds.form.echDynamicApply')}
</Button>
{preview && (
<>
<Divider style={{ margin: '12px 0' }} />
<Alert
type="warning"
showIcon
data-testid="ech-dynamic-overwrite-warning"
title={t('pages.inbounds.form.echDynamicOverwriteTitle')}
description={
<>
<div>{t('pages.inbounds.form.echDynamicOverwriteBody')}</div>
<div data-testid="ech-dynamic-overwrite-value" style={{ marginTop: 4 }}>
<Typography.Text code>{preview}</Typography.Text>
</div>
</>
}
action={
<Space orientation="vertical">
<Button size="small" type="primary" onClick={confirmOverwrite}>
{t('pages.inbounds.form.echDynamicOverwriteConfirm')}
</Button>
<Button size="small" onClick={() => setPreview(null)}>
{t('cancel')}
</Button>
</Space>
}
/>
</>
)}
</div>
);
}
@@ -125,21 +125,23 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
<Input
value={row.name}
placeholder="Name"
aria-label={t('pages.inbounds.form.headerName')}
onChange={(e) => setRow(idx, { name: e.target.value })}
/>
<Input
value={row.value}
placeholder="Value"
aria-label={t('pages.inbounds.form.headerValue')}
onChange={(e) => setRow(idx, { value: e.target.value })}
/>
<Button
aria-label={t('remove')}
icon={<MinusOutlined />}
icon={<MinusOutlined aria-hidden />}
onClick={() => removeRow(idx)}
/>
</Space.Compact>
))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
<Button size="small" type="primary" icon={<PlusOutlined aria-hidden />} onClick={addRow}>
Add
</Button>
</>
+11 -1
View File
@@ -157,8 +157,18 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
if (!view) return;
const current = view.state.doc.toString();
if (value === current) return;
let from = 0;
while (from < current.length && from < value.length && current[from] === value[from]) {
from += 1;
}
let oldEnd = current.length;
let newEnd = value.length;
while (oldEnd > from && newEnd > from && current[oldEnd - 1] === value[newEnd - 1]) {
oldEnd -= 1;
newEnd -= 1;
}
valueRef.current = value;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
view.dispatch({ changes: { from, to: oldEnd, insert: value.slice(from, newEnd) } });
}, [value]);
useEffect(() => {
@@ -22,6 +22,9 @@ interface RemarkTemplateFieldProps {
multiline?: boolean;
rows?: number;
metadataOnly?: boolean;
id?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
}
/**
@@ -37,6 +40,9 @@ export default function RemarkTemplateField({
multiline = false,
rows,
metadataOnly = false,
id,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: RemarkTemplateFieldProps) {
const { t } = useTranslation();
const inputRef = useRef<InputRef>(null);
@@ -76,7 +82,7 @@ export default function RemarkTemplateField({
<Button
type="text"
size="small"
icon={<CodeOutlined />}
icon={<CodeOutlined aria-hidden />}
aria-label={t('pages.hosts.remarkVars.title')}
style={{ marginInlineEnd: -7 }}
/>
@@ -90,6 +96,9 @@ export default function RemarkTemplateField({
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Input.TextArea
ref={textAreaRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value}
maxLength={maxLength}
placeholder={placeholder}
@@ -101,6 +110,9 @@ export default function RemarkTemplateField({
) : (
<Input
ref={inputRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value}
maxLength={maxLength}
placeholder={placeholder}
@@ -4,6 +4,7 @@ import { expect } from 'storybook/test';
import { Select } from 'antd';
import SelectAllClearButtons from './SelectAllClearButtons';
import { selectTagRender } from './SelectTag';
const inboundOptions: Array<{ value: number; label: string }> = [
{ value: 1, label: 'VLESS Reality — 443' },
@@ -64,13 +65,13 @@ function InboundPickerDemo() {
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={inboundOptions}
placeholder="Select inbounds"
aria-label="Select inbounds"
maxTagCount="responsive"
/>
</div>
);
@@ -89,13 +90,13 @@ function ClientEmailsDemo() {
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={clientEmailOptions}
placeholder="Select clients"
aria-label="Select clients"
maxTagCount="responsive"
/>
</div>
);
@@ -0,0 +1,14 @@
.select-tag-remove {
border: 0;
background: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
}
.select-tag-remove:focus-visible {
outline: 2px solid var(--ant-color-primary);
outline-offset: 1px;
border-radius: 2px;
}
@@ -0,0 +1,85 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Select } from 'antd';
import { selectTagRender } from './SelectTag';
const meta = {
title: 'Form/SelectTag',
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Tag renderer for every `mode="tags"` / `mode="multiple"` Select. Ant Design draws the tag "x" as an aria-hidden, unfocusable span; `tagRender={selectTagRender}` swaps in a real button named "Remove <value>", so keyboard and screen-reader users can remove any tag, and focus moves to the next one.',
},
},
},
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
function SniDemo() {
const [value, setValue] = useState(['www.microsoft.com', 'www.apple.com', 'dl.google.com']);
return (
<div style={{ maxWidth: 420 }}>
<Select
aria-label="SNI"
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ width: '100%' }}
value={value}
onChange={setValue}
/>
</div>
);
}
function InboundDemo() {
const options = [
{ value: 1, label: 'VLESS Reality — 443' },
{ value: 2, label: 'VMess WS — 8443' },
{ value: 3, label: 'Trojan TCP — 2053' },
];
const [value, setValue] = useState([1, 2, 3]);
return (
<div style={{ maxWidth: 420 }}>
<Select
aria-label="Inbounds"
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
options={options}
value={value}
onChange={setValue}
/>
</div>
);
}
export const FreeTextTags: Story = {
render: () => <SniDemo />,
play: async ({ canvas, userEvent }) => {
await userEvent.tab();
await userEvent.tab();
await expect(canvas.getByRole('button', { name: 'Remove www.apple.com' })).toHaveFocus();
await userEvent.keyboard('{Enter}');
await expect(canvas.queryByText('www.apple.com')).toBeNull();
await waitFor(() =>
expect(canvas.getByRole('button', { name: 'Remove dl.google.com' })).toHaveFocus(),
);
await expect(canvas.getByRole('combobox', { name: 'SNI' })).toHaveAttribute(
'aria-expanded',
'false',
);
},
};
export const OptionLabels: Story = {
render: () => <InboundDemo />,
};

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