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.
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.
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.
* 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
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.