Compare commits

..

68 Commits

Author SHA1 Message Date
MHSanaei c621ba9014 v3.11.0 2026-10-25 20:08:14 +01:00
MHSanaei 7f82daa42f feat(finalmask): tlshello fragment presets for client editors
The host, JSON subscription and outbound mask editors now offer PattN's
two chained tlshello fragment presets; a pick replaces the TCP mask list.

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

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

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

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

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

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

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

* fix(amneziawg): align readiness and endpoint reporting

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

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

* style(amneziawg): format readiness test

* test: reproduce kernel AWG client validation gaps

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

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

* test(amneziawgkern): cover owned interface lifecycle recovery

* fix(amneziawgkern): recover owned interfaces on restart

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

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

* style(amneziawgkern): satisfy static analysis

* test(amneziawgkern): cover long rendered peer comments

* fix(amneziawgkern): accept long generated config comments

* test(amneziawgkern): refuse foreign occupied interfaces

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

* test(amneziawgkern): cover stale interface collisions

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

* fix(amneziawgkern): verify interface ownership before mutation

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

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

* build: update Go toolchain to 1.27.3

* test(amneziawg): reproduce unsafe key rotation recovery

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

* fix(amneziawg): recover interrupted key rotations

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

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

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

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

* fix(xray): build core from pinned module

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

* fix(tests): stabilize local network fixtures

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Fixes #6928

* docs(traffic): describe persisted global heartbeat timestamps

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Restores TestNodeQuotaDisable_SameExpiryStillLatches as it was on main.

---------

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

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

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

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

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

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

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

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

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

* style(docs): format API scope table

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

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

* chore(ci): rerun flaky checks

---------

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

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

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

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+514 -11
View File
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "3X-UI Panel API", "title": "3X-UI Panel API",
"version": "3.x", "version": "3.x",
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password." "description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
}, },
"servers": [ "servers": [
{ {
@@ -16,7 +16,7 @@
"bearerAuth": { "bearerAuth": {
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "bearer",
"description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`." "description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
}, },
"cookieAuth": { "cookieAuth": {
"type": "apiKey", "type": "apiKey",
@@ -152,6 +152,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -242,6 +247,9 @@
"subClashPath": { "subClashPath": {
"type": "string" "type": "string"
}, },
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": { "subClashRules": {
"type": "string" "type": "string"
}, },
@@ -342,6 +350,10 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": { "subIncyAnnounceUrl": {
"type": "string" "type": "string"
}, },
@@ -636,6 +648,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -662,6 +675,7 @@
"subClashEnable", "subClashEnable",
"subClashEnableRouting", "subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRemoteDNS",
"subClashRules", "subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex", "subClashUserAgentRegex",
@@ -695,6 +709,7 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl", "subIncyAnnounceUrl",
"subIncyAppAutoDetect", "subIncyAppAutoDetect",
"subIncyBannerBgColor", "subIncyBannerBgColor",
@@ -928,6 +943,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -1018,6 +1038,9 @@
"subClashPath": { "subClashPath": {
"type": "string" "type": "string"
}, },
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": { "subClashRules": {
"type": "string" "type": "string"
}, },
@@ -1118,6 +1141,10 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": { "subIncyAnnounceUrl": {
"type": "string" "type": "string"
}, },
@@ -1420,6 +1447,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -1446,6 +1474,7 @@
"subClashEnable", "subClashEnable",
"subClashEnableRouting", "subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRemoteDNS",
"subClashRules", "subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex", "subClashUserAgentRegex",
@@ -1479,6 +1508,7 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl", "subIncyAnnounceUrl",
"subIncyAppAutoDetect", "subIncyAppAutoDetect",
"subIncyBannerBgColor", "subIncyBannerBgColor",
@@ -1673,6 +1703,23 @@
], ],
"type": "object" "type": "object"
}, },
"Blocker": {
"description": "Blocker is one unmet requirement.",
"properties": {
"code": {
"example": "tools",
"type": "string"
},
"detail": {
"example": "awg",
"type": "string"
}
},
"required": [
"code"
],
"type": "object"
},
"Client": { "Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": { "properties": {
@@ -1700,6 +1747,17 @@
"description": "Auth password (Hysteria)", "description": "Auth password (Hysteria)",
"type": "string" "type": "string"
}, },
"clientHostRuleIds": {
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": { "comment": {
"description": "Client comment", "description": "Client comment",
"type": "string" "type": "string"
@@ -1739,10 +1797,16 @@
"type": "string" "type": "string"
}, },
"keepAlive": { "keepAlive": {
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value", "description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
"nullable": true, "oneOf": [
{
"type": "integer" "type": "integer"
}, },
{
"type": "string"
}
]
},
"limitIp": { "limitIp": {
"description": "IP limit for this client", "description": "IP limit for this client",
"type": "integer" "type": "integer"
@@ -1931,6 +1995,16 @@
"auth": { "auth": {
"type": "string" "type": "string"
}, },
"clientHostRuleIds": {
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": { "comment": {
"type": "string" "type": "string"
}, },
@@ -1961,8 +2035,16 @@
"type": "integer" "type": "integer"
}, },
"keepAlive": { "keepAlive": {
"description": "Numeric values or AmneziaWG ranges.",
"oneOf": [
{
"type": "integer" "type": "integer"
}, },
{
"type": "string"
}
]
},
"limitHwid": { "limitHwid": {
"type": "integer" "type": "integer"
}, },
@@ -2738,6 +2820,10 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"randomCount": {
"example": 1,
"type": "integer"
},
"remark": { "remark": {
"example": "cdn-front", "example": "cdn-front",
"maxLength": 256, "maxLength": 256,
@@ -2763,6 +2849,15 @@
"sni": { "sni": {
"type": "string" "type": "string"
}, },
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": {}, "sockoptParams": {},
"sortOrder": { "sortOrder": {
"type": "integer" "type": "integer"
@@ -2810,11 +2905,13 @@
"path", "path",
"pinnedPeerCertSha256", "pinnedPeerCertSha256",
"port", "port",
"randomCount",
"remark", "remark",
"security", "security",
"serverDescription", "serverDescription",
"shuffleHost", "shuffleHost",
"sni", "sni",
"sniSharedRandom",
"sockoptParams", "sockoptParams",
"sortOrder", "sortOrder",
"tags", "tags",
@@ -2919,6 +3016,11 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"randomCount": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"remark": { "remark": {
"maxLength": 256, "maxLength": 256,
"type": "string" "type": "string"
@@ -2942,6 +3044,15 @@
"sni": { "sni": {
"type": "string" "type": "string"
}, },
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": { "sockoptParams": {
"type": "string" "type": "string"
}, },
@@ -2984,11 +3095,14 @@
"path", "path",
"pinnedPeerCertSha256", "pinnedPeerCertSha256",
"port", "port",
"randomCount",
"remark", "remark",
"security", "security",
"serverDescription", "serverDescription",
"shuffleHost", "shuffleHost",
"sni", "sni",
"sniSharedRandom",
"snis",
"sockoptParams", "sockoptParams",
"sortOrder", "sortOrder",
"tags", "tags",
@@ -3664,6 +3778,13 @@
"transitive": { "transitive": {
"type": "boolean" "type": "boolean"
}, },
"unavailableMetrics": {
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": { "updatedAt": {
"example": 1700000000, "example": 1700000000,
"format": "int64", "format": "int64",
@@ -3972,6 +4093,12 @@
"example": false, "example": false,
"type": "boolean" "type": "boolean"
}, },
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": { "updatedAt": {
"example": 1700003600, "example": 1700003600,
"format": "int64", "format": "int64",
@@ -4184,6 +4311,12 @@
"example": "online", "example": "online",
"type": "string" "type": "string"
}, },
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"uptimeSecs": { "uptimeSecs": {
"example": 86400, "example": 86400,
"format": "int64", "format": "int64",
@@ -4215,6 +4348,37 @@
], ],
"type": "object" "type": "object"
}, },
"Readiness": {
"description": "Readiness is the host's ability to run any kernel-mode inbound at all.",
"properties": {
"blockers": {
"example": [
{
"code": "tools",
"detail": "awg"
}
],
"items": {
"$ref": "#/components/schemas/Blocker"
},
"type": "array"
},
"installCommand": {
"example": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"type": "string"
},
"ready": {
"example": false,
"type": "boolean"
}
},
"required": [
"blockers",
"installCommand",
"ready"
],
"type": "object"
},
"RealityScanResult": { "RealityScanResult": {
"properties": { "properties": {
"alpn": { "alpn": {
@@ -4397,6 +4561,10 @@
"keepaliveTimeout": { "keepaliveTimeout": {
"type": "string" "type": "string"
}, },
"kernelMode": {
"description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.",
"type": "boolean"
},
"maxHandshakeAttempts": { "maxHandshakeAttempts": {
"type": "string" "type": "string"
}, },
@@ -4651,6 +4819,35 @@
], ],
"type": "object" "type": "object"
}, },
"TgBotCapabilities": {
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
"properties": {
"groupPrivacy": {
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
"example": false,
"type": "boolean"
},
"inlineEnabled": {
"example": true,
"type": "boolean"
},
"running": {
"example": true,
"type": "boolean"
},
"username": {
"example": "xui_bot",
"type": "string"
}
},
"required": [
"groupPrivacy",
"inlineEnabled",
"running",
"username"
],
"type": "object"
},
"Traffic": { "Traffic": {
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.", "description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
"properties": { "properties": {
@@ -4855,7 +5052,7 @@
}, },
{ {
"name": "API Tokens", "name": "API Tokens",
"description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation." "description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
}, },
{ {
"name": "Xray Settings", "name": "Xray Settings",
@@ -5319,6 +5516,51 @@
} }
} }
}, },
"/panel/api/inbounds/amneziawgKernelReadiness": {
"get": {
"tags": [
"Inbounds"
],
"summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.",
"operationId": "get_panel_api_inbounds_amneziawgKernelReadiness",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/Readiness"
}
}
},
"example": {
"success": true,
"obj": {
"blockers": [
{
"code": "tools",
"detail": "awg"
}
],
"installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"ready": false
}
}
}
}
}
}
}
},
"/panel/api/inbounds/allLinks": { "/panel/api/inbounds/allLinks": {
"get": { "get": {
"tags": [ "tags": [
@@ -7184,7 +7426,7 @@
"tags": [ "tags": [
"Server" "Server"
], ],
"summary": "Self-update the panel to the latest version. The server restarts on success.", "summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
"operationId": "post_panel_api_server_updatePanel", "operationId": "post_panel_api_server_updatePanel",
"requestBody": { "requestBody": {
"required": false, "required": false,
@@ -8481,6 +8723,81 @@
} }
} }
}, },
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch clients by subscription ID.",
"operationId": "get_panel_api_clients_get_subId_subId",
"description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.",
"parameters": [
{
"name": "subId",
"in": "path",
"required": true,
"description": "Exact subscription ID (URL-encoded).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"client": {
"$ref": "#/components/schemas/ClientRecord"
},
"inboundIds": {
"type": "array",
"items": {
"type": "integer"
}
},
"externalLinks": {
"type": "array",
"items": {
"type": "object"
}
},
"usedTraffic": {
"type": "integer",
"format": "int64"
},
"tunnelAllowedIPs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/tgId/{tgId}": { "/panel/api/clients/get/tgId/{tgId}": {
"get": { "get": {
"tags": [ "tags": [
@@ -10983,6 +11300,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
@@ -11184,6 +11504,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
@@ -11334,6 +11657,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
@@ -11557,6 +11883,9 @@
"memPct": 45.2, "memPct": 45.2,
"panelVersion": "v3.x.x", "panelVersion": "v3.x.x",
"status": "online", "status": "online",
"unavailableMetrics": [
""
],
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
"xrayState": "", "xrayState": "",
@@ -11909,11 +12238,16 @@
"" ""
], ],
"port": 0, "port": 0,
"randomCount": 1,
"remark": "", "remark": "",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "", "sockoptParams": "",
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -11930,6 +12264,98 @@
} }
} }
}, },
"/panel/api/hosts/raw": {
"get": {
"tags": [
"Hosts"
],
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
"operationId": "get_panel_api_hosts_raw",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"address": "cdn.example.com",
"allowInsecure": false,
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"groupId": "",
"hostHeader": "",
"id": 1,
"inboundId": 1,
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": null,
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
""
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": "443"
}
]
}
}
}
}
}
}
},
"/panel/api/hosts/get/{groupId}": { "/panel/api/hosts/get/{groupId}": {
"get": { "get": {
"tags": [ "tags": [
@@ -12004,11 +12430,16 @@
"" ""
], ],
"port": 0, "port": 0,
"randomCount": 1,
"remark": "", "remark": "",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "", "sockoptParams": "",
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12102,11 +12533,16 @@
"" ""
], ],
"port": 0, "port": 0,
"randomCount": 1,
"remark": "", "remark": "",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "", "sockoptParams": "",
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12250,11 +12686,16 @@
"" ""
], ],
"port": 8443, "port": 8443,
"randomCount": 1,
"remark": "cdn-front", "remark": "cdn-front",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null, "sockoptParams": null,
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12372,11 +12813,16 @@
"" ""
], ],
"port": 8443, "port": 8443,
"randomCount": 1,
"remark": "cdn-front", "remark": "cdn-front",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null, "sockoptParams": null,
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12494,7 +12940,7 @@
"tags": [ "tags": [
"Hosts" "Hosts"
], ],
"summary": "Set host group sort order by the position of each groupId in the array.", "summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
"operationId": "post_panel_api_hosts_reorder", "operationId": "post_panel_api_hosts_reorder",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -12624,11 +13070,16 @@
"" ""
], ],
"port": 8443, "port": 8443,
"randomCount": 1,
"remark": "cdn-front", "remark": "cdn-front",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null, "sockoptParams": null,
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -13115,6 +13566,47 @@
} }
} }
}, },
"/panel/api/setting/tgBotCapabilities": {
"post": {
"tags": [
"Settings"
],
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
"operationId": "post_panel_api_setting_tgBotCapabilities",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/TgBotCapabilities"
}
}
},
"example": {
"success": true,
"obj": {
"groupPrivacy": false,
"inlineEnabled": true,
"running": true,
"username": "xui_bot"
}
}
}
}
}
}
}
},
"/panel/api/setting/testDiscord": { "/panel/api/setting/testDiscord": {
"post": { "post": {
"tags": [ "tags": [
@@ -13208,9 +13700,11 @@
"obj": [ "obj": [
{ {
"id": 1, "id": 1,
"name": "default", "name": "central-panel-a",
"enabled": true, "enabled": true,
"createdAt": 1736000000 "createdAt": 1736000000,
"scope": "node-admin",
"expiresAt": 0
} }
] ]
} }
@@ -13240,7 +13734,13 @@
}, },
"scope": { "scope": {
"type": "string", "type": "string",
"description": "admin (default), monitor, or node-sync." "enum": [
"admin",
"monitor",
"node-sync",
"node-admin"
],
"description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
}, },
"expiresAt": { "expiresAt": {
"type": "integer", "type": "integer",
@@ -13253,7 +13753,7 @@
}, },
"example": { "example": {
"name": "central-panel-a", "name": "central-panel-a",
"scope": "node-sync", "scope": "node-admin",
"expiresAt": 1798761600000 "expiresAt": 1798761600000
} }
} }
@@ -16387,6 +16887,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
+6
View File
@@ -86,6 +86,12 @@ fields) is pushed to child nodes verbatim, so the node's Xray records the real I
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
shows up on the parent automatically. shows up on the parent automatically.
Reports retain each address's newest timestamp within the 30-minute retention window.
Flat and per-node IP blobs use a stable timestamp-ascending order, with IP text breaking
ties, so equivalent reports preserve the same bytes and skip redundant writes. Flat-row
merges use compare-and-set retries to preserve concurrent observations; advancing a
timestamp remains a real change. GUID attribution stays separate from the flat union.
## Security note ## Security note
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
+25 -2
View File
@@ -9,7 +9,9 @@
"jsx-a11y" "jsx-a11y"
], ],
"jsPlugins": [ "jsPlugins": [
"./tools/oxlint/input-number-guard.mjs" "./tools/oxlint/input-number-guard.mjs",
"./tools/oxlint/control-label-guard.mjs",
"./tools/oxlint/icon-a11y-guard.mjs"
], ],
"categories": { "categories": {
"correctness": "error" "correctness": "error"
@@ -48,7 +50,9 @@
"react-hooks/exhaustive-deps": "error", "react-hooks/exhaustive-deps": "error",
"jsx-a11y/no-autofocus": "off", "jsx-a11y/no-autofocus": "off",
"input-number/no-synthetic-clear": "off", "input-number/no-synthetic-clear": "off",
"jsx-a11y/prefer-tag-over-role": "off" "jsx-a11y/prefer-tag-over-role": "off",
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off"
}, },
"overrides": [ "overrides": [
{ {
@@ -67,6 +71,25 @@
"rules": { "rules": {
"input-number/no-synthetic-clear": "off" "input-number/no-synthetic-clear": "off"
} }
},
{
"files": [
"src/**/*.tsx"
],
"rules": {
"control-label/has-name": "error",
"antd-icon/explicit-a11y": "error"
}
},
{
"files": [
"src/**/*.stories.tsx",
"src/test/**"
],
"rules": {
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off"
}
} }
] ]
} }
+2
View File
@@ -15,6 +15,8 @@ const config: StorybookConfig = {
if (viteConfig.build) { if (viteConfig.build) {
viteConfig.build.outDir = undefined; viteConfig.build.outDir = undefined;
viteConfig.build.emptyOutDir = false; viteConfig.build.emptyOutDir = false;
// Storybook bundles the whole UI plus the axe engine; hold it to the app's chunk ceiling.
viteConfig.build.chunkSizeWarningLimit = 1500;
if (viteConfig.build.rollupOptions) { if (viteConfig.build.rollupOptions) {
viteConfig.build.rollupOptions.input = undefined; viteConfig.build.rollupOptions.input = undefined;
} }
+9 -6
View File
@@ -33,11 +33,12 @@ The `@` import alias maps to `src/`.
- Function components + hooks only; no class components. - Function components + hooks only; no class components.
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the - Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine. *why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` + - TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with React Hook
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`); Form's `useForm` + `FormField` from `@/components/form/rhf` (wrap the tree in
validate through the `zodResolver` or per-field `FormProvider`) and validate each field with
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays issue keys resolved via `t()`, never inline `z.string()`. A whole-form
`zodResolver` is only for a form that submits one schema. AntD `<Form>` stays
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing / only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
`src/lib/xray/forms/fields/`, bound via a `Controller`. `src/lib/xray/forms/fields/`, bound via a `Controller`.
@@ -54,8 +55,10 @@ The `@` import alias maps to `src/`.
## Adding a panel route ## Adding a panel route
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component). 1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
2. Register in `src/routes.tsx` under `/panel` (lazy import). 2. Add it to `src/routes/panelRoutes.tsx`. The router, the sidebar, the page
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav. title and the command palette all read that one list;
`src/test/navigation-contract.test.tsx` holds it to the router and to the
translation bundle.
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` + Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
`rollupOptions.input` (in `vite.config.js`) + a Go controller route. `rollupOptions.input` (in `vite.config.js`) + a Go controller route.
+19 -16
View File
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
| Command | What | | Command | What |
|---|---| |---|---|
| `npm run dev` | Vite dev server with API + WS proxy to Go | | `npm run dev` | Vite dev server with API + WS proxy to Go |
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` | | `npm run build` | Regenerates the OpenAPI spec, then builds into `../internal/web/dist/` |
| `npm run preview` | Serve the built bundle locally | | `npm run preview` | Serve the built bundle locally |
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) | | `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) | | `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
@@ -42,7 +42,7 @@ production-style links work without round-tripping through Go.
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) | | `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story | | `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` | | `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` | | `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types,schemas,examples}.ts` |
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
`build-storybook` on every PR (see `../.github/workflows/ci.yml`). `build-storybook` on every PR (see `../.github/workflows/ci.yml`).
@@ -156,9 +156,10 @@ Patterns:
security. security.
- **Three validation layers**, non-overlapping: - **Three validation layers**, non-overlapping:
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack - API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
Query `queryFn` — warn-only in prod, throws in dev Query `queryFn` — warns on a mismatch and returns the payload; pass
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` — `{ strict: true }` to throw instead
blocks submit + per-field inline error - Form input: `rules={{ validate: rhfZodValidate(schema.shape.field) }}` on
every `FormField` — blocks submit + per-field inline error
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws, - Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
because a malformed payload here is always a developer bug because a malformed payload here is always a developer bug
- **No `.loose()` or `[key: string]: any`** in production schemas. - **No `.loose()` or `[key: string]: any`** in production schemas.
@@ -166,27 +167,29 @@ Patterns:
## Form pattern (Pattern A) ## Form pattern (Pattern A)
All non-trivial modals use this single pattern: All non-trivial modals use this single pattern — React Hook Form for the
state, AntD's `<Form>` only as the layout:
```tsx ```tsx
const [form] = Form.useForm<InboundFormValues>(); const form = useForm<InboundFormValues>({ defaultValues: initialValues });
const onFinish = async () => { const onSubmit = (values: InboundFormValues) => createInbound.mutateAsync(values);
const values = await form.validateFields();
await createInbound.mutateAsync(values);
};
<Form form={form} onFinish={onFinish}> <FormProvider {...form}>
<Form.Item <Form layout="vertical" onFinish={form.handleSubmit(onSubmit)}>
<FormField
name="port" name="port"
label="Port" label={t('pages.inbounds.port')}
rules={[antdRule(InboundFormSchema.shape.port, t)]} rules={{ validate: rhfZodValidate(InboundFormSchema.shape.port) }}
> >
<InputNumber min={1} max={65535} /> <InputNumber min={1} max={65535} />
</Form.Item> </FormField>
</Form> </Form>
</FormProvider>
``` ```
`FormField` (from `@/components/form/rhf`) bridges a React Hook Form
`Controller` to an AntD input and renders the field's error as help text.
No `safeParse`-on-submit handlers, no `useRef<any>` for form No `safeParse`-on-submit handlers, no `useRef<any>` for form
references, no inline `z.string().min(1)` in rules. Conditional references, no inline `z.string().min(1)` in rules. Conditional
fields use `<Form.Item dependencies={...} shouldUpdate>` with the fields use `<Form.Item dependencies={...} shouldUpdate>` with the
+885 -911
View File
File diff suppressed because it is too large Load Diff
+30 -34
View File
@@ -24,12 +24,13 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api", "gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node scripts/build-openapi.mjs", "gen:api": "node scripts/build-openapi.mjs",
"audit": "npm audit --omit=dev --audit-level=high",
"gen:zod": "cd .. && go run ./tools/openapigen", "gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true" "prepare": "cd .. && husky frontend/.husky || true"
}, },
"lint-staged": { "lint-staged": {
"src/**/*.{ts,tsx}": [ "src/**/*.{ts,tsx}": [
"oxfmt", "oxfmt --no-error-on-unmatched-pattern",
"oxlint --fix" "oxlint --fix"
] ]
}, },
@@ -37,53 +38,53 @@
"@ant-design/icons": "^6.3.4", "@ant-design/icons": "^6.3.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^6.0.0",
"@noble/hashes": "^2.4.0", "@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.2", "@tanstack/react-query": "^5.104.5",
"@tanstack/react-query-devtools": "^5.104.2", "@tanstack/react-query-devtools": "^5.104.5",
"antd": "^6.6.5", "antd": "^6.6.7",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"i18next": "^26.4.3", "i18next": "^26.5.6",
"otpauth": "^9.5.2", "otpauth": "^9.5.2",
"persian-calendar-suite": "^1.5.6", "persian-calendar-suite": "^1.5.6",
"react": "^19.3.0", "react": "^19.3.1",
"react-dom": "^19.3.0", "react-dom": "^19.3.1",
"react-hook-form": "^7.90.0", "react-hook-form": "^7.90.0",
"react-i18next": "^17.0.16", "react-i18next": "^17.1.3",
"react-router": "^8.4.0", "react-router": "^8.5.0",
"swagger-ui-react": "^5.33.1", "swagger-ui-react": "^5.34.0",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"zod": "^4.6.6" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-a11y": "^10.6.1", "@storybook/addon-a11y": "^10.6.3",
"@storybook/addon-docs": "^10.6.1", "@storybook/addon-docs": "^10.6.3",
"@storybook/addon-vitest": "^10.6.1", "@storybook/addon-vitest": "^10.6.3",
"@storybook/react-vite": "^10.6.1", "@storybook/react-vite": "^10.6.3",
"@testing-library/dom": "^10.4.2", "@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.1.2", "@vitejs/plugin-react": "^6.1.2",
"@vitest/browser-playwright": "5.0.3", "@vitest/browser-playwright": "5.0.5",
"@vitest/coverage-v8": "^5.0.3", "@vitest/coverage-v8": "^5.0.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^30.1.2", "jsdom": "^30.1.3",
"lint-staged": "^17.6.0", "lint-staged": "^17.6.0",
"msw": "^3.0.3", "msw": "^3.0.3",
"oxfmt": "0.72.0", "oxfmt": "0.74.0",
"oxlint": "1.87.0", "oxlint": "1.89.0",
"oxlint-tsgolint": "^7.0.2004", "oxlint-tsgolint": "^7.0.2004",
"playwright": "^1.64.0", "playwright": "^1.64.1",
"storybook": "^10.6.1", "storybook": "^10.6.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.3.4", "vite": "8.3.5",
"vitest": "^5.0.3" "vitest": "^5.0.5"
}, },
"overrides": { "overrides": {
"dompurify": "^3.4.15", "dompurify": "^3.4.17",
"react-copy-to-clipboard": "^5.1.1", "react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0", "react-inspector": "^9.0.0",
"seroval": "^1.6.8", "seroval": "^1.6.8",
@@ -99,9 +100,9 @@
"valibot": "^1.1.0" "valibot": "^1.1.0"
}, },
"@storybook/addon-vitest": { "@storybook/addon-vitest": {
"vitest": "^5.0.0", "vitest": "5.0.5",
"@vitest/browser-playwright": "^5.0.0", "@vitest/browser-playwright": "5.0.5",
"@vitest/browser": "^5.0.0" "@vitest/browser": "5.0.5"
}, },
"remarkable": { "remarkable": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -115,10 +116,5 @@
"tree-sitter-json": false, "tree-sitter-json": false,
"msw": false, "msw": false,
"esbuild": false "esbuild": false
},
"msw": {
"workerDirectory": [
"public"
]
} }
} }
-361
View File
@@ -1,361 +0,0 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+514 -11
View File
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "3X-UI Panel API", "title": "3X-UI Panel API",
"version": "3.x", "version": "3.x",
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password." "description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
}, },
"servers": [ "servers": [
{ {
@@ -16,7 +16,7 @@
"bearerAuth": { "bearerAuth": {
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "bearer",
"description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`." "description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
}, },
"cookieAuth": { "cookieAuth": {
"type": "apiKey", "type": "apiKey",
@@ -152,6 +152,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -242,6 +247,9 @@
"subClashPath": { "subClashPath": {
"type": "string" "type": "string"
}, },
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": { "subClashRules": {
"type": "string" "type": "string"
}, },
@@ -342,6 +350,10 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": { "subIncyAnnounceUrl": {
"type": "string" "type": "string"
}, },
@@ -636,6 +648,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -662,6 +675,7 @@
"subClashEnable", "subClashEnable",
"subClashEnableRouting", "subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRemoteDNS",
"subClashRules", "subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex", "subClashUserAgentRegex",
@@ -695,6 +709,7 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl", "subIncyAnnounceUrl",
"subIncyAppAutoDetect", "subIncyAppAutoDetect",
"subIncyBannerBgColor", "subIncyBannerBgColor",
@@ -928,6 +943,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -1018,6 +1038,9 @@
"subClashPath": { "subClashPath": {
"type": "string" "type": "string"
}, },
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": { "subClashRules": {
"type": "string" "type": "string"
}, },
@@ -1118,6 +1141,10 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": { "subIncyAnnounceUrl": {
"type": "string" "type": "string"
}, },
@@ -1420,6 +1447,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -1446,6 +1474,7 @@
"subClashEnable", "subClashEnable",
"subClashEnableRouting", "subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRemoteDNS",
"subClashRules", "subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex", "subClashUserAgentRegex",
@@ -1479,6 +1508,7 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl", "subIncyAnnounceUrl",
"subIncyAppAutoDetect", "subIncyAppAutoDetect",
"subIncyBannerBgColor", "subIncyBannerBgColor",
@@ -1673,6 +1703,23 @@
], ],
"type": "object" "type": "object"
}, },
"Blocker": {
"description": "Blocker is one unmet requirement.",
"properties": {
"code": {
"example": "tools",
"type": "string"
},
"detail": {
"example": "awg",
"type": "string"
}
},
"required": [
"code"
],
"type": "object"
},
"Client": { "Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": { "properties": {
@@ -1700,6 +1747,17 @@
"description": "Auth password (Hysteria)", "description": "Auth password (Hysteria)",
"type": "string" "type": "string"
}, },
"clientHostRuleIds": {
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": { "comment": {
"description": "Client comment", "description": "Client comment",
"type": "string" "type": "string"
@@ -1739,10 +1797,16 @@
"type": "string" "type": "string"
}, },
"keepAlive": { "keepAlive": {
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value", "description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
"nullable": true, "oneOf": [
{
"type": "integer" "type": "integer"
}, },
{
"type": "string"
}
]
},
"limitIp": { "limitIp": {
"description": "IP limit for this client", "description": "IP limit for this client",
"type": "integer" "type": "integer"
@@ -1931,6 +1995,16 @@
"auth": { "auth": {
"type": "string" "type": "string"
}, },
"clientHostRuleIds": {
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": { "comment": {
"type": "string" "type": "string"
}, },
@@ -1961,8 +2035,16 @@
"type": "integer" "type": "integer"
}, },
"keepAlive": { "keepAlive": {
"description": "Numeric values or AmneziaWG ranges.",
"oneOf": [
{
"type": "integer" "type": "integer"
}, },
{
"type": "string"
}
]
},
"limitHwid": { "limitHwid": {
"type": "integer" "type": "integer"
}, },
@@ -2738,6 +2820,10 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"randomCount": {
"example": 1,
"type": "integer"
},
"remark": { "remark": {
"example": "cdn-front", "example": "cdn-front",
"maxLength": 256, "maxLength": 256,
@@ -2763,6 +2849,15 @@
"sni": { "sni": {
"type": "string" "type": "string"
}, },
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": {}, "sockoptParams": {},
"sortOrder": { "sortOrder": {
"type": "integer" "type": "integer"
@@ -2810,11 +2905,13 @@
"path", "path",
"pinnedPeerCertSha256", "pinnedPeerCertSha256",
"port", "port",
"randomCount",
"remark", "remark",
"security", "security",
"serverDescription", "serverDescription",
"shuffleHost", "shuffleHost",
"sni", "sni",
"sniSharedRandom",
"sockoptParams", "sockoptParams",
"sortOrder", "sortOrder",
"tags", "tags",
@@ -2919,6 +3016,11 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"randomCount": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"remark": { "remark": {
"maxLength": 256, "maxLength": 256,
"type": "string" "type": "string"
@@ -2942,6 +3044,15 @@
"sni": { "sni": {
"type": "string" "type": "string"
}, },
"sniSharedRandom": {
"type": "boolean"
},
"snis": {
"items": {
"type": "string"
},
"type": "array"
},
"sockoptParams": { "sockoptParams": {
"type": "string" "type": "string"
}, },
@@ -2984,11 +3095,14 @@
"path", "path",
"pinnedPeerCertSha256", "pinnedPeerCertSha256",
"port", "port",
"randomCount",
"remark", "remark",
"security", "security",
"serverDescription", "serverDescription",
"shuffleHost", "shuffleHost",
"sni", "sni",
"sniSharedRandom",
"snis",
"sockoptParams", "sockoptParams",
"sortOrder", "sortOrder",
"tags", "tags",
@@ -3664,6 +3778,13 @@
"transitive": { "transitive": {
"type": "boolean" "type": "boolean"
}, },
"unavailableMetrics": {
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": { "updatedAt": {
"example": 1700000000, "example": 1700000000,
"format": "int64", "format": "int64",
@@ -3972,6 +4093,12 @@
"example": false, "example": false,
"type": "boolean" "type": "boolean"
}, },
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": { "updatedAt": {
"example": 1700003600, "example": 1700003600,
"format": "int64", "format": "int64",
@@ -4184,6 +4311,12 @@
"example": "online", "example": "online",
"type": "string" "type": "string"
}, },
"unavailableMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"uptimeSecs": { "uptimeSecs": {
"example": 86400, "example": 86400,
"format": "int64", "format": "int64",
@@ -4215,6 +4348,37 @@
], ],
"type": "object" "type": "object"
}, },
"Readiness": {
"description": "Readiness is the host's ability to run any kernel-mode inbound at all.",
"properties": {
"blockers": {
"example": [
{
"code": "tools",
"detail": "awg"
}
],
"items": {
"$ref": "#/components/schemas/Blocker"
},
"type": "array"
},
"installCommand": {
"example": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"type": "string"
},
"ready": {
"example": false,
"type": "boolean"
}
},
"required": [
"blockers",
"installCommand",
"ready"
],
"type": "object"
},
"RealityScanResult": { "RealityScanResult": {
"properties": { "properties": {
"alpn": { "alpn": {
@@ -4397,6 +4561,10 @@
"keepaliveTimeout": { "keepaliveTimeout": {
"type": "string" "type": "string"
}, },
"kernelMode": {
"description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.",
"type": "boolean"
},
"maxHandshakeAttempts": { "maxHandshakeAttempts": {
"type": "string" "type": "string"
}, },
@@ -4651,6 +4819,35 @@
], ],
"type": "object" "type": "object"
}, },
"TgBotCapabilities": {
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
"properties": {
"groupPrivacy": {
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
"example": false,
"type": "boolean"
},
"inlineEnabled": {
"example": true,
"type": "boolean"
},
"running": {
"example": true,
"type": "boolean"
},
"username": {
"example": "xui_bot",
"type": "string"
}
},
"required": [
"groupPrivacy",
"inlineEnabled",
"running",
"username"
],
"type": "object"
},
"Traffic": { "Traffic": {
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.", "description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
"properties": { "properties": {
@@ -4855,7 +5052,7 @@
}, },
{ {
"name": "API Tokens", "name": "API Tokens",
"description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation." "description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
}, },
{ {
"name": "Xray Settings", "name": "Xray Settings",
@@ -5319,6 +5516,51 @@
} }
} }
}, },
"/panel/api/inbounds/amneziawgKernelReadiness": {
"get": {
"tags": [
"Inbounds"
],
"summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.",
"operationId": "get_panel_api_inbounds_amneziawgKernelReadiness",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/Readiness"
}
}
},
"example": {
"success": true,
"obj": {
"blockers": [
{
"code": "tools",
"detail": "awg"
}
],
"installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
"ready": false
}
}
}
}
}
}
}
},
"/panel/api/inbounds/allLinks": { "/panel/api/inbounds/allLinks": {
"get": { "get": {
"tags": [ "tags": [
@@ -7184,7 +7426,7 @@
"tags": [ "tags": [
"Server" "Server"
], ],
"summary": "Self-update the panel to the latest version. The server restarts on success.", "summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
"operationId": "post_panel_api_server_updatePanel", "operationId": "post_panel_api_server_updatePanel",
"requestBody": { "requestBody": {
"required": false, "required": false,
@@ -8481,6 +8723,81 @@
} }
} }
}, },
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch clients by subscription ID.",
"operationId": "get_panel_api_clients_get_subId_subId",
"description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.",
"parameters": [
{
"name": "subId",
"in": "path",
"required": true,
"description": "Exact subscription ID (URL-encoded).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"client": {
"$ref": "#/components/schemas/ClientRecord"
},
"inboundIds": {
"type": "array",
"items": {
"type": "integer"
}
},
"externalLinks": {
"type": "array",
"items": {
"type": "object"
}
},
"usedTraffic": {
"type": "integer",
"format": "int64"
},
"tunnelAllowedIPs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/tgId/{tgId}": { "/panel/api/clients/get/tgId/{tgId}": {
"get": { "get": {
"tags": [ "tags": [
@@ -10983,6 +11300,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
@@ -11184,6 +11504,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
@@ -11334,6 +11657,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
@@ -11557,6 +11883,9 @@
"memPct": 45.2, "memPct": 45.2,
"panelVersion": "v3.x.x", "panelVersion": "v3.x.x",
"status": "online", "status": "online",
"unavailableMetrics": [
""
],
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
"xrayState": "", "xrayState": "",
@@ -11909,11 +12238,16 @@
"" ""
], ],
"port": 0, "port": 0,
"randomCount": 1,
"remark": "", "remark": "",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "", "sockoptParams": "",
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -11930,6 +12264,98 @@
} }
} }
}, },
"/panel/api/hosts/raw": {
"get": {
"tags": [
"Hosts"
],
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
"operationId": "get_panel_api_hosts_raw",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"address": "cdn.example.com",
"allowInsecure": false,
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"groupId": "",
"hostHeader": "",
"id": 1,
"inboundId": 1,
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": null,
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null,
"sortOrder": 0,
"tags": [
""
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": "443"
}
]
}
}
}
}
}
}
},
"/panel/api/hosts/get/{groupId}": { "/panel/api/hosts/get/{groupId}": {
"get": { "get": {
"tags": [ "tags": [
@@ -12004,11 +12430,16 @@
"" ""
], ],
"port": 0, "port": 0,
"randomCount": 1,
"remark": "", "remark": "",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "", "sockoptParams": "",
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12102,11 +12533,16 @@
"" ""
], ],
"port": 0, "port": 0,
"randomCount": 1,
"remark": "", "remark": "",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": "", "sockoptParams": "",
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12250,11 +12686,16 @@
"" ""
], ],
"port": 8443, "port": 8443,
"randomCount": 1,
"remark": "cdn-front", "remark": "cdn-front",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null, "sockoptParams": null,
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12372,11 +12813,16 @@
"" ""
], ],
"port": 8443, "port": 8443,
"randomCount": 1,
"remark": "cdn-front", "remark": "cdn-front",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null, "sockoptParams": null,
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -12494,7 +12940,7 @@
"tags": [ "tags": [
"Hosts" "Hosts"
], ],
"summary": "Set host group sort order by the position of each groupId in the array.", "summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
"operationId": "post_panel_api_hosts_reorder", "operationId": "post_panel_api_hosts_reorder",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -12624,11 +13070,16 @@
"" ""
], ],
"port": 8443, "port": 8443,
"randomCount": 1,
"remark": "cdn-front", "remark": "cdn-front",
"security": "same", "security": "same",
"serverDescription": "", "serverDescription": "",
"shuffleHost": false, "shuffleHost": false,
"sni": "", "sni": "",
"sniSharedRandom": false,
"snis": [
""
],
"sockoptParams": null, "sockoptParams": null,
"sortOrder": 0, "sortOrder": 0,
"tags": [ "tags": [
@@ -13115,6 +13566,47 @@
} }
} }
}, },
"/panel/api/setting/tgBotCapabilities": {
"post": {
"tags": [
"Settings"
],
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
"operationId": "post_panel_api_setting_tgBotCapabilities",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/TgBotCapabilities"
}
}
},
"example": {
"success": true,
"obj": {
"groupPrivacy": false,
"inlineEnabled": true,
"running": true,
"username": "xui_bot"
}
}
}
}
}
}
}
},
"/panel/api/setting/testDiscord": { "/panel/api/setting/testDiscord": {
"post": { "post": {
"tags": [ "tags": [
@@ -13208,9 +13700,11 @@
"obj": [ "obj": [
{ {
"id": 1, "id": 1,
"name": "default", "name": "central-panel-a",
"enabled": true, "enabled": true,
"createdAt": 1736000000 "createdAt": 1736000000,
"scope": "node-admin",
"expiresAt": 0
} }
] ]
} }
@@ -13240,7 +13734,13 @@
}, },
"scope": { "scope": {
"type": "string", "type": "string",
"description": "admin (default), monitor, or node-sync." "enum": [
"admin",
"monitor",
"node-sync",
"node-admin"
],
"description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
}, },
"expiresAt": { "expiresAt": {
"type": "integer", "type": "integer",
@@ -13253,7 +13753,7 @@
}, },
"example": { "example": {
"name": "central-panel-a", "name": "central-panel-a",
"scope": "node-sync", "scope": "node-admin",
"expiresAt": 1798761600000 "expiresAt": 1798761600000
} }
} }
@@ -16387,6 +16887,9 @@
"status": "online", "status": "online",
"tlsVerifyMode": "verify", "tlsVerifyMode": "verify",
"transitive": false, "transitive": false,
"unavailableMetrics": [
""
],
"updatedAt": 1700003600, "updatedAt": 1700003600,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "", "xrayError": "",
+2 -2
View File
@@ -23,7 +23,7 @@ const SECURITY_SCHEMES = {
type: 'http', type: 'http',
scheme: 'bearer', scheme: 'bearer',
description: description:
'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.', 'Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
}, },
cookieAuth: { cookieAuth: {
type: 'apiKey', type: 'apiKey',
@@ -342,7 +342,7 @@ export function buildSpec() {
title: '3X-UI Panel API', title: '3X-UI Panel API',
version: PANEL_VERSION, version: PANEL_VERSION,
description: description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.', 'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password.',
}, },
servers: [{ url: '/', description: 'Current panel (basePath aware)' }], servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
components: { components: {
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { ReadinessSchema, type Readiness } from '@/generated/zod';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
export type KernelReadiness = Readiness;
async function fetchKernelReadiness(): Promise<KernelReadiness> {
const msg = await HttpUtil.get('/panel/api/inbounds/amneziawgKernelReadiness', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch kernel readiness');
return ReadinessSchema.parse(msg.obj);
}
// Probing the module is cheap but not free, and the answer only changes when an
// admin installs something, so a short stale window is plenty.
export function useAmneziawgKernelReadiness(enabled: boolean) {
return useQuery({
queryKey: keys.inbounds.amneziawgKernelReadiness(),
queryFn: fetchKernelReadiness,
enabled,
staleTime: 30_000,
});
}
+1 -1
View File
@@ -45,7 +45,7 @@ export function useHostMutations() {
mutationFn: (groupIds: string[]) => mutationFn: (groupIds: string[]) =>
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS), HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
onSuccess: (msg) => { onSuccess: (msg) => {
if (msg?.success) invalidate(); if (msg?.success) return invalidate();
}, },
}); });
@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { z } from 'zod';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
// One row of the raw hosts table (GET /panel/api/hosts/raw), carrying the
// numeric id that per-client host rule bindings reference.
export const RawHostSchema = z
.object({
id: z.number(),
inboundId: z.number().optional(),
remark: z.string().optional(),
address: z.string().optional(),
port: z.number().optional(),
isDisabled: z.boolean().optional(),
})
.loose();
export type RawHost = z.infer<typeof RawHostSchema>;
const RawHostListSchema = z
.array(RawHostSchema)
.nullable()
.transform((v) => v ?? []);
async function fetchRawHosts(): Promise<RawHost[]> {
const msg = await HttpUtil.get('/panel/api/hosts/raw', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
const validated = parseMsg(msg, RawHostListSchema, 'hosts/raw');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useRawHostsQuery() {
const query = useQuery({
queryKey: keys.hosts.raw(),
queryFn: fetchRawHosts,
});
const hosts = useMemo(() => query.data ?? [], [query.data]);
return {
hosts,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}
+2
View File
@@ -11,6 +11,7 @@ export const keys = {
hosts: { hosts: {
root: () => ['hosts'] as const, root: () => ['hosts'] as const,
list: () => ['hosts', 'list'] as const, list: () => ['hosts', 'list'] as const,
raw: () => ['hosts', 'raw'] as const,
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const, byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const, tags: () => ['hosts', 'tags'] as const,
}, },
@@ -28,6 +29,7 @@ export const keys = {
root: () => ['inbounds'] as const, root: () => ['inbounds'] as const,
slim: () => ['inbounds', 'slim'] as const, slim: () => ['inbounds', 'slim'] as const,
options: () => ['inbounds', 'options'] as const, options: () => ['inbounds', 'options'] as const,
amneziawgKernelReadiness: () => ['inbounds', 'amneziawgKernelReadiness'] as const,
}, },
clients: { clients: {
root: () => ['clients'] as const, root: () => ['clients'] as const,
@@ -44,7 +44,12 @@ export default function ClientHwidListModal({
zIndex={zIndex} zIndex={zIndex}
onCancel={onClose} onCancel={onClose}
footer={[ footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}> <Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={loading}
onClick={onRefresh}
>
{t('refresh')} {t('refresh')}
</Button>, </Button>,
<Popconfirm <Popconfirm
@@ -122,7 +127,7 @@ export default function ClientHwidListModal({
type="text" type="text"
size="small" size="small"
aria-label={t('pages.clients.deleteHwid')} aria-label={t('pages.clients.deleteHwid')}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
loading={deletingId === entry.id} loading={deletingId === entry.id}
/> />
</Popconfirm> </Popconfirm>
@@ -38,12 +38,17 @@ export default function ConfigBlock({
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}> <div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} /> <Button
size="small"
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={copy}
/>
</Tooltip> </Tooltip>
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
onClick={() => FileManager.downloadTextFile(text, fileName)} onClick={() => FileManager.downloadTextFile(text, fileName)}
/> />
@@ -56,7 +61,11 @@ export default function ConfigBlock({
content={<QrPanel value={text} remark={qrRemark || label} size={220} />} content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} /> <Button
size="small"
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip> </Tooltip>
</Popover> </Popover>
)} )}
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ConfigProvider, Tag, Tooltip, message } from 'antd'; import { ConfigProvider, Tag, Tooltip, message } from 'antd';
import { import {
ApiOutlined,
ApartmentOutlined, ApartmentOutlined,
CheckCircleFilled, CheckCircleFilled,
ClockCircleOutlined, ClockCircleOutlined,
@@ -13,17 +12,14 @@ import {
ClusterOutlined, ClusterOutlined,
CodeOutlined, CodeOutlined,
CopyOutlined, CopyOutlined,
CrownOutlined,
DashboardOutlined, DashboardOutlined,
DatabaseOutlined, DatabaseOutlined,
DiscordOutlined, DiscordOutlined,
ExportOutlined, ExportOutlined,
FileTextOutlined, FileTextOutlined,
GlobalOutlined,
ImportOutlined, ImportOutlined,
LoadingOutlined, LoadingOutlined,
MailOutlined, MailOutlined,
MessageOutlined,
MoonOutlined, MoonOutlined,
PlusOutlined, PlusOutlined,
ReloadOutlined, ReloadOutlined,
@@ -32,8 +28,7 @@ import {
SettingOutlined, SettingOutlined,
SunOutlined, SunOutlined,
SwapOutlined, SwapOutlined,
TagsOutlined, TelegramFilled,
TeamOutlined,
ToolOutlined, ToolOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
import { useInboundOptions } from '@/api/queries/useInboundOptions'; import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { useAllSettings } from '@/api/queries/useAllSettings'; import { useAllSettings } from '@/api/queries/useAllSettings';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { PANEL_ROUTES } from '@/routes/panelRoutes';
import type { ClientRecord, InboundOption } from '@/schemas/client'; import type { ClientRecord, InboundOption } from '@/schemas/client';
import { commandPaletteStore, useCommandPalette } from './useCommandPalette'; import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
import './CommandPalette.css'; import './CommandPalette.css';
@@ -62,6 +58,34 @@ interface PaletteItem {
}; };
} }
// The search terms of every panel page, so the palette finds it by what it
// does rather than by its title.
export const PALETTE_KEYWORDS: Record<string, string[]> = {
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
'/inbounds': [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
'/groups': ['groups', 'tags', 'batch'],
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
'/hosts': ['hosts', 'sni', 'domains'],
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
'/xray': ['xray', 'templates', 'balancer', 'dns'],
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
'/sponsors': ['sponsors', 'sponsor', 'partners'],
};
export default function CommandPalette() { export default function CommandPalette() {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
@@ -262,9 +286,9 @@ export default function CommandPalette() {
title: c.email, title: c.email,
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`, subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
icon: isOnline ? ( icon: isOnline ? (
<CheckCircleFilled style={{ color: '#52c41a' }} /> <CheckCircleFilled aria-hidden style={{ color: '#52c41a' }} />
) : ( ) : (
<CloseCircleFilled style={{ color: '#ff4d4f' }} /> <CloseCircleFilled aria-hidden style={{ color: '#ff4d4f' }} />
), ),
action: () => { action: () => {
close(); close();
@@ -274,7 +298,7 @@ export default function CommandPalette() {
c.subId && allSetting.subURI c.subId && allSetting.subURI
? { ? {
label: t('commandPalette.copySubscription'), label: t('commandPalette.copySubscription'),
icon: <CopyOutlined />, icon: <CopyOutlined aria-hidden />,
execute: (e) => { execute: (e) => {
e.stopPropagation(); e.stopPropagation();
copySubscription(c); copySubscription(c);
@@ -331,7 +355,7 @@ export default function CommandPalette() {
category: 'inbounds', category: 'inbounds',
title: ib.remark || ib.tag || `Inbound #${ib.id}`, title: ib.remark || ib.tag || `Inbound #${ib.id}`,
subtitle: `Port ${ib.port || ''}`, subtitle: `Port ${ib.port || ''}`,
icon: <ImportOutlined style={{ color: '#1677ff' }} />, icon: <ImportOutlined aria-hidden style={{ color: '#1677ff' }} />,
tag: tag:
tags.length > 0 ? ( tags.length > 0 ? (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div> <div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
@@ -343,91 +367,12 @@ export default function CommandPalette() {
}); });
}); });
const pages = [ const pages = PANEL_ROUTES.map((route) => ({
{ path: route.path,
path: '/', title: t(route.titleKey),
title: t('menu.dashboard'), keywords: PALETTE_KEYWORDS[route.path] ?? [],
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'], icon: <route.icon aria-hidden />,
icon: <DashboardOutlined />, }));
},
{
path: '/inbounds',
title: t('menu.inbounds'),
keywords: [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
icon: <ImportOutlined />,
},
{
path: '/clients',
title: t('menu.clients'),
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
icon: <TeamOutlined />,
},
{
path: '/groups',
title: t('menu.groups'),
keywords: ['groups', 'tags', 'batch'],
icon: <TagsOutlined />,
},
{
path: '/nodes',
title: t('menu.nodes'),
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
icon: <ClusterOutlined />,
},
{
path: '/hosts',
title: t('menu.hosts'),
keywords: ['hosts', 'sni', 'domains'],
icon: <GlobalOutlined />,
},
{
path: '/outbound',
title: t('menu.outbounds'),
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
icon: <ExportOutlined />,
},
{
path: '/routing',
title: t('menu.routing'),
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
icon: <SwapOutlined />,
},
{
path: '/settings',
title: t('menu.settings'),
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
icon: <SettingOutlined />,
},
{
path: '/xray',
title: t('menu.xray'),
keywords: ['xray', 'templates', 'balancer', 'dns'],
icon: <ToolOutlined />,
},
{
path: '/api-docs',
title: t('menu.apiDocs'),
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined />,
},
{
path: '/sponsors',
title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined />,
},
];
pages pages
.filter((p) => matches(p.title, undefined, p.keywords)) .filter((p) => matches(p.title, undefined, p.keywords))
@@ -451,63 +396,63 @@ export default function CommandPalette() {
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
subtitle: t('pages.settings.panelSettings'), subtitle: t('pages.settings.panelSettings'),
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'], keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
icon: <SettingOutlined />, icon: <SettingOutlined aria-hidden />,
}, },
{ {
path: '/settings#security', path: '/settings#security',
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
subtitle: t('pages.settings.securitySettings'), subtitle: t('pages.settings.securitySettings'),
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'], keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
icon: <SafetyOutlined />, icon: <SafetyOutlined aria-hidden />,
}, },
{ {
path: '/settings#telegram', path: '/settings#telegram',
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
subtitle: t('pages.settings.TGBotSettings'), subtitle: t('pages.settings.TGBotSettings'),
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'], keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
icon: <MessageOutlined />, icon: <TelegramFilled aria-hidden />,
}, },
{ {
path: '/settings#email', path: '/settings#email',
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
subtitle: t('pages.settings.emailSettings'), subtitle: t('pages.settings.emailSettings'),
keywords: ['email', 'smtp', 'mail', 'crash alerts'], keywords: ['email', 'smtp', 'mail', 'crash alerts'],
icon: <MailOutlined />, icon: <MailOutlined aria-hidden />,
}, },
{ {
path: '/settings#discord', path: '/settings#discord',
title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
subtitle: t('pages.settings.discordSettings'), subtitle: t('pages.settings.discordSettings'),
keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'], keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
icon: <DiscordOutlined />, icon: <DiscordOutlined aria-hidden />,
}, },
{ {
path: '/settings#subscription', path: '/settings#subscription',
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
subtitle: t('pages.settings.subSettings'), subtitle: t('pages.settings.subSettings'),
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'], keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
}, },
{ {
path: '/settings#subscription-formats', path: '/settings#subscription-formats',
title: `${t('menu.settings')} · ${t('menu.subFormats')}`, title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
subtitle: t('menu.subFormats'), subtitle: t('menu.subFormats'),
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'], keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
icon: <CodeOutlined />, icon: <CodeOutlined aria-hidden />,
}, },
{ {
path: '/settings#subscription-balancers', path: '/settings#subscription-balancers',
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`, title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
subtitle: t('pages.settings.subBalancers.menu'), subtitle: t('pages.settings.subBalancers.menu'),
keywords: ['balancers', 'sub balancers', 'balancer nodes'], keywords: ['balancers', 'sub balancers', 'balancer nodes'],
icon: <ApartmentOutlined />, icon: <ApartmentOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`, title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
subtitle: t('pages.xray.basicTemplate'), subtitle: t('pages.xray.basicTemplate'),
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'], keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
icon: <ToolOutlined />, icon: <ToolOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
@@ -522,56 +467,56 @@ export default function CommandPalette() {
'statsOutbound', 'statsOutbound',
'metrics_out', 'metrics_out',
], ],
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`, title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
subtitle: t('pages.xray.connectionLimits'), subtitle: t('pages.xray.connectionLimits'),
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'], keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
icon: <ClockCircleOutlined />, icon: <ClockCircleOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`, title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
subtitle: t('pages.xray.logConfigs'), subtitle: t('pages.xray.logConfigs'),
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'], keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
icon: <FileTextOutlined />, icon: <FileTextOutlined aria-hidden />,
}, },
{ {
path: '/xray#balancer', path: '/xray#balancer',
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`, title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
subtitle: t('pages.xray.Balancers'), subtitle: t('pages.xray.Balancers'),
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'], keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
icon: <ClusterOutlined />, icon: <ClusterOutlined aria-hidden />,
}, },
{ {
path: '/xray#dns', path: '/xray#dns',
title: `${t('menu.xray')} · DNS`, title: `${t('menu.xray')} · DNS`,
subtitle: 'DNS', subtitle: 'DNS',
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'], keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
icon: <DatabaseOutlined />, icon: <DatabaseOutlined aria-hidden />,
}, },
{ {
path: '/xray#outbound', path: '/xray#outbound',
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`, title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
subtitle: t('pages.xray.Outbounds'), subtitle: t('pages.xray.Outbounds'),
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'], keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
icon: <ExportOutlined />, icon: <ExportOutlined aria-hidden />,
}, },
{ {
path: '/xray#routing', path: '/xray#routing',
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`, title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
subtitle: t('pages.xray.basicRouting'), subtitle: t('pages.xray.basicRouting'),
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'], keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
icon: <SwapOutlined />, icon: <SwapOutlined aria-hidden />,
}, },
{ {
path: '/xray#advanced', path: '/xray#advanced',
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`, title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
subtitle: t('pages.xray.advancedTemplate'), subtitle: t('pages.xray.advancedTemplate'),
keywords: ['advanced', 'json template', 'advanced config', 'custom json'], keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
icon: <CodeOutlined />, icon: <CodeOutlined aria-hidden />,
}, },
]; ];
@@ -599,7 +544,7 @@ export default function CommandPalette() {
title: t('commandPalette.restartXray'), title: t('commandPalette.restartXray'),
subtitle: t('pages.index.restartXray'), subtitle: t('pages.index.restartXray'),
keywords: ['restart', 'xray restart', 'reboot xray'], keywords: ['restart', 'xray restart', 'reboot xray'],
icon: <ReloadOutlined style={{ color: '#faad14' }} />, icon: <ReloadOutlined aria-hidden style={{ color: '#faad14' }} />,
action: restartXray, action: restartXray,
}, },
{ {
@@ -608,7 +553,7 @@ export default function CommandPalette() {
title: t('menu.theme'), title: t('menu.theme'),
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light', subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
keywords: ['theme', 'light', 'dark', 'ultra'], keywords: ['theme', 'light', 'dark', 'ultra'],
icon: isDark ? <SunOutlined /> : <MoonOutlined />, icon: isDark ? <SunOutlined aria-hidden /> : <MoonOutlined aria-hidden />,
action: cycleTheme, action: cycleTheme,
}, },
{ {
@@ -617,7 +562,7 @@ export default function CommandPalette() {
title: t('pages.inbounds.addInbound'), title: t('pages.inbounds.addInbound'),
subtitle: t('menu.inbounds'), subtitle: t('menu.inbounds'),
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'], keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />, icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
action: () => { action: () => {
close(); close();
navigate('/inbounds'); navigate('/inbounds');
@@ -629,7 +574,7 @@ export default function CommandPalette() {
title: t('pages.clients.addClient'), title: t('pages.clients.addClient'),
subtitle: t('menu.clients'), subtitle: t('menu.clients'),
keywords: ['add client', 'create user', 'new client', 'new user'], keywords: ['add client', 'create user', 'new client', 'new user'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />, icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
action: () => { action: () => {
close(); close();
navigate('/clients'); navigate('/clients');
@@ -705,9 +650,9 @@ export default function CommandPalette() {
> >
<div className="command-palette-header"> <div className="command-palette-header">
{isClientSearching ? ( {isClientSearching ? (
<LoadingOutlined className="command-palette-search-icon spinning" /> <LoadingOutlined aria-hidden className="command-palette-search-icon spinning" />
) : ( ) : (
<SearchOutlined className="command-palette-search-icon" /> <SearchOutlined aria-hidden className="command-palette-search-icon" />
)} )}
<input <input
ref={inputRef} ref={inputRef}
@@ -69,11 +69,11 @@ export default function TextModal({
footer={ footer={
<> <>
{fileName && ( {fileName && (
<Button icon={<DownloadOutlined />} onClick={download}> <Button icon={<DownloadOutlined aria-hidden />} onClick={download}>
{fileName} {fileName}
</Button> </Button>
)} )}
<Button type="primary" icon={<CopyOutlined />} onClick={copy}> <Button type="primary" icon={<CopyOutlined aria-hidden />} onClick={copy}>
{t('copy')} {t('copy')}
</Button> </Button>
</> </>
@@ -1,10 +1,17 @@
import { Select } from 'antd'; import { Select } from 'antd';
import type { SelectProps } from 'antd'; import type { SelectProps } from 'antd';
import { useTranslation } from 'react-i18next';
import { TLS_CIPHER_OPTION } from '@/schemas/primitives'; import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v })); const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
const PRESET_PREFIX = 'preset:';
const PRESET_OPTIONS = Object.keys(TLS_CIPHER_PRESETS).map((name) => ({
value: PRESET_PREFIX + name,
label: name,
}));
type CipherSuitesSelectProps = Omit< type CipherSuitesSelectProps = Omit<
SelectProps<string[]>, SelectProps<string[]>,
'value' | 'onChange' | 'mode' | 'options' 'value' | 'onChange' | 'mode' | 'options'
@@ -14,6 +21,13 @@ type CipherSuitesSelectProps = Omit<
onChange?: (value: string) => void; onChange?: (value: string) => void;
}; };
function expandPreset(next: string[]): string[] {
const picked = [...next].reverse().find((v) => v.startsWith(PRESET_PREFIX));
if (!picked) return next;
const name = picked.slice(PRESET_PREFIX.length) as keyof typeof TLS_CIPHER_PRESETS;
return TLS_CIPHER_PRESETS[name].split(':');
}
// xray splits cipherSuites on ':' into a list, so the picker edits tags while // xray splits cipherSuites on ':' into a list, so the picker edits tags while
// the stored value stays the single colon-joined string xray reads. // the stored value stays the single colon-joined string xray reads.
export default function CipherSuitesSelect({ export default function CipherSuitesSelect({
@@ -21,6 +35,7 @@ export default function CipherSuitesSelect({
onChange, onChange,
...rest ...rest
}: CipherSuitesSelectProps) { }: CipherSuitesSelectProps) {
const { t } = useTranslation();
const suites = value const suites = value
.split(':') .split(':')
.map((s) => s.trim()) .map((s) => s.trim())
@@ -31,9 +46,12 @@ export default function CipherSuitesSelect({
tokenSeparators={[':', ',']} tokenSeparators={[':', ',']}
{...rest} {...rest}
mode="tags" mode="tags"
options={CIPHER_SUITE_OPTIONS} options={[
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
]}
value={suites} value={suites}
onChange={(next) => onChange?.(next.join(':'))} onChange={(next) => onChange?.(expandPreset(next).join(':'))}
/> />
); );
} }
@@ -19,6 +19,9 @@ interface DateTimePickerProps {
disabled?: boolean; disabled?: boolean;
allowClear?: boolean; allowClear?: boolean;
maxDate?: Dayjs; maxDate?: Dayjs;
id?: string;
'aria-label'?: string;
'aria-describedby'?: string;
} }
const LIGHT_THEME = { const LIGHT_THEME = {
@@ -57,6 +60,9 @@ export default function DateTimePicker({
disabled = false, disabled = false,
allowClear = true, allowClear = true,
maxDate, maxDate,
id,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}: DateTimePickerProps) { }: DateTimePickerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
@@ -97,7 +103,11 @@ export default function DateTimePicker({
useEffect(() => { useEffect(() => {
if (datepicker !== 'jalalian') return; if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input'); const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder; if (!input) return;
input.placeholder = placeholder;
if (id) input.id = id;
if (ariaLabel) input.setAttribute('aria-label', ariaLabel);
if (ariaDescribedBy) input.setAttribute('aria-describedby', ariaDescribedBy);
}); });
if (datepicker === 'jalalian') { if (datepicker === 'jalalian') {
@@ -137,7 +147,7 @@ export default function DateTimePicker({
setClearNonce((n) => n + 1); setClearNonce((n) => n + 1);
}} }}
> >
<CloseCircleFilled /> <CloseCircleFilled aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -156,6 +166,9 @@ export default function DateTimePicker({
disabled={disabled} disabled={disabled}
allowClear={allowClear} allowClear={allowClear}
maxDate={maxDate} maxDate={maxDate}
id={id}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
); );
@@ -0,0 +1,274 @@
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Divider, Form, Input, Select, Space, Typography } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import {
DEFAULT_DOH_ENDPOINT,
DEFAULT_ECH_DOMAIN,
DEFAULT_ECH_PAIR,
dohEndpointOptions,
echDomainOptions,
formatDynamicEch,
isSupportedDohUrl,
pairPresetOptions,
parseDynamicEch,
} from '@/lib/xray/ech-presets';
interface DynamicEchHelperProps {
/** Current echConfigList, so an existing dynamic pair can be recovered. */
value?: string;
/** Node SNI, used by the "resolve from the proxied hostname" target. */
sni?: string;
/** True when a locally generated ECH keypair is present. */
hasLocalKeys?: boolean;
onApply?: (value: string) => void;
/** Called when the operator accepts dropping CDN-invalid local keys. */
onClearLocalKeys?: () => void;
disabled?: boolean;
}
const CUSTOM = '__custom__';
const AUTO = '__auto__';
const ADVANCED = '__advanced__';
/* Only echConfigList can hold the dynamic `domain+DoH` form; clearing local keys
* is a separate, explicitly confirmed step because behind a CDN they are dead. */
export default function DynamicEchHelper({
value = '',
sni = '',
hasLocalKeys = false,
onApply,
onClearLocalKeys,
disabled = false,
}: DynamicEchHelperProps) {
const { t } = useTranslation();
const uid = useId();
const parsed = parseDynamicEch(value);
const presetValues = new Set(
pairPresetOptions(t).flatMap((group) => group.options.map((o) => o.value)),
);
const [preset, setPreset] = useState<string>(() => {
const current = value.trim();
if (presetValues.has(current)) return current;
if (!current || !parsed) return DEFAULT_ECH_PAIR;
return ADVANCED;
});
const [domainChoice, setDomainChoice] = useState<string>(
() => parsed?.domain ?? DEFAULT_ECH_DOMAIN,
);
const [domainInput, setDomainInput] = useState<string>(() => parsed?.domain ?? '');
const [dohChoice, setDohChoice] = useState<string>(() => parsed?.dohUrl ?? DEFAULT_DOH_ENDPOINT);
const [dohInput, setDohInput] = useState<string>(parsed?.dohUrl ?? '');
const [preview, setPreview] = useState<string | null>(null);
const domain =
domainChoice === CUSTOM ? domainInput : domainChoice === AUTO ? sni.trim() : domainChoice;
const doh = dohChoice === CUSTOM ? dohInput : dohChoice;
const advancedBuilt = formatDynamicEch(domain, doh);
const built = preset === ADVANCED ? advancedBuilt : preset;
const apply = () => {
if (!built) return;
if (hasLocalKeys) setPreview(built);
else onApply?.(built);
};
const pickPreset = (next: string) => {
setPreset(next);
setPreview(null);
};
const pickDomain = (next: string) => {
setDomainChoice(next);
setPreview(null);
};
const pickDoh = (next: string) => {
setDohChoice(next);
setPreview(null);
};
const confirmOverwrite = () => {
if (!built) return;
onApply?.(built);
onClearLocalKeys?.();
setPreview(null);
};
return (
<div data-testid="ech-dynamic-helper" style={{ maxWidth: 640 }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, marginBottom: 4 }}
>
<ThunderboltOutlined aria-hidden />
{t('pages.inbounds.form.echDynamicTitle')}
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicIntro')}
</Typography.Paragraph>
{value.trim() !== '' && !parsed && (
<Typography.Paragraph
type="warning"
data-testid="ech-existing-not-dynamic"
style={{ fontSize: 12, marginBottom: 8 }}
>
{t('pages.inbounds.form.echExistingNotDynamic')}
</Typography.Paragraph>
)}
{/* component={false}: rendered inside the inbound/host <form>, and a nested
<form> element is invalid HTML. */}
<Form layout="vertical" size="small" colon={false} component={false}>
<Form.Item htmlFor={`${uid}-preset`} label={t('pages.inbounds.form.echPresetLabel')}>
<Select
id={`${uid}-preset`}
value={preset}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
data-testid="ech-preset-select"
onChange={pickPreset}
options={[
...pairPresetOptions(t),
{
label: t('pages.inbounds.form.addCustomOption'),
options: [{ value: ADVANCED, label: t('pages.inbounds.form.echPresetAdvanced') }],
},
]}
/>
</Form.Item>
{preset === ADVANCED && (
<>
<Form.Item htmlFor={`${uid}-domain`} label={t('pages.inbounds.form.echDynamicDomain')}>
<Select
id={`${uid}-domain`}
value={domainChoice}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
onChange={pickDomain}
options={[
{ value: AUTO, label: t('pages.inbounds.form.echDomainAuto') },
...echDomainOptions(t),
{ value: CUSTOM, label: t('pages.inbounds.form.addCustomOption') },
]}
/>
</Form.Item>
{domainChoice === CUSTOM && (
<Form.Item>
<Input
aria-label={t('pages.inbounds.form.echDynamicDomain')}
value={domainInput}
disabled={disabled}
placeholder={DEFAULT_ECH_DOMAIN}
onChange={(e) => {
setDomainInput(e.target.value);
setPreview(null);
}}
/>
</Form.Item>
)}
<Form.Item htmlFor={`${uid}-doh`} label={t('pages.inbounds.form.echDynamicDoh')}>
<Select
id={`${uid}-doh`}
value={dohChoice}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
onChange={pickDoh}
options={[
...dohEndpointOptions(t),
{
label: t('pages.inbounds.form.addCustomOption'),
options: [{ value: CUSTOM, label: t('pages.inbounds.form.echDynamicCustom') }],
},
]}
/>
</Form.Item>
{dohChoice === CUSTOM && (
<Form.Item>
<Input
aria-label={t('pages.inbounds.form.echDynamicDoh')}
value={dohInput}
disabled={disabled}
placeholder={DEFAULT_DOH_ENDPOINT}
status={dohInput && !isSupportedDohUrl(dohInput) ? 'error' : undefined}
onChange={(e) => {
setDohInput(e.target.value);
setPreview(null);
}}
/>
</Form.Item>
)}
{dohChoice === CUSTOM && dohInput && !isSupportedDohUrl(dohInput) && (
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicUnsupportedScheme')}
</Typography.Paragraph>
)}
</>
)}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echMainlandAdvisory')}
</Typography.Paragraph>
</Form>
{built ? (
<Typography.Paragraph
code
copyable={{ text: built }}
data-testid="ech-dynamic-preview"
style={{ fontSize: 12, marginBottom: 8, wordBreak: 'break-all' }}
>
{built}
</Typography.Paragraph>
) : (
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicInvalid')}
</Typography.Paragraph>
)}
<Button
type="primary"
size="small"
icon={<ThunderboltOutlined aria-hidden />}
disabled={disabled || !built}
data-testid="ech-dynamic-apply"
onClick={apply}
>
{t('pages.inbounds.form.echDynamicApply')}
</Button>
{preview && (
<>
<Divider style={{ margin: '12px 0' }} />
<Alert
type="warning"
showIcon
data-testid="ech-dynamic-overwrite-warning"
title={t('pages.inbounds.form.echDynamicOverwriteTitle')}
description={
<>
<div>{t('pages.inbounds.form.echDynamicOverwriteBody')}</div>
<div data-testid="ech-dynamic-overwrite-value" style={{ marginTop: 4 }}>
<Typography.Text code>{preview}</Typography.Text>
</div>
</>
}
action={
<Space orientation="vertical">
<Button size="small" type="primary" onClick={confirmOverwrite}>
{t('pages.inbounds.form.echDynamicOverwriteConfirm')}
</Button>
<Button size="small" onClick={() => setPreview(null)}>
{t('cancel')}
</Button>
</Space>
}
/>
</>
)}
</div>
);
}
@@ -125,21 +125,23 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
<Input <Input
value={row.name} value={row.name}
placeholder="Name" placeholder="Name"
aria-label={t('pages.inbounds.form.headerName')}
onChange={(e) => setRow(idx, { name: e.target.value })} onChange={(e) => setRow(idx, { name: e.target.value })}
/> />
<Input <Input
value={row.value} value={row.value}
placeholder="Value" placeholder="Value"
aria-label={t('pages.inbounds.form.headerValue')}
onChange={(e) => setRow(idx, { value: e.target.value })} onChange={(e) => setRow(idx, { value: e.target.value })}
/> />
<Button <Button
aria-label={t('remove')} aria-label={t('remove')}
icon={<MinusOutlined />} icon={<MinusOutlined aria-hidden />}
onClick={() => removeRow(idx)} onClick={() => removeRow(idx)}
/> />
</Space.Compact> </Space.Compact>
))} ))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}> <Button size="small" type="primary" icon={<PlusOutlined aria-hidden />} onClick={addRow}>
Add Add
</Button> </Button>
</> </>
+11 -1
View File
@@ -157,8 +157,18 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
if (!view) return; if (!view) return;
const current = view.state.doc.toString(); const current = view.state.doc.toString();
if (value === current) return; if (value === current) return;
let from = 0;
while (from < current.length && from < value.length && current[from] === value[from]) {
from += 1;
}
let oldEnd = current.length;
let newEnd = value.length;
while (oldEnd > from && newEnd > from && current[oldEnd - 1] === value[newEnd - 1]) {
oldEnd -= 1;
newEnd -= 1;
}
valueRef.current = value; valueRef.current = value;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); view.dispatch({ changes: { from, to: oldEnd, insert: value.slice(from, newEnd) } });
}, [value]); }, [value]);
useEffect(() => { useEffect(() => {
@@ -22,6 +22,9 @@ interface RemarkTemplateFieldProps {
multiline?: boolean; multiline?: boolean;
rows?: number; rows?: number;
metadataOnly?: boolean; metadataOnly?: boolean;
id?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
} }
/** /**
@@ -37,6 +40,9 @@ export default function RemarkTemplateField({
multiline = false, multiline = false,
rows, rows,
metadataOnly = false, metadataOnly = false,
id,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: RemarkTemplateFieldProps) { }: RemarkTemplateFieldProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<InputRef>(null); const inputRef = useRef<InputRef>(null);
@@ -76,7 +82,7 @@ export default function RemarkTemplateField({
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<CodeOutlined />} icon={<CodeOutlined aria-hidden />}
aria-label={t('pages.hosts.remarkVars.title')} aria-label={t('pages.hosts.remarkVars.title')}
style={{ marginInlineEnd: -7 }} style={{ marginInlineEnd: -7 }}
/> />
@@ -90,6 +96,9 @@ export default function RemarkTemplateField({
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Input.TextArea <Input.TextArea
ref={textAreaRef} ref={textAreaRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value} value={value}
maxLength={maxLength} maxLength={maxLength}
placeholder={placeholder} placeholder={placeholder}
@@ -101,6 +110,9 @@ export default function RemarkTemplateField({
) : ( ) : (
<Input <Input
ref={inputRef} ref={inputRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value} value={value}
maxLength={maxLength} maxLength={maxLength}
placeholder={placeholder} placeholder={placeholder}
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test'; import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd'; import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form'; import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { FormField } from './FormField'; import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const GB = 1024 * 1024 * 1024; const GB = 1024 * 1024 * 1024;
@@ -60,7 +60,8 @@ const ClientSchema = z.object({
}); });
function ClientDemo() { function ClientDemo() {
const methods = useZodForm(ClientSchema, { const methods = useForm<z.infer<typeof ClientSchema>>({
resolver: zodResolver(ClientSchema),
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true }, defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
}); });
return ( return (
@@ -95,8 +96,11 @@ const TrafficSchema = z.object({
}); });
function TrafficDemo() { function TrafficDemo() {
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } }); const methods = useForm<z.infer<typeof TrafficSchema>>({
const totalBytes = methods.watch('totalBytes'); resolver: zodResolver(TrafficSchema),
defaultValues: { totalBytes: 50 * GB },
});
const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' });
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}> <Form layout="vertical" style={{ maxWidth: 360 }}>
@@ -128,7 +132,10 @@ const InboundSchema = z.object({
}); });
function ValidationDemo() { function ValidationDemo() {
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } }); const methods = useForm<z.infer<typeof InboundSchema>>({
resolver: zodResolver(InboundSchema),
defaultValues: { remark: '', port: 0 },
});
useEffect(() => { useEffect(() => {
void methods.trigger(); void methods.trigger();
}, [methods]); }, [methods]);
@@ -157,7 +164,8 @@ const RealitySchema = z.object({
}); });
function NestedDemo() { function NestedDemo() {
const methods = useZodForm(RealitySchema, { const methods = useForm<z.infer<typeof RealitySchema>>({
resolver: zodResolver(RealitySchema),
defaultValues: { defaultValues: {
streamSettings: { streamSettings: {
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' }, realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
+14 -2
View File
@@ -49,8 +49,13 @@ export function FormField<T extends FieldValues = FieldValues>({
const { t } = useTranslation(); const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>; const dottedName = toDotted(name) as Path<T>;
const generatedId = useId(); const generatedId = useId();
const explicitId = (children as ReactElement<{ id?: string }>).props.id; const hintId = useId();
const fieldId = explicitId ?? generatedId; const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
.props;
const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
return ( return (
<Controller <Controller
@@ -74,6 +79,7 @@ export function FormField<T extends FieldValues = FieldValues>({
onBlur: field.onBlur, onBlur: field.onBlur,
ref: field.ref, ref: field.ref,
}; };
if (describedBy) childProps['aria-describedby'] = describedBy;
return ( return (
<Form.Item <Form.Item
label={label} label={label}
@@ -88,6 +94,12 @@ export function FormField<T extends FieldValues = FieldValues>({
style={style} style={style}
> >
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)} {cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
{/* The tooltip only opens on hover, so screen readers get its text as a description. */}
{tooltip && (
<span id={hintId} hidden>
{tooltip}
</span>
)}
</Form.Item> </Form.Item>
); );
}} }}
@@ -1,5 +1,4 @@
export { FormField, type FormFieldProps } from './FormField'; export { FormField, type FormFieldProps } from './FormField';
export { useZodForm } from './useZodForm';
export { rhfZodValidate } from './rhfZodValidate'; export { rhfZodValidate } from './rhfZodValidate';
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange'; export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
export { toDotted, type FieldName } from './toDotted'; export { toDotted, type FieldName } from './toDotted';
@@ -1,20 +0,0 @@
import { useForm } from 'react-hook-form';
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
export function useZodForm<TFieldValues extends FieldValues>(
schema: z.ZodType<TFieldValues>,
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
): UseFormReturn<TFieldValues> {
const resolver = zodResolver(
schema as z.ZodType<TFieldValues, TFieldValues>,
) as Resolver<TFieldValues>;
return useForm<TFieldValues>({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldUnregister: false,
...options,
resolver,
});
}
@@ -332,6 +332,7 @@ export default function GeoBrowserModal({
value={categoryQuery} value={categoryQuery}
onChange={(event) => setCategoryQuery(event.target.value)} onChange={(event) => setCategoryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchCategory')} placeholder={t('pages.xray.geoBrowser.searchCategory')}
aria-label={t('pages.xray.geoBrowser.searchCategory')}
allowClear allowClear
/> />
<Button <Button
@@ -392,6 +393,7 @@ export default function GeoBrowserModal({
value={entryQuery} value={entryQuery}
onChange={(event) => setEntryQuery(event.target.value)} onChange={(event) => setEntryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchEntries')} placeholder={t('pages.xray.geoBrowser.searchEntries')}
aria-label={t('pages.xray.geoBrowser.searchEntries')}
allowClear allowClear
className="geo-entry-filter" className="geo-entry-filter"
/> />
@@ -30,6 +30,7 @@ export interface GeoTokenInputProps {
kind: GeoTokenKind; kind: GeoTokenKind;
placeholder?: string; placeholder?: string;
id?: string; id?: string;
'aria-describedby'?: string;
ref?: Ref<InputRef>; ref?: Ref<InputRef>;
} }
@@ -40,6 +41,7 @@ export default function GeoTokenInput({
kind, kind,
placeholder, placeholder,
id, id,
'aria-describedby': ariaDescribedBy,
ref, ref,
}: GeoTokenInputProps) { }: GeoTokenInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -92,6 +94,7 @@ export default function GeoTokenInput({
<Input <Input
ref={ref} ref={ref}
id={id} id={id}
aria-describedby={ariaDescribedBy}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
@@ -99,7 +102,7 @@ export default function GeoTokenInput({
/> />
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}> <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
<Button <Button
icon={<DatabaseOutlined />} icon={<DatabaseOutlined aria-hidden />}
aria-label={t('pages.xray.geoBrowser.openTooltip')} aria-label={t('pages.xray.geoBrowser.openTooltip')}
onClick={() => setBrowsing(true)} onClick={() => setBrowsing(true)}
/> />

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