Compare commits

...

153 Commits

Author SHA1 Message Date
dependabot[bot] b6cb818a51 chore(deps-dev): bump @vitejs/plugin-react in /frontend
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 6.1.2 to 6.2.0.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.2.0/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 09:08:45 +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
411 changed files with 19842 additions and 3051 deletions
+19 -4
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,12 +75,12 @@ jobs:
- name: PostgreSQL durable-first tests
run: |
set -o pipefail
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$' -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)
if [ "$passed" -lt 4 ]; then
echo "expected at least 4 passing durable-first tests, got $passed" >&2
if [ "$passed" -lt 5 ]; then
echo "expected at least 5 passing durable-first tests, got $passed" >&2
exit 1
fi
@@ -191,6 +195,15 @@ jobs:
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
codegen:
runs-on: ubuntu-latest
@@ -314,8 +327,10 @@ jobs:
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
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
+2 -2
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.21/"
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
@@ -283,7 +283,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
$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 -1
View File
@@ -52,7 +52,7 @@ 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.21/Xray-linux-${XRAY_ZIP}.zip"
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}"
+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 ]]
+4 -3
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">
@@ -132,10 +132,11 @@ enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound with
| Field | Default | Meaning |
| -------------- | -------------- | ----------------------------------------------------------------------- |
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. |
| `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
@@ -76,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
+25 -19
View File
@@ -182,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
@@ -223,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.
@@ -457,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
@@ -492,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
@@ -609,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.
+30 -16
View File
@@ -56,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'
@@ -70,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.
@@ -96,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
@@ -113,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
@@ -157,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;
@@ -168,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
@@ -188,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
@@ -202,8 +214,10 @@ _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: []
---
+3 -2
View File
@@ -133,10 +133,11 @@ inbound به مقدار `decryption` نیاز دارد و outbound به `encrypt
| فیلد | پیش‌فرض | معنا |
| -------------- | -------------- | ---- |
| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. |
| `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
@@ -77,6 +77,8 @@ icon: Send
ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده،
بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها،
کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
کارت **کلاینت** در اختیار ادمین است و لینک اشتراک، لینک‌های منفرد و کد QR کلاینت را
نشان می‌دهد، تا کانفیگ را بتوان به کلاینتی که هنوز به Telegram دسترسی ندارد هم داد.
کاربران عادی دکمه‌هایی برای مصرف خودشان، لینک‌های اشتراک، لینک‌های منفرد و کدهای QR دارند.
## گزارش‌ها و هشدارها
+3 -2
View File
@@ -135,10 +135,11 @@ Mux. Панель предлагает XDRIVE только для VLESS, не с
| Поле | По умолчанию | Значение |
| -------------- | -------------- | -------- |
| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. |
| `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) — домен-фронт,
@@ -80,9 +80,11 @@ ID темы после двоеточия, например `-1001234567890:42`.
Администраторам также доступны сценарии со встроенными кнопками: использование
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
«скоро исчерпают» и полноценный мастер **добавления клиента**. Обычным
пользователям доступны кнопки для своего расхода, ссылок на подписку, отдельных
ссылок и QR-кодов.
«скоро исчерпают» и полноценный мастер **добавления клиента**. В карточке
клиента администратор получает ссылку на подписку, отдельные ссылки и QR-код —
конфигурацию можно передать клиенту, который ещё не может открыть Telegram.
Обычным пользователям доступны кнопки для своего расхода, ссылок на подписку,
отдельных ссылок и QR-кодов.
## Отчёты и оповещения
+3 -2
View File
@@ -124,10 +124,11 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服
| 字段 | 默认值 | 含义 |
| -------------- | -------------- | ---- |
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 |
| `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 主机名。
@@ -71,7 +71,9 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看
“即将耗尽”,以及完整的**添加客户端**向导。管理员的**客户端卡片**上还带有该客户端的
订阅链接、单条链接和 QR 码,因此可以把配置交给暂时还无法打开 Telegram 的客户端。
普通用户则可以使用按钮查看
自己的用量、订阅链接、单条链接和 QR 码。
## 报告与告警
+9 -9
View File
@@ -18,12 +18,12 @@
"test:watch": "vitest"
},
"dependencies": {
"fumadocs-core": "^16.17.8",
"fumadocs-core": "^16.17.9",
"fumadocs-docgen": "^3.2.0",
"fumadocs-mdx": "^15.4.10",
"fumadocs-openapi": "^12.5.5",
"fumadocs-ui": "^16.17.8",
"lucide-react": "^1.61.0",
"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",
@@ -37,15 +37,15 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14",
"@types/node": "^26.6.5",
"@types/node": "^26.6.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"oxfmt": "0.74.0",
"oxlint": "1.89.0",
"postcss": "^8.5.31",
"oxfmt": "0.75.0",
"oxlint": "1.90.0",
"postcss": "^8.5.32",
"tailwindcss": "^4.3.3",
"typescript": "7.0.2",
"vitest": "^5.0.5"
},
"packageManager": "pnpm@12.15.0"
"packageManager": "pnpm@12.16.1"
}
+288 -288
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -11,8 +11,6 @@ overrides:
'lightningcss@<1.33.0': '^1.33.0'
minimumReleaseAgeExcludePrune: true
minimumReleaseAgeExclude:
- fumadocs-core@16.17.8
- fumadocs-ui@16.17.8
- '@fumadocs/json-schema@0.3.5'
- '@next/env@16.4.3'
- '@next/swc-darwin-arm64@16.4.3'
@@ -24,5 +22,4 @@ minimumReleaseAgeExclude:
- '@next/swc-win32-arm64-msvc@16.4.3'
- '@next/swc-win32-x64-msvc@16.4.3'
- fumadocs-mdx@15.4.10
- fumadocs-openapi@12.5.5
- next@16.4.3
+11 -12
View File
@@ -1785,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": {
@@ -5650,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,
@@ -5674,8 +5675,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -5846,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": [
{
@@ -5881,8 +5881,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -6157,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,
@@ -6331,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": [
{
@@ -8846,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": {
@@ -10163,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,
@@ -10504,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": [
{
+11 -4
View File
@@ -11,7 +11,8 @@
"jsPlugins": [
"./tools/oxlint/input-number-guard.mjs",
"./tools/oxlint/control-label-guard.mjs",
"./tools/oxlint/icon-a11y-guard.mjs"
"./tools/oxlint/icon-a11y-guard.mjs",
"./tools/oxlint/antd-a11y-guard.mjs"
],
"categories": {
"correctness": "error"
@@ -52,7 +53,9 @@
"input-number/no-synthetic-clear": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off"
"antd-icon/explicit-a11y": "off",
"antd-a11y/spin-content-not-live": "off",
"antd-a11y/select-tag-remove": "off"
},
"overrides": [
{
@@ -78,7 +81,9 @@
],
"rules": {
"control-label/has-name": "error",
"antd-icon/explicit-a11y": "error"
"antd-icon/explicit-a11y": "error",
"antd-a11y/spin-content-not-live": "error",
"antd-a11y/select-tag-remove": "error"
}
},
{
@@ -88,7 +93,9 @@
],
"rules": {
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off"
"antd-icon/explicit-a11y": "off",
"antd-a11y/spin-content-not-live": "off",
"antd-a11y/select-tag-remove": "off"
}
}
]
+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,
});
}
+604 -588
View File
File diff suppressed because it is too large Load Diff
+23 -19
View File
@@ -38,47 +38,47 @@
"@ant-design/icons": "^6.3.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^6.0.0",
"@hookform/resolvers": "^7.0.1",
"@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.5",
"@tanstack/react-query-devtools": "^5.104.5",
"antd": "^6.6.7",
"@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.5.6",
"i18next": "^26.6.0",
"otpauth": "^9.5.2",
"persian-calendar-suite": "^1.5.6",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
"react-i18next": "^17.1.3",
"react-router": "^8.5.0",
"swagger-ui-react": "^5.34.0",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.10",
"react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32",
"zod": "^4.6.7"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.6.3",
"@storybook/addon-docs": "^10.6.3",
"@storybook/addon-vitest": "^10.6.3",
"@storybook/react-vite": "^10.6.3",
"@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",
"@vitejs/plugin-react": "^6.2.0",
"@vitest/browser-playwright": "5.0.5",
"@vitest/coverage-v8": "^5.0.5",
"husky": "^9.1.7",
"jsdom": "^30.1.3",
"lint-staged": "^17.6.0",
"msw": "^3.0.3",
"oxfmt": "0.74.0",
"oxlint": "1.89.0",
"oxlint-tsgolint": "^7.0.2004",
"oxfmt": "0.75.0",
"oxlint": "1.90.0",
"oxlint-tsgolint": "^7.0.2005",
"playwright": "^1.64.1",
"storybook": "^10.6.3",
"storybook": "^10.6.5",
"typescript": "7.0.2",
"vite": "8.3.5",
"vitest": "^5.0.5"
@@ -94,7 +94,11 @@
},
"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"
+11 -12
View File
@@ -1785,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": {
@@ -5650,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,
@@ -5674,8 +5675,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -5846,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": [
{
@@ -5881,8 +5881,7 @@
"email": "user1"
}
],
"decryption": "none",
"fallbacks": []
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
@@ -6157,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,
@@ -6331,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": [
{
@@ -8846,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": {
@@ -10163,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,
@@ -10504,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": [
{
+8 -3
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();
},
@@ -3,6 +3,7 @@ import type { SelectProps } from 'antd';
import { useTranslation } from 'react-i18next';
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 }));
@@ -46,6 +47,7 @@ export default function CipherSuitesSelect({
tokenSeparators={[':', ',']}
{...rest}
mode="tags"
tagRender={selectTagRender}
options={[
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
@@ -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 />,
};
@@ -0,0 +1,66 @@
import { useContext } from 'react';
import type { KeyboardEvent, MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider } from 'antd';
import type { SelectProps } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import './SelectTag.css';
type SelectTagProps = Parameters<NonNullable<SelectProps['tagRender']>>[0];
const REMOVE_CLASS = 'select-tag-remove';
// Stops rc-select from opening the dropdown or eating Enter/Space before the click fires.
function keepFromSelect(e: KeyboardEvent | MouseEvent) {
if ('key' in e && e.key === 'Tab') return;
e.stopPropagation();
if (e.type === 'mousedown') e.preventDefault();
}
function SelectTag({ label, value, closable, onClose }: SelectTagProps) {
const { t } = useTranslation();
const { getPrefixCls } = useContext(ConfigProvider.ConfigContext);
const selectCls = getPrefixCls('select');
const itemCls = `${selectCls}-selection-item`;
const text =
typeof label === 'string' || typeof label === 'number' ? String(label) : String(value);
const remove = (e: MouseEvent<HTMLButtonElement>) => {
const button = e.currentTarget;
const root = button.closest(`.${selectCls}`);
const hadFocus = document.activeElement === button;
const index = root ? [...root.querySelectorAll(`.${REMOVE_CLASS}`)].indexOf(button) : -1;
onClose(e);
if (!root || !hadFocus) return;
// Keyboard users would otherwise land on <body> once this tag unmounts.
requestAnimationFrame(() => {
const rest = root.querySelectorAll<HTMLElement>(`.${REMOVE_CLASS}`);
const next = rest[Math.min(index, rest.length - 1)] ?? root.querySelector('input');
next?.focus();
});
};
return (
<span className={itemCls} title={text}>
<span className={`${itemCls}-content`}>{label}</span>
{closable && (
<button
type="button"
className={`${itemCls}-remove ${REMOVE_CLASS}`}
aria-label={t('removeItem', { item: text })}
onMouseDown={keepFromSelect}
onKeyDown={keepFromSelect}
onClick={remove}
>
<CloseOutlined aria-hidden />
</button>
)}
</span>
);
}
// Ant Design renders each tag's "x" as an aria-hidden, unfocusable span, so screen-reader
// and keyboard users cannot remove a tag; this swaps in a real, named button.
export function selectTagRender(props: SelectTagProps) {
return <SelectTag {...props} />;
}
+1
View File
@@ -7,3 +7,4 @@ export { default as CipherSuitesSelect } from './CipherSuitesSelect';
export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker';
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
export { selectTagRender } from './SelectTag';
@@ -1,6 +1,7 @@
import { lazy, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd';
import { Alert, Button, Card, Layout, Modal, Skeleton, Space, Switch, Tag, Typography } from 'antd';
import { expect, userEvent, within } from 'storybook/test';
import LazyMount from './LazyMount';
@@ -21,7 +22,10 @@ const meta = {
when: {
description: 'Children mount the first time this becomes true and stay mounted afterwards.',
},
fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
fallback: {
description:
'Suspense fallback shown while a React.lazy child is loading. The default fullscreen spinner does not occupy space in the surrounding layout; custom fallbacks render as supplied.',
},
children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
},
} satisfies Meta<typeof LazyMount>;
@@ -145,3 +149,53 @@ export const MountedImmediately: Story = {
),
},
};
function LazyModalInLayoutDemo() {
const [open, setOpen] = useState(false);
const [SlowModal] = useState(() =>
lazy(
() =>
new Promise<{ default: typeof Modal }>((resolve) => {
setTimeout(() => resolve({ default: Modal }), 1200);
}),
),
);
return (
<Layout hasSider style={{ minHeight: 240 }}>
<Layout.Sider width={96} style={{ color: '#fff' }}>
Navigation
</Layout.Sider>
<Layout.Content data-testid="lazy-modal-content" style={{ padding: 16 }}>
<Button onClick={() => setOpen(true)}>Open lazy dialog</Button>
<Typography.Paragraph>
The content width stays fixed while the dialog loads.
</Typography.Paragraph>
</Layout.Content>
<LazyMount when={open}>
<SlowModal title="Lazy dialog" open={open} onCancel={() => setOpen(false)} footer={null}>
Dialog loaded without resizing the page.
</SlowModal>
</LazyMount>
</Layout>
);
}
export const LazyModalInLayout: Story = {
args: placeholderArgs,
render: () => <LazyModalInLayoutDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const content = canvas.getByTestId('lazy-modal-content');
const width = content.getBoundingClientRect().width;
await userEvent.click(canvas.getByRole('button', { name: 'Open lazy dialog' }));
const widths: number[] = [];
for (let frame = 0; frame < 12; frame++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
widths.push(content.getBoundingClientRect().width);
}
expect(Math.min(...widths)).toBeCloseTo(width, 1);
const page = within(canvasElement.ownerDocument.body);
await page.findByRole('dialog', { name: 'Lazy dialog' }, { timeout: 5000 });
await userEvent.click(page.getByRole('button', { name: 'Close' }));
},
};
@@ -11,7 +11,11 @@ interface LazyMountProps {
// thereafter, so React.lazy modals get loaded on demand but their close
// animations still play out. Pair with `lazy(() => import(...))` modal imports
// on heavy list pages to keep the initial bundle small.
export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
export default function LazyMount({
when,
fallback = <Spin fullscreen />,
children,
}: LazyMountProps) {
const [mounted, setMounted] = useState(when);
if (when && !mounted) setMounted(true);
if (!mounted) return null;
+1 -1
View File
@@ -431,7 +431,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": false,
"expiryTime": 0,
"flow": "",
"forwardedPorts": "",
"forwardedPorts": null,
"group": "",
"id": "",
"keepAlive": 0,
+2 -1
View File
@@ -1759,7 +1759,8 @@ export const SCHEMAS: Record<string, unknown> = {
"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": {
+1 -1
View File
@@ -426,7 +426,7 @@ export interface Client {
enable: boolean;
expiryTime: number;
flow?: string;
forwardedPorts?: string;
forwardedPorts?: string | null;
group?: string;
id?: string;
keepAlive?: number | string;
+1 -1
View File
@@ -450,7 +450,7 @@ export const ClientSchema = z.object({
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string().optional(),
forwardedPorts: z.string().optional(),
forwardedPorts: z.string().nullable().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.union([z.number().int(), z.string()]).optional(),
+12 -1
View File
@@ -1,4 +1,5 @@
import i18next from 'i18next';
import type { InterpolationOptions } from 'i18next';
import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils';
@@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json';
const FALLBACK = 'en-US';
// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed
// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match.
export const INTERPOLATION: InterpolationOptions = {
escapeValue: false,
prefix: '{',
suffix: '}',
nestingPrefix: '',
nestingPrefixEscaped: '(?!)',
};
const lazyModules = import.meta.glob([
'../../../internal/web/translation/*.json',
'!../../../internal/web/translation/en-US.json',
@@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
lng: active,
fallbackLng: FALLBACK,
resources: { [FALLBACK]: { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
interpolation: INTERPOLATION,
returnNull: false,
});
if (active !== FALLBACK) {
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Select, Switch } from 'antd';
import type { FormInstance } from 'antd/es/form';
import { selectTagRender } from '@/components/form/SelectTag';
import { SNIFFING_OPTION } from '@/schemas/primitives';
const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label }));
@@ -36,6 +37,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Select
mode="multiple"
tagRender={selectTagRender}
className="sniffing-options"
aria-label={t('pages.inbounds.sniffingDestOverride')}
options={DEST_OPTIONS}
@@ -61,6 +63,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*/ext:*"
style={{ width: '100%' }}
@@ -72,6 +75,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
placeholder="domain:*/ext:*"
style={{ width: '100%' }}
@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { selectTagRender } from '@/components/form/SelectTag';
import { RandomUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
@@ -608,6 +609,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
<Select
aria-describedby={`${uid}-allowed-hint`}
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="10.0.0.0/8, geoip:ir"
@@ -624,6 +626,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
<Select
aria-describedby={`${uid}-blocked-hint`}
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="203.0.113.7, geoip:cn"
@@ -649,7 +652,12 @@ function SudokuSettings({ fieldName }: { fieldName: number }) {
<Input />
</Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
/>
</Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} />
@@ -1175,7 +1183,12 @@ function UdpMaskItem({
<Switch />
</Form.Item>
<Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
/>
</Form.Item>
</>
);
@@ -1189,6 +1202,7 @@ function UdpMaskItem({
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
placeholder="host:port"
@@ -1236,6 +1250,7 @@ function UdpMaskItem({
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
options={[
{ value: 'h3', label: 'h3' },
@@ -1529,6 +1544,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Form.Item label="Names" name={[domain.name, 'names']}>
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
placeholder="t.example.com"
@@ -1537,6 +1553,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Form.Item label="Record Types" name={[domain.name, 'types']}>
<Select
mode="multiple"
tagRender={selectTagRender}
options={XDNS_RECORD_TYPE_OPTIONS}
placeholder="TXT (client), all (server)"
/>
@@ -1583,6 +1600,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Form.Item label="Addresses" name={[resolver.name, 'addrs']}>
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="udp://8.8.8.8:53, https://dns.google/dns-query"
@@ -1670,7 +1688,7 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
normalize={(value: string[]) => value.join(',')}
rules={[{ required: true, message: 'pick at least one mode' }]}
>
<Select mode="multiple" options={UDP_HOP_MODES} />
<Select mode="multiple" tagRender={selectTagRender} options={UDP_HOP_MODES} />
</Form.Item>
<Form.Item
label="Interval (s)"
@@ -1683,7 +1701,12 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
<Input placeholder="20000-50000" />
</Form.Item>
<Form.Item label="Remote IPs" name={[udpFieldName, 'settings', 'remoteIPs']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',', ' ']} />
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
/>
</Form.Item>
</>
);
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Typography } from 'antd';
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { JsonEditor } from '@/components/form';
import { selectTagRender } from '@/components/form/SelectTag';
import { FormField } from '@/components/form/rhf';
import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive';
@@ -13,7 +14,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
local: 'pages.inbounds.form.xdrive.serviceLocal',
template: 'pages.inbounds.form.xdrive.serviceTemplate',
};
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive' };
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive', s3: 'S3' };
// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
// empty field keeps meaning "use the core's value".
@@ -29,6 +30,11 @@ const TUNING_FIELDS = [
] as const;
const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const;
const S3_SECRETS = [
['accessKeyId', true],
['secretAccessKey', true],
['sessionToken', false],
] as const;
function TemplateEditor({
value,
@@ -90,7 +96,11 @@ export default function XDriveForm() {
name={[...BASE, 'remoteFolder']}
required
>
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
<Input
placeholder={
service === 'local' ? '/var/lib/xdrive' : service === 's3' ? 'xdrive' : 'xray-tunnel'
}
/>
</FormField>
{service === 'google-drive' &&
GOOGLE_DRIVE_SECRETS.map((key, index) => (
@@ -103,6 +113,48 @@ export default function XDriveForm() {
{key === 'clientId' ? <Input /> : <Input.Password />}
</FormField>
))}
{service === 's3' && (
<>
{S3_SECRETS.map(([key, required], index) => (
<FormField
key={key}
label={t(`pages.inbounds.form.xdrive.${key}`)}
name={[...BASE, 'secrets', index]}
required={required}
>
{key === 'accessKeyId' ? <Input /> : <Input.Password />}
</FormField>
))}
<FormField
label={t('pages.inbounds.form.xdrive.s3Endpoint')}
name={[...BASE, 's3', 'endpoint']}
required
>
<Input placeholder="https://s3.amazonaws.com" />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.s3Region')}
name={[...BASE, 's3', 'region']}
>
<Input placeholder="us-east-1" />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.s3Bucket')}
name={[...BASE, 's3', 'bucket']}
required
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.s3PathStyle')}
tooltip={t('pages.inbounds.form.xdrive.s3PathStyleDesc')}
name={[...BASE, 's3', 'pathStyle']}
transform={{ input: (value) => value !== false }}
>
<Switch />
</FormField>
</>
)}
{service === 'template' && (
<>
<FormField
@@ -110,7 +162,7 @@ export default function XDriveForm() {
tooltip={t('pages.inbounds.form.xdrive.secretsDesc')}
name={[...BASE, 'secrets']}
>
<Select mode="tags" tokenSeparators={[',']} open={false} />
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',']} open={false} />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.template')}
+22 -3
View File
@@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null {
export function parseOutboundLink(link: string): Raw | null {
const trimmed = link.trim();
if (!trimmed) return null;
return (
const outbound =
parseVmessLink(trimmed) ??
parseVlessLink(trimmed) ??
parseTrojanLink(trimmed) ??
parseShadowsocksLink(trimmed) ??
parseHysteria2Link(trimmed) ??
parseWireguardLink(trimmed)
);
parseWireguardLink(trimmed);
if (outbound) dropHostLocalKeys(outbound);
return outbound;
}
// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and
// vmess JSON copy provider objects verbatim, and the core binds keys case-folded.
const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']);
function dropHostLocalKeys(value: unknown): void {
if (Array.isArray(value)) {
value.forEach(dropHostLocalKeys);
return;
}
if (!value || typeof value !== 'object') return;
const obj = value as Raw;
for (const key of Object.keys(obj)) {
// Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does.
if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key];
else dropHostLocalKeys(obj[key]);
}
}
+8 -8
View File
@@ -67,7 +67,7 @@ export interface Section {
// /inbounds/update replaces the whole row, so it takes the same payload as /add.
const inboundBody =
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
const outboundSubscriptionBodyParams: EndpointParam[] = [
{
@@ -309,7 +309,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/inbounds/add',
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).',
'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.',
body: inboundBody,
errorResponse: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
},
@@ -332,7 +332,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/inbounds/update/:id',
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.',
'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.',
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
body: inboundBody,
},
@@ -377,7 +377,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/inbounds/import',
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.',
'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.',
params: [
{
name: 'data',
@@ -422,7 +422,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/inbounds/:id/fallbacks',
summary:
'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
'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.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
{
@@ -1195,7 +1195,7 @@ export const sections: readonly Section[] = [
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.',
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.',
'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.',
params: [
{
name: 'client',
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/bulkResetTraffic',
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.',
'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.',
body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
},
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/resetTraffic/:email',
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.',
'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.',
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
},
{
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons } from '@/components/form';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkAttachResult } from '@/schemas/client';
@@ -106,6 +106,7 @@ export default function BulkAttachInboundsModal({
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons } from '@/components/form';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkDetachResult } from '@/schemas/client';
@@ -106,6 +106,7 @@ export default function BulkDetachInboundsModal({
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}
@@ -21,7 +21,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { RandomUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
@@ -268,6 +268,7 @@ export default function ClientBulkAddModal({
<Select
id={`${uid}-inbounds`}
mode="multiple"
tagRender={selectTagRender}
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
options={inboundOptions}
@@ -38,7 +38,7 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useClientHwids } from '@/hooks/useClientHwids';
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import ClientHwidListModal from '@/components/clients/ClientHwidList';
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
@@ -1168,11 +1168,11 @@ export default function ClientFormModal({
<Select
id={`${uid}-host-rule-override`}
mode="multiple"
tagRender={selectTagRender}
value={clientHostRuleIds || []}
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
options={hostRuleOptions}
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
maxTagCount="responsive"
placement="topLeft"
listHeight={220}
allowClear
@@ -1226,11 +1226,11 @@ export default function ClientFormModal({
<Select
id={`${uid}-inbounds`}
mode="multiple"
tagRender={selectTagRender}
value={inboundIds}
onChange={handleInboundChange}
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
maxTagCount="responsive"
placement="topLeft"
listHeight={220}
showSearch={{
+2 -2
View File
@@ -163,7 +163,7 @@ function SubscriptionQrPresentation({
showIcon
title={t('pages.clients.happLinkDisclosure')}
/>
<Spin spinning={happLoading}>
<Spin aria-live="off" spinning={happLoading}>
<div style={{ minHeight: happLoading ? 48 : undefined }}>
{happLink ? (
<>
@@ -563,7 +563,7 @@ function ClientQrModalContent({
centered
onCancel={() => onOpenChange(false)}
>
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
{!client?.subId && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
{t('pages.clients.noSubId')}
+2 -1
View File
@@ -1305,6 +1305,7 @@ export default function ClientsPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={!fetched || !hostsFetched}
delay={200}
description={t('loading')}
@@ -1724,7 +1725,7 @@ export default function ClientsPage() {
}}
/>
) : (
<Spin spinning={transitioning}>
<Spin aria-live="off" spinning={transitioning}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
+5 -4
View File
@@ -17,6 +17,7 @@ import {
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import type { NodeRecord } from '@/schemas/node';
import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -118,11 +119,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-protocols`}
mode="multiple"
tagRender={selectTagRender}
value={filters.protocols}
onChange={(v) => patch('protocols', v as string[])}
options={protocolOptions}
placeholder={t('pages.inbounds.protocol')}
maxTagCount="responsive"
allowClear
/>
</Form.Item>
@@ -131,11 +132,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-inbounds`}
mode="multiple"
tagRender={selectTagRender}
value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])}
options={inboundOptions}
placeholder={t('inbounds')}
maxTagCount="responsive"
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
@@ -147,11 +148,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-nodes`}
mode="multiple"
tagRender={selectTagRender}
value={filters.nodeIds}
onChange={(v) => patch('nodeIds', v as number[])}
options={nodeOptions}
placeholder={t('pages.clients.filters.nodes')}
maxTagCount="responsive"
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
@@ -163,11 +164,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-groups`}
mode="multiple"
tagRender={selectTagRender}
value={filters.groups}
onChange={(v) => patch('groups', v as string[])}
options={groupOptions}
placeholder={t('pages.clients.groupPlaceholder')}
maxTagCount="responsive"
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
+7 -1
View File
@@ -496,7 +496,13 @@ export default function GroupsPage() {
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
<Spin
aria-live="off"
spinning={!fetched}
delay={200}
description={t('loading')}
size="large"
>
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
+24 -4
View File
@@ -28,7 +28,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { CipherSuitesSelect } from '@/components/form';
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
@@ -291,6 +291,7 @@ export default function HostFormModal({
>
<Select
mode="multiple"
tagRender={selectTagRender}
options={inboundSelectOptions}
showSearch={{ optionFilterProp: 'label' }}
placeholder={t('pages.hosts.selectInbound')}
@@ -304,6 +305,7 @@ export default function HostFormModal({
>
<Select
mode="tags"
tagRender={selectTagRender}
options={hostOptions}
tokenSeparators={[',', ';', ' ']}
placeholder="cdn.example.com, cdn2.example.com:443"
@@ -326,7 +328,12 @@ export default function HostFormModal({
label={t('pages.hosts.fields.tags')}
tooltip={t('pages.hosts.hints.tags')}
>
<Select mode="tags" allowClear tokenSeparators={[',']} />
<Select
mode="tags"
tagRender={selectTagRender}
allowClear
tokenSeparators={[',']}
/>
</FormField>
<FormField
name="nodeGuids"
@@ -335,6 +342,7 @@ export default function HostFormModal({
>
<Select
mode="multiple"
tagRender={selectTagRender}
allowClear
options={nodeSelectOptions}
showSearch={{ optionFilterProp: 'label' }}
@@ -377,6 +385,7 @@ export default function HostFormModal({
>
<Select
mode="tags"
tagRender={selectTagRender}
options={sniOptions}
tokenSeparators={[',', ';', ' ']}
placeholder="sni.example.com, sni2.example.com"
@@ -413,7 +422,12 @@ export default function HostFormModal({
{showTlsExtras && (
<>
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
<Select
mode="multiple"
tagRender={selectTagRender}
allowClear
options={alpnOptions}
/>
</FormField>
<FormField
name="cipherSuites"
@@ -425,7 +439,12 @@ export default function HostFormModal({
/>
</FormField>
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
<Select
mode="tags"
tagRender={selectTagRender}
allowClear
tokenSeparators={[',']}
/>
</FormField>
<FormField
name="verifyPeerCertByName"
@@ -519,6 +538,7 @@ export default function HostFormModal({
>
<Select
mode="multiple"
tagRender={selectTagRender}
allowClear
options={['raw', 'json', 'clash'].map((v) => ({
value: v,
+1 -1
View File
@@ -180,7 +180,7 @@ export default function HostsPage() {
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} size="large">
<Spin aria-live="off" spinning={!fetched} delay={200} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Modal, Select, Typography, message } from 'antd';
import { HttpUtil } from '@/utils';
import { SelectAllClearButtons } from '@/components/form';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { DBInbound } from '@/models/dbinbound';
@@ -132,6 +132,7 @@ export default function CloneInboundModal({
<Select
aria-label={t('pages.inbounds.deployTo')}
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targets}
onChange={setTargets}
@@ -294,6 +294,7 @@ export default function InboundsPage() {
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue;
if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue;
@@ -719,6 +720,7 @@ export default function InboundsPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={!fetched || !hostsFetched}
delay={200}
description={t('loading')}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { selectTagRender } from '@/components/form';
import { HttpUtil } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
@@ -207,6 +208,7 @@ export default function AttachClientsModal({
) : (
<Select
mode="multiple"
tagRender={selectTagRender}
aria-label={t('pages.inbounds.attachClientsTargets')}
style={{ width: '100%' }}
value={targetIds}
@@ -200,7 +200,7 @@ export default function AttachExistingClientsModal({
{noClients ? (
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
) : (
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Space wrap>
@@ -10,6 +10,7 @@ import {
import type { FallbackRow } from '@/schemas/forms/inbound-form';
interface FallbacksCardProps {
canAdd?: boolean;
fallbacks: FallbackRow[];
fallbackChildOptions: { label: string; value: number }[];
addFallback: () => void;
@@ -20,6 +21,7 @@ interface FallbacksCardProps {
}
export default function FallbacksCard({
canAdd = true,
fallbacks,
fallbackChildOptions,
addFallback,
@@ -38,6 +40,7 @@ export default function FallbacksCard({
size="small"
icon={<PlusOutlined aria-hidden />}
onClick={addFallback}
disabled={!canAdd}
>
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
@@ -45,7 +48,9 @@ export default function FallbacksCard({
size="small"
onClick={addAllFallbacks}
disabled={
fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length
!canAdd ||
fallbackChildOptions.length === 0 ||
fallbacks.length >= fallbackChildOptions.length
}
title={t('pages.inbounds.form.addAllFallbackTooltip')}
>
@@ -226,11 +226,14 @@ export default function InboundFormModal({
const getV = methods.getValues as unknown as (name?: string) => unknown;
const control = methods.control;
const [saving, setSaving] = useState(false);
const savedInboundId = useRef<number | null>(null);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
const [activeTab, setActiveTab] = useState('basic');
const {
fallbacks,
hasStoredFallbacks,
fallbacksLoaded,
fallbackChildOptions,
loadFallbacks,
saveFallbacks,
@@ -250,6 +253,7 @@ export default function InboundFormModal({
* always empty. Offer it only then; `listen`/`custom` work for local inbounds.
*/
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
const vlessDecryption = useWatch({ control, name: 'settings.decryption' }) ?? '';
const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
const ssMethod = useWatch({ control, name: 'settings.method' });
const isSSWith2022 = isSS2022({
@@ -295,7 +299,10 @@ export default function InboundFormModal({
streamSettings: { network },
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
});
const hasVlessDecryption =
protocol === Protocols.VLESS && vlessDecryption !== '' && vlessDecryption !== 'none';
const isFallbackHost =
!hasVlessDecryption &&
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
(security === 'tls' || security === 'reality');
@@ -437,6 +444,7 @@ export default function InboundFormModal({
? withoutClients(rawInboundToFormValues(dbInbound))
: buildAddModeValues();
methods.reset(initial);
savedInboundId.current = null;
setScanResult(null);
setActiveTab('basic');
const initialTag = (initial.tag ?? '') as string;
@@ -573,19 +581,46 @@ export default function InboundFormModal({
);
return;
}
if (saving) return;
if (!fallbacksLoaded) {
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
return;
}
if (!isFallbackHost && fallbacks.length > 0) {
setActiveTab('protocol');
messageApi.error(
t(
hasVlessDecryption
? 'pages.inbounds.fallbacks.decryptionConflict'
: 'pages.inbounds.fallbacks.needsTls',
),
);
return;
}
setSaving(true);
try {
const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
const url =
mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}`
: '/panel/api/inbounds/add';
// Persist an explicit removal before the backend checks the new decryption setting.
if (!isFallbackHost && hasStoredFallbacks && dbInbound) {
if (!(await saveFallbacks(dbInbound.id))) {
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
return;
}
}
const existingId = mode === 'edit' ? dbInbound?.id : savedInboundId.current;
const payload = formValuesToWirePayload(parsed.data, { omitClients: !!existingId });
const url = existingId
? `/panel/api/inbounds/update/${existingId}`
: '/panel/api/inbounds/add';
const msg = await HttpUtil.post(url, payload);
if (msg?.success) {
if (isFallbackHost) {
const obj = msg.obj as { id?: number; Id?: number } | null;
const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0);
if (masterId) await saveFallbacks(masterId);
const masterId = existingId ?? obj?.id ?? obj?.Id ?? 0;
if (masterId) savedInboundId.current = masterId;
if (masterId && !(await saveFallbacks(masterId))) {
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
return;
}
}
onSaved();
onClose();
@@ -783,6 +818,7 @@ export default function InboundFormModal({
const fallbacksCard = (
<FallbacksCard
canAdd={isFallbackHost && fallbacksLoaded}
fallbacks={fallbacks}
fallbackChildOptions={fallbackChildOptions}
addFallback={addFallback}
@@ -834,9 +870,18 @@ export default function InboundFormModal({
/>
)}
{isFallbackHost && fallbacksCard}
{(isFallbackHost || fallbacks.length > 0) && fallbacksCard}
{hasVlessDecryption && (
<Alert
className="mt-12"
type="warning"
showIcon
title={t('pages.inbounds.fallbacks.decryptionConflict')}
/>
)}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
!hasVlessDecryption &&
!isFallbackHost && (
<Alert
className="mt-12"
@@ -1175,7 +1220,9 @@ export default function InboundFormModal({
Protocols.TUIC,
Protocols.MASQUE,
] as string[]
).includes(protocol) || isFallbackHost
).includes(protocol) ||
isFallbackHost ||
fallbacks.length > 0
? [
{
key: 'protocol',
@@ -239,7 +239,7 @@ export default function AmneziawgFields({
<Alert
type={kernelModeOn ? 'warning' : 'info'}
showIcon
message={t('pages.xray.amneziawg.kernelUnavailable')}
title={t('pages.xray.amneziawg.kernelUnavailable')}
description={
<>
<ul style={{ margin: '0 0 8px', paddingInlineStart: 20 }}>
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select } from 'antd';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
@@ -14,7 +15,7 @@ export function MasqueSettingsFields() {
name={['settings', 'address']}
required
>
<Select mode="tags" tokenSeparators={[',', ' ']} open={false} />
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',', ' ']} open={false} />
</FormField>
<FormField label={t('pages.inbounds.info.mtu')} name={['settings', 'mtu']}>
<InputNumber min={1280} max={65535} placeholder="1280" style={{ width: '100%' }} />
@@ -16,6 +16,7 @@ import {
import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { HttpUtil } from '@/utils';
@@ -210,6 +211,7 @@ export default function TuicFields() {
<FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
options={[
{ label: 'h3', value: 'h3' },
@@ -4,6 +4,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
interface StringListProps {
@@ -46,9 +47,10 @@ function StringList({ name, label, tooltip, placeholder }: StringListProps) {
);
}
// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values.
// Named autoSystemWfpBlockLeak values; xray-core 26.10.31 also takes typed "tcp/<port>" or "udp/<port>".
const WFP_BLOCK_LEAK_OPTIONS = [
{ value: 'dns', label: 'dns' },
{ value: 'windnsclient', label: 'windnsclient' },
{ value: 'misconfigtun', label: 'misconfigtun' },
];
@@ -110,7 +112,9 @@ export default function TunFields() {
>
<Select
id="tun-wfp-block-leak"
mode="multiple"
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
allowClear
options={WFP_BLOCK_LEAK_OPTIONS}
/>
@@ -17,6 +17,7 @@ import {
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import {
@@ -208,7 +209,12 @@ export default function RealityForm({
</Form.Item>
)}
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ width: '100%' }}
/>
</FormField>
<FormField
name={['streamSettings', 'realitySettings', 'maxTimediff']}
@@ -251,6 +257,7 @@ export default function RealityForm({
<Select
id={`${uid}-shortIds`}
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ flex: 1 }}
/>
@@ -20,7 +20,7 @@ import {
} from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { CipherSuitesSelect } from '@/components/form';
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
import { FormField } from '@/components/form/rhf';
import {
@@ -380,6 +380,7 @@ export default function TlsForm({
<FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
<Select
mode="multiple"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ width: '100%' }}
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
@@ -392,6 +393,7 @@ export default function TlsForm({
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
style={{ width: '100%' }}
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
@@ -478,6 +480,7 @@ export default function TlsForm({
id={`${uid}-pinnedPeerCertSha256`}
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 64px)' }}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { SockoptCustomField } from '@/lib/xray/forms/fields';
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
@@ -228,6 +229,7 @@ export default function SockoptForm({
>
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
options={[
@@ -10,6 +10,8 @@ import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) {
const fallbackKeyRef = useRef(0);
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
const [hasStoredFallbacks, setHasStoredFallbacks] = useState(false);
const [fallbacksLoaded, setFallbacksLoaded] = useState(false);
const fallbackChildOptions = (dbInbounds || [])
.filter((ib) => ib.id !== dbInbound?.id)
@@ -19,7 +21,10 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
}));
const loadFallbacks = async (masterId: number | null) => {
setFallbacksLoaded(false);
setHasStoredFallbacks(false);
if (!masterId) {
setFallbacksLoaded(true);
setFallbacks([]);
return;
}
@@ -28,6 +33,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
setFallbacks([]);
return;
}
setFallbacksLoaded(true);
setHasStoredFallbacks(msg.obj.length > 0);
setFallbacks(
(
msg.obj as {
@@ -68,6 +75,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, {
headers: { 'Content-Type': 'application/json' },
});
if (msg?.success) setHasStoredFallbacks(payload.fallbacks.length > 0);
return !!msg?.success;
};
@@ -181,6 +189,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
return {
fallbacks,
hasStoredFallbacks,
fallbacksLoaded,
fallbackChildOptions,
loadFallbacks,
saveFallbacks,
@@ -69,7 +69,9 @@ export default function InboundInfoModal({
if (!clientStats?.email) return;
setRefreshing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`);
const msg = await HttpUtil.post(
`/panel/api/clients/ips/${encodeURIComponent(clientStats.email)}`,
);
if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record');
setClientIpsArray([]);
@@ -101,7 +103,9 @@ export default function InboundInfoModal({
const clearClientIps = useCallback(async () => {
if (!clientStats?.email) return;
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`);
const msg = await HttpUtil.post(
`/panel/api/clients/clearIps/${encodeURIComponent(clientStats.email)}`,
);
if (msg?.success) {
setClientIpsArray([]);
setClientIpsText(t('tgbot.noIpRecord'));
@@ -223,7 +227,8 @@ export default function InboundInfoModal({
setClientIpsText('');
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) {
void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => {
const email = encodeURIComponent(stats.email);
void HttpUtil.post(`/panel/api/clients/ips/${email}`).then((msg) => {
if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record');
return;
+3 -3
View File
@@ -19,8 +19,8 @@ interface GeodataSectionProps {
}
const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
// Xray resolves `file` inside its asset directory; since 26.10.29 a scheduled one must be .dat.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.dat$/i;
function fileNameFromUrl(url: string): string {
try {
@@ -178,7 +178,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
<div>
{modalContextHolder}
{messageContextHolder}
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item
+1
View File
@@ -174,6 +174,7 @@ export default function IndexPage() {
<Layout className="content-shell">
<Layout.Content className="content-area">
<Spin
aria-live="off"
spinning={loading || !fetched}
delay={200}
description={loading ? loadingTip : t('loading')}
+1 -1
View File
@@ -103,7 +103,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
return (
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
{modalContextHolder}
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
<Collapse
accordion
activeKey={activeKey}
+12 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useId, useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
@@ -31,6 +31,7 @@ export default function VitalTile({
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const labelId = useId();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
@@ -39,10 +40,18 @@ export default function VitalTile({
);
return (
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
<Card
hoverable
className="ov-tile"
styles={{ body: { padding: 0 } }}
role="group"
aria-labelledby={labelId}
>
<div className="ov-tile-head">
<span className="ov-tile-icon">{icon}</span>
<span className="ov-kicker">{label}</span>
<span className="ov-kicker" id={labelId}>
{label}
</span>
</div>
<div className="ov-tile-value">
@@ -14,6 +14,7 @@ import {
message,
} from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils';
@@ -431,6 +432,7 @@ export default function NodeFormModal({
>
<Select
mode="multiple"
tagRender={selectTagRender}
allowClear
loading={fetchingInbounds}
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
+11 -4
View File
@@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
</span>
);
}
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
// A sub-node's status is free text from its parent node: show it as unknown, not as a key.
return (
<span>
{t(`pages.nodes.statusValues.${status || 'unknown'}`, {
defaultValue: t('pages.nodes.statusValues.unknown'),
})}
</span>
);
}
function formatPct(p?: number, available = true): string {
@@ -188,11 +195,11 @@ export default function NodeList({
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
// Map a node GUID to its display name so a transitive sub-node can show which
// parent it is reached through (#4983).
// Map a direct node's GUID to its display name so a transitive sub-node can show
// which parent it is reached through (#4983); the master walks one level only.
const nameByGuid = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid);
return m;
}, [nodes]);
+7 -1
View File
@@ -290,7 +290,13 @@ export default function NodesPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
<Spin
aria-live="off"
spinning={!fetched}
delay={200}
description={t('loading')}
size="large"
>
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
@@ -9,6 +9,7 @@ import {
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { selectTagRender } from '@/components/form';
import type { AllSetting } from '@/models/setting';
import { isOutboundProtocol } from '@/schemas/primitives';
import { HttpUtil, LanguageManager } from '@/utils';
@@ -637,6 +638,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<>
<Select
mode="multiple"
tagRender={selectTagRender}
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
+1 -1
View File
@@ -381,7 +381,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
</Button>
</div>
<Spin spinning={apiTokensLoading}>
<Spin aria-live="off" spinning={apiTokensLoading}>
{!apiTokens.length && !apiTokensLoading && (
<Empty
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
@@ -242,6 +242,7 @@ export default function SettingsPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={spinning || !fetched}
delay={200}
description={t('loading')}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
@@ -166,8 +167,8 @@ export default function SubBalancerFormModal({
>
<Select
mode="multiple"
tagRender={selectTagRender}
options={inboundOptions}
maxTagCount="responsive"
listHeight={220}
showSearch={{ optionFilterProp: 'label' }}
/>
@@ -13,7 +13,7 @@ import {
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { GoRegexInput, selectTagRender } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
@@ -418,6 +418,7 @@ export default function SubscriptionFormatsTab({
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('direct', 'ip')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
@@ -434,6 +435,7 @@ export default function SubscriptionFormatsTab({
>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('direct', 'domain')}
style={{ width: '100%' }}
onChange={(v) =>
@@ -467,6 +469,7 @@ export default function SubscriptionFormatsTab({
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('block', 'domain')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
@@ -476,6 +479,7 @@ export default function SubscriptionFormatsTab({
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('block', 'ip')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
+1 -1
View File
@@ -72,7 +72,7 @@ export default function SponsorsPage() {
<BecomeButton contact={data.contact} />
</div>
<Spin spinning={!fetched} delay={200}>
<Spin aria-live="off" spinning={!fetched} delay={200}>
{sponsors.length === 0 ? (
fetched && <YourBrandCard contact={data.contact} />
) : (
+1
View File
@@ -319,6 +319,7 @@ export default function XrayPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={spinning || !fetched}
delay={200}
description={t('loading')}
@@ -6,6 +6,7 @@ import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { Path } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
@@ -253,6 +254,7 @@ export default function BalancerFormModal({
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
+2
View File
@@ -23,6 +23,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import { selectTagRender } from '@/components/form';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -417,6 +418,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
/>
<Select
mode="tags"
tagRender={selectTagRender}
value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')}
@@ -4,6 +4,7 @@ import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from '
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
@@ -274,6 +275,7 @@ export default function FreedomFields() {
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Select } from 'antd';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function MasqueFields() {
@@ -9,6 +10,7 @@ export default function MasqueFields() {
<FormField label={t('pages.xray.outboundForm.remoteDNS')} name={['settings', 'remoteDNS']}>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
open={false}
placeholder="1.1.1.1, 2606:4700:4700::1111"
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Input, Select, Switch } from 'antd';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
@@ -20,7 +21,7 @@ export default function TlsForm() {
/>
</FormField>
<FormField label="ALPN" name={['streamSettings', 'tlsSettings', 'alpn']}>
<Select mode="multiple" options={ALPN_OPTIONS} />
<Select mode="multiple" tagRender={selectTagRender} options={ALPN_OPTIONS} />
</FormField>
<FormField label="ECH" name={['streamSettings', 'tlsSettings', 'echConfigList']}>
<Input />
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { HeaderMapEditor, selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
@@ -79,6 +79,7 @@ export default function MasqueForm() {
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
open={false}
placeholder="172.16.0.2, 2606:4700:110:8a36::2"
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Select, Switch } from 'antd';
import { selectTagRender } from '@/components/form';
import { SettingListItem } from '@/components/ui';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import {
@@ -105,6 +106,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={blockedIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
@@ -119,6 +121,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={blockedDomains}
style={{ width: '100%' }}
options={BLOCK_DOMAINS_OPTIONS}
@@ -140,6 +143,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={directIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
@@ -159,6 +163,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={directDomains}
style={{ width: '100%' }}
options={DOMAINS_OPTIONS}
@@ -179,6 +184,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={ipv4Domains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
@@ -152,9 +152,7 @@ export default function RuleCardList({
<span className="flow-arrow">→</span>
<div className="flow-side flow-side-target">
<span className="flow-label">
{rule.balancerTag
? t('pages.xray.balancer') || 'Balancer'
: t('pages.xray.Outbounds')}
{rule.balancerTag ? t('pages.xray.rules.balancer') : t('pages.xray.Outbounds')}
</span>
{rule.outboundTag ? (
<Tag color="green" className="flow-tag">
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Switch } from 'antd';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { InputAddon } from '@/components/ui';
import { GeoTokenInput } from '@/components/geodata';
import { FormField } from '@/components/form/rhf';
@@ -222,7 +223,11 @@ export default function RuleFormModal({
</FormField>
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
<Select
mode="multiple"
tagRender={selectTagRender}
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
/>
</FormField>
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
@@ -292,6 +297,7 @@ export default function RuleFormModal({
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
allowClear
loading={clientsLoading}
@@ -319,6 +325,7 @@ export default function RuleFormModal({
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
tagRender={selectTagRender}
options={inboundTags.map((tag) => ({
value: tag,
label: formatInboundTag(tag, remarkByTag),
@@ -54,7 +54,7 @@ export const AmneziawgServerSchema = z.object({
publicKey: z.string().optional(),
subnetIp: z.string().default('10.8.1.0'),
subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)),
mtu: optionalClearedInt(z.number().int().min(1)),
mtu: optionalClearedInt(z.number().int().min(1).max(65535)),
primaryDns: z.string().default('8.8.8.8'),
secondaryDns: z.string().default('8.8.4.4'),
externalInterface: z.string().default(''),
@@ -71,10 +71,10 @@ export const AmneziawgServerSchema = z.object({
// embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding.
kernelMode: z.boolean().default(false).optional(),
// Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go):
// jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail.
jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
// Every handshake allocates jc packets of jmin..jmax bytes; s1-s4 are uint16.
jc: clearedToDefault(z.number().int().min(0).max(128).default(5)),
jmin: clearedToDefault(z.number().int().min(0).max(65535).default(10)),
jmax: clearedToDefault(z.number().int().min(0).max(65535).default(50)),
s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)),
s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)),
s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)),
@@ -10,7 +10,7 @@ export const TunInboundSettingsSchema = z.object({
userLevel: z.number().int().min(0).default(0),
autoSystemRoutingTable: z.array(z.string()).default([]),
autoOutboundsInterface: z.string().default('auto'),
// xray-core 26.9.30: the first acts on Linux, the second ("dns", "misconfigtun") on Windows.
// xray-core 26.9.30: the first acts on Linux, the second (named values or "tcp/<port>") on Windows.
autoSystemDnsToGateway: z.boolean().optional(),
autoSystemWfpBlockLeak: z.array(z.string()).optional(),
});
@@ -14,14 +14,14 @@ export type AmneziaWGOutboundPeer = z.infer<typeof AmneziaWGOutboundPeerSchema>;
export const AmneziaWGOutboundSettingsSchema = z.object({
// 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU);
// a pinned 1420 fragments every full-size packet once S4 exceeds 20.
mtu: z.number().int().min(0).default(0),
mtu: z.number().int().min(0).max(65535).default(0),
secretKey: z.string().default(''),
address: z.array(z.string()).default([]),
listenPort: z.number().int().min(0).max(65535).default(0),
dns: z.string().default(''),
jc: z.number().int().min(0).default(0),
jmin: z.number().int().min(0).default(40),
jmax: z.number().int().min(0).default(100),
jc: z.number().int().min(0).max(128).default(0),
jmin: z.number().int().min(0).max(65535).default(40),
jmax: z.number().int().min(0).max(65535).default(100),
// The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here.
s1: z.number().int().min(0).max(65535).default(15),
s2: z.number().int().min(0).max(65535).default(80),
@@ -2,7 +2,7 @@ import { z } from 'zod';
// Both XDRIVE ends carry the same service, folder and secrets; an absent tuning knob takes
// the core's default (segmentBytes and concurrency are capped there at 16 MiB / 64).
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template']);
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template', 's3']);
export type XDriveService = z.infer<typeof XDriveServiceSchema>;
// xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias.
@@ -22,10 +22,19 @@ export function liftLegacyXDriveService(stream: Record<string, unknown>): void {
const MillisSchema = z.number().int().min(0);
// S3-compatible storage (xray-core 26.10.31, #7231); an unset pathStyle means path-style addressing.
export const XDriveS3Schema = z.object({
endpoint: z.string().optional(),
region: z.string().optional(),
bucket: z.string().optional(),
pathStyle: z.boolean().optional(),
});
export const XDriveStreamSettingsSchema = z.object({
service: XDriveServiceInputSchema.default('google-drive'),
remoteFolder: z.string().default(''),
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken.
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken; S3 takes
// AccessKeyID, SecretAccessKey and an optional SessionToken.
secrets: z.array(z.string()).default([]),
segmentBytes: z.number().int().min(0).optional(),
flushIntervalMs: MillisSchema.optional(),
@@ -36,6 +45,7 @@ export const XDriveStreamSettingsSchema = z.object({
eagerWindowMs: MillisSchema.optional(),
holeTimeoutMs: MillisSchema.optional(),
template: z.record(z.string(), z.unknown()).optional(),
s3: XDriveS3Schema.optional(),
// Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no
// defaults are filled in here. A JSON subscription hands an inbound's value to clients.
xmux: z.record(z.string(), z.unknown()).optional(),
@@ -108,6 +108,8 @@ export const XHttpStreamSettingsSchema = z.preprocess(
serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
// Server-only Go net/http HTTP2Config (xray-core 26.10.31); never put into share links.
h2Params: z.record(z.string(), z.unknown()).optional(),
// Client-side fields stored on inbound for subscription propagation.
// The server listener ignores them at runtime, but the panel embeds
// them in share-link `extra` blobs so the same xhttp config can
@@ -42,9 +42,10 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
['s2', 1609],
['s3', 1637],
['s4', 33],
['jc', 4294967296],
['jmin', 4294967296],
['jmax', 5000000000],
['jc', 129],
['jmin', 65536],
['jmax', 65536],
['mtu', 65536],
];
it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => {
@@ -56,7 +57,9 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
['s2', 1608],
['s3', 1636],
['s4', 32],
['jc', 4294967295],
['jc', 128],
['jmax', 65535],
['mtu', 65535],
];
it.each(atLimit)('accepts %s exactly at its limit', (field, value) => {
+74
View File
@@ -0,0 +1,74 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
// The Spin/Select screen-reader guards walk oxlint's AST by hand, so an oxlint
// bump can kill them silently. These tests fail loudly when it does.
const FIXTURES = 'tools/oxlint/__fixtures__';
const SPIN_RULE = 'antd-a11y(spin-content-not-live)';
const SELECT_RULE = 'antd-a11y(select-tag-remove)';
function runGuard(target: string): string {
try {
execFileSync(
process.execPath,
[
'node_modules/oxlint/bin/oxlint',
'-c',
`${FIXTURES}/antd-a11y.oxlintrc.json`,
'-f',
'unix',
target,
],
{ encoding: 'utf8', stdio: 'pipe' },
);
return '';
} catch (error) {
const { status, stdout } = error as { status?: number | null; stdout?: string };
if (typeof status !== 'number') throw error;
return String(stdout ?? '');
}
}
function hitLines(output: string, rule: string): number[] {
return output
.split('\n')
.filter((line) => line.includes(rule))
.map((line) => Number(line.split(':')[1]));
}
describe('antd-a11y-guard oxlint plugin', () => {
it('flags a Spin whose content would be announced on every update', () => {
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SPIN_RULE)).toEqual([5]);
});
it('flags tag Selects without a focusable remove button or with hidden tags', () => {
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SELECT_RULE)).toEqual([6, 7]);
});
it('accepts quiet Spins, bare indicators, single Selects, tagRender and spreads', () => {
const output = runGuard(`${FIXTURES}/antd-a11y-ok.tsx`);
expect(output).not.toContain(SPIN_RULE);
expect(output).not.toContain(SELECT_RULE);
});
});
describe('antd-a11y-guard wiring', () => {
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
jsPlugins: string[];
overrides: { files: string[]; rules: Record<string, unknown> }[];
};
it('enforces both rules on app sources but not on stories or tests', () => {
expect(config.jsPlugins).toContain('./tools/oxlint/antd-a11y-guard.mjs');
for (const rule of ['antd-a11y/spin-content-not-live', 'antd-a11y/select-tag-remove']) {
expect(config.overrides.find((o) => o.rules[rule] === 'error')?.files).toEqual([
'src/**/*.tsx',
]);
expect(config.overrides.find((o) => o.rules[rule] === 'off')?.files).toEqual([
'src/**/*.stories.tsx',
'src/test/**',
]);
}
});
});
@@ -76,4 +76,33 @@ describe('GeodataSection', () => {
}
});
});
// xray-core 26.10.29 refuses a scheduled asset whose file is not .dat when it starts.
it('refuses to save a scheduled asset whose file is not .dat', async () => {
const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
new Msg(
true,
'',
JSON.stringify({
xraySetting: {
outbounds: [],
geodata: { cron: '0 4 * * *', assets: [CUSTOM_SOURCE] },
},
geodataSources: [],
}),
),
);
const user = userEvent.setup();
render(<GeodataSection active onBusy={vi.fn()} onClose={vi.fn()} />);
const file = await screen.findByDisplayValue(CUSTOM_SOURCE.file);
await user.clear(file);
await user.type(file, 'geosite_custom.mmdb');
await user.click(screen.getByRole('button', { name: 'Save & Restart Xray' }));
expect(await screen.findByText(/File names must be/)).toBeTruthy();
expect(screen.queryByText('Save geodata settings?')).toBeNull();
expect(post).toHaveBeenCalledTimes(1);
});
});
+12 -1
View File
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
/*
* Guards the 14-locale translation set two ways: every key in en-US must be
* Guards the 14-locale translation set: every key in en-US must be
* referenced somewhere in the frontend or Go sources (dead keys accumulate
* silently — this test deleted over two hundred of them when it was
* introduced), and every locale must carry exactly the en-US key set
@@ -82,6 +82,17 @@ describe('i18n keys', () => {
);
});
it('no literal t() key resolves to an object', () => {
const objectKeys = Array.from(
blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g),
(m) => m[2],
).filter((key) => enKeys.some((leaf) => leaf.startsWith(`${key}.`)));
expect(
objectKeys,
`t() keys naming a group, rendered as "key '…' returned an object instead of string.":\n ${objectKeys.join('\n ')}`,
).toEqual([]);
});
it('every locale carries exactly the en-US key set', () => {
const enSet = new Set(enKeys);
for (const file of readdirSync(translationDir)) {
@@ -228,6 +228,21 @@ describe('formValuesToWirePayload', () => {
expect(payload.sniffing).toBe('');
});
// xray-core 26.10.31 (#7295): the XHTTP server reads h2Params; the form must not strip it.
it('keeps an XHTTP inbound h2Params set through JSON', () => {
const h2Params = { MaxConcurrentStreams: 512, MaxReadFrameSize: 1048576 };
const values = rawInboundToFormValues({
...vlessRow,
streamSettings: {
network: 'xhttp',
security: 'none',
xhttpSettings: { path: '/x', mode: 'auto', h2Params },
},
});
const payload = formValuesToWirePayload(values);
expect(JSON.parse(payload.streamSettings).xhttpSettings.h2Params).toEqual(h2Params);
});
it('omits nodeId when null', () => {
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
const payload = formValuesToWirePayload(values);

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