Commit Graph

3441 Commits

Author SHA1 Message Date
Gleb Gudkov
b8597314f8 docs(api): mark collection responses nullable (#6430)
* docs(api): mark collection responses nullable

Describe allLinks and panel log response objects as nullable string arrays so generated clients accept the existing nil-slice wire format. Pin both schemas with buildSpec regression assertions and regenerate the OpenAPI copies.

* docs(api): include nullable Xray log responses

Allow generated response arrays to opt into nullability while retaining their schema references and Go-derived examples. Apply this to Xray logs, whose nil slices already serialize as null, and pin the schema and example through buildSpec.
2026-09-08 17:12:09 +02:00
YoungReckless4
3cd3836d77 fix(amneziawg): account for S4 junk in the default tunnel MTU (#6376)
* fix(amneziawg): account for S4 junk in the default tunnel MTU

amneziawg prepends S4 random bytes to every transport packet
(device.NewOutboundElement) and, unlike content padding and random trailers,
never clamps them against the tunnel MTU. A full-size packet therefore lands on
the wire at MTU + 60 + S4 bytes: 20 IPv4 + 8 UDP + S4 + 16 transport header +
16 poly1305 tag.

With the 1420 default that overflows a 1500-byte link once S4 exceeds 20, and
GenerateObfuscation31 draws S4 from 12..27 inclusive -- so roughly 44% of newly
created inbounds fragment every full-size packet they send.

Measured on a live pair of interfaces, predicted against observed:

    MTU 1380  S4 12  ->  1452 on the wire   (fits)
    MTU 1420  S4 12  ->  1492               (fits)
    MTU 1420  S4 20  ->  1500               (exactly at the limit)
    MTU 1420  S4 21  ->  1501               (fragments)
    MTU 1420  S4 27  ->  1507               (fragments)

EffectiveMTU now subtracts S4 from the default; an explicit MTU is untouched.

Client configs carry the same number. They previously omitted the MTU line
whenever the server had no explicit value, which left the client on its own
1420 default and fragmented the client-to-server direction even after the
server side was fixed -- silently, and only in one direction. All three
emitters (the Go subscription text and the two TypeScript ones) now agree,
which is what the existing parity test exists to protect.

* fix(amneziawg): rebuild the device when S4 changes the derived MTU

Addresses review feedback on the previous commit.

Deriving the default MTU from S4 made a construction-time-only property depend
on a hot-reloadable input, but addressFingerprint -- ensureLocked's only rebuild
trigger -- still hashed the raw inst.MTU. S4 is a UAPI field, so an S4-only edit
took the in-place IpcSet branch and the gVisor netstack kept the MTU derived
from the old S4 while all three client emitters already advertised the new one.

Every panel-created inbound leaves mtu unset, so that was the normal case, not
an edge one: with S4 raised far enough the fragmentation this fix exists to
remove came straight back, and stayed until a panel restart or an unrelated
address edit.

Folding EffectiveMTU into the fingerprint fixes it. An explicit MTU still takes
the in-place branch on an S4 edit, since it does not move the interface MTU.

Also trims four comment blocks to the 2-line cap in CLAUDE.md, and points
NewDevice's doc comment at EffectiveMTU instead of the deleted defaultMTU.
2026-09-08 16:55:32 +02:00
Amirmohammad Sadat Shokouhi
5a63d5d468 fix(mtproto): use hosts for public share links (#6369)
* fix(mtproto): use hosts for public share links

Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible.

Closes #5126.

* fix(mtproto): keep host share links lossless and consistent

Address review on the MTProto hosts share-link change.

The migration no longer drops a legacy custom share address: an unrelated
(or disabled) Host stopped suppressing it, so only a Host already advertising
the same address does. An imported address now clears the same validation the
strict normalizer applies to every other protocol before it becomes a Host.

Panel and subscription agree on the endpoint a Host advertises: a portless host
string inherits the inbound port rather than the group's, and a port-only host
inherits the inbound address instead of emitting server=%3A8443.

LinksForClient prefers host endpoints for every protocol, the way getSubs and
inboundLinks already do, so the client-links API no longer ignores managed
hosts.

* fix(mtproto): migrate legacy share address past unusable hosts

The seeder skipped the conversion whenever any Host already carried the
address, including one that is disabled or excludes the raw sub type.
hostEndpoints drops those, so nothing advertised the address afterwards and
the marker committed with no way back. The duplicate check now mirrors that
same predicate.

UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion
AddInbound runs, so re-applying an inbound definition through the API dropped
the public address silently. Both paths share one capture helper now.

Refresh the generated clients API reference for the summary reworded in the
previous commit.

* fix(inbounds): wait for the hosts list before building mtproto links

The page destructured only `hosts` from useHostsQuery, and that list reads
empty both while /panel/api/hosts/list is in flight and after it fails.
withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and
Export advertise the internal listen port — the endpoint this branch exists to
replace. It is worse than not fixing it: the seeder has already moved a legacy
custom share address into a Host, so the fallback is the panel's own hostname
instead of the operator's address, and the Go generators reading the same rows
from the DB stay correct, so the two disagree for one inbound.

Fold the query into the page's existing readiness gate, the same way
useInbounds and HostsPage already consume that hook, so an empty list means
"no hosts" rather than "not loaded yet". The error branch fires only when
nothing is cached, so a refetch failing on window focus does not blank a page
whose host rows are still perfectly usable.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-08 15:36:57 +02:00
ilyusha
d2ac3b4d7a fix(cli): let -getApiToken name the token it regenerates (#6405)
* fix(cli): let -getApiToken name the token it regenerates

The flag's help text said "Display current API token". It cannot display
anything -- tokens are stored as SHA-256 hashes, and the command's own first
two output lines say so. What it does is destroy and reissue a credential:
GetApiToken calls RecreateByName on the hardcoded name "cli-fallback". The
help therefore invited an operator to run a command they believed was
read-only, and it revoked a token someone else was holding.

Because that name is a single global slot, two callers silently invalidate
each other, and the loser is left with a token that answers HTTP 404 with an
empty body -- indistinguishable from a wrong base path, so the failure does
not even say what happened. install.sh is one of those callers, at lines 1231
and 1325, so the collision already exists inside this repository.

Add -tokenName, defaulting to cli-fallback so install.sh and every existing
invocation behave exactly as before. -getApiToken stays a boolean on purpose:
install.sh calls it as `x-ui setting -getApiToken true`, and a string flag
would swallow that trailing argument and mint a token named "true".

The name now reaches both branches of GetApiToken. On a database with no
tokens the command used to create one called "install", which the CLI could
then never rotate -- defeating the stated purpose of the cli-fallback constant,
that -getApiToken cannot accumulate admin-equivalent credentials it never
revokes. Both branches use the resolved name, so repeated calls rotate a
single slot instead of leaving a permanent token behind.

Also cap the name at 64 characters in RecreateByName. Create already enforces
that limit on the same column; RecreateByName did not, and it now receives
operator input.

Assisted-by: Claude Code:claude-opus-5 (mostly)

* fix(cli): keep the installer's token out of the rotated slot

Folding both branches of GetApiToken onto one name made the bug worse in the
exact case this change is about. install.sh records the token it gets on a
fresh panel; with both branches on cli-fallback, the next bare -getApiToken
rotated that very row and silently invalidated the credential written into the
install-result file.

Restore the split default -- "install" when the database has no tokens,
cli-fallback when it does -- so nothing about an unnamed call changes. An
explicit -tokenName still applies to both branches, which is what keeps the
flag coherent: -tokenName ci-bot now yields ci-bot on a fresh panel too,
rather than "install".

Pin it with a test that reads the install row's id and hash before and after a
rotation, since a name-only assertion would pass against a deleted-and-
recreated row.

* fix(cli): stop the `-getApiToken true` form from swallowing -tokenName

Three corrections from review.

Go's flag package stops parsing at the first non-flag argument, so the trailing
`true` in install.sh's invocation does not merely get ignored -- it terminates
parsing. An operator copying that documented shape and writing
`x-ui setting -getApiToken true -tokenName ci-bot` left tokenName empty, so the
command rotated cli-fallback: the shared-slot collision this change exists to
remove, reachable through the one form the repository itself demonstrates.
Verified against the built binary, which printed
`The API token "cli-fallback" has been regenerated`.

Drop the stray `true` from both install.sh call sites so the documented form no
longer teaches the trap, and warn whenever `setting` is given positional
arguments, naming what was ignored. A warning rather than an error, because an
older install.sh in the wild still passes `true` and must keep working.

Cover both branches in the help strings. They described only the rotation path,
so on a fresh panel -h announced cli-fallback while the command actually mints
`install`, and nothing is regenerated or invalidated there at all -- misleading
help being the defect this change set out to remove.

Assert the concrete error in the name-length test. It checked only that some
error came back, which RecreateByName's empty-name guard and its transaction
errors would satisfy just as well.
2026-09-08 14:15:37 +02:00
Sanaei
2d151d7648 Update deps and simplify parsing
Bump frontend and Go dependencies, then modernize a few hot paths with newer Go string/range helpers. Also widen the client form quota/limit fields to improve the layout.
2026-09-08 14:12:13 +02:00
Sanaei
2ec6c73613 feat(xray): update xray-core to v26.9.8 and adapt panel
Bump xtls/xray-core to 37ceb8b4b6 (v26.9.8) and the three binary pins
(DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted
symbols; the impact is entirely on the JSON config surface.

Outbound "proxySettings" is now refused by the config loader (moved to
streamSettings.sockopt.dialerProxy) and a freedom outbound rejects
sockopt.addressPortStrategy. Either key in a stored template would keep
the core from starting after the upgrade, so a new OutboundRemovedKeysFix
seeder rewrites xrayTemplateConfig once: proxySettings.tag becomes
sockopt.dialerProxy (an existing dialerProxy wins) and addressPortStrategy
is dropped from freedom outbounds. Template saves and outbound
subscriptions already run through the vendored loader, so the new
refusals surface there with the core's own message.

REALITY no longer applies a built-in minClientVer (26.3.27) when the
field is empty. The form placeholder and the min/max hints in all 13
locales now say that empty means no minimum.

New upstream keys the Zod schemas would otherwise strip, with form
support where a sibling field already had it:
- blackhole response type "custom" with base64 customResponseData
- realm finalmask ipMode (dual/v4/v6) and portMapping (UPnP / NAT-PMP)
- quicParams brutalDisableLossCompensation, disableChromeParrot,
  disableGSO, disableStatelessReset
- hysteria masquerade proxy xForwarded
- wireguard outbound remoteDNS
- routing rule localOS

freedom.domainStrategy is only deprecated upstream (auto-migrated to
sockopt.domainStrategy with a warning) and is left untouched.
2026-09-08 13:49:32 +02:00
Sanaei
a5e68f410f perf(node): bound the per-client node push and fan out the traffic reset
An operator with several nodes reported that editing a client or resetting
its traffic takes more than ten seconds on the master. Measured against real
Remote HTTP (fake node servers, one client per node), the healthy case is
already fast — 3 nodes: update 51ms, delete 51ms; 5 nodes: 102ms / 103ms —
but two things were not:

  - ResetTrafficByEmail still walked its inbounds one node round-trip after
    another: 152ms at 3 nodes, 253ms at 5, linear in node count.
  - Every per-client op blocked on the SLOWEST node's push. With one node
    answering in 3s, update/delete/reset all took 3003ms regardless of node
    count. A node that answers the 4s heartbeat probe but hangs on the push
    stays "online", so every edit waited on it up to remoteHTTPTimeout — the
    ten seconds in the report. More nodes only raise the odds one is sick.

The push is an immediacy optimisation, not the source of truth: every one of
these ops calls MarkNodeDirtyTx inside the transaction that commits the
change, before it pushes, and the node reconcile job converges a dirty node on
its next 5s tick by re-sending the inbound whose fingerprint was not advanced.
So bound the synchronous push with nodeClientPushTimeout = 4s — the budget the
heartbeat and traffic-sync jobs already treat as "responsive" — at the eight
node-branch push sites. A node that does not answer in time is left dirty and
converged a few seconds later instead of stalling the request; the tag-cache
list fetch inside resolveRemoteID shares the same budget.

Once one push in a batch has timed out, the rest of that inbound's batch now
stops pushing too, as AddInboundClient already did: the node is dirty and one
reconcile converges the whole inbound. Deleting three clients on one hung node
went from 30.08s (three remote timeouts) to 4.06s; at the 32-client push
threshold that is 128s of deadlines saved per inbound.

Fan the reset out through fanoutInboundApplies like the other client ops. Its
node propagation is still attempted whatever the node's status flag says, as
before, because nothing replays a traffic reset — the reconcile pushes inbound
config, not counters — so a node still serving after being marked offline must
receive it now or never.

Trade-offs stated plainly: a node that would have answered in 4–10s now falls
to the reconcile's full-inbound push, which on the node is a delete+add of the
inbound and drops its sessions there — the same fallback a failed 10s push
already used, now reached sooner. The reset stays best-effort with no retry
path, which predates this change. The response still reports success while a
timed-out node catches up; the pending-node badge is keyed off node status by
design, so only the warning log records it.

Tests: a barrier test that a sequential reset cannot satisfy; two tests against
a real runtime.Remote and an httptest node that hangs on the push, pinning that
an edit returns at the deadline (exactly one push reached the node, the node
is left dirty) and that a bulk delete stops after its first timed-out push.
All red without the change; the two hung-node tests pay their 4s deadline on
every run.
2026-09-07 14:24:14 +02:00
Sanaei
e9e2e30278 perf(clients): push a bulk client change to every node at once
63b46cd6 made a multi-inbound client create apply its inbounds concurrently,
and d34ec97f did the same for the single-client update, delete and detach. The
bulk operations were never converted, so they still walked their inbounds in a
plain sequential loop with a node RPC in each iteration — and those are what the
panel actually calls for a multi-select delete or an enable/disable, which is
why editing and deleting still felt slow on a master with several nodes.

Measured with a node runtime injecting 100ms per RPC, one client per node:

  nodes=1  update=101ms  bulkSetEnable=101ms  bulkAdjust=101ms  bulkDelete=101ms
  nodes=3  update=102ms  bulkSetEnable=302ms  bulkAdjust=304ms  bulkDelete=303ms
  nodes=5  update=203ms  bulkSetEnable=504ms  bulkAdjust=504ms  bulkDelete=504ms

after, all of them track the single-client ops:

  nodes=3  bulkSetEnable=103ms  bulkAdjust=101ms  bulkDelete=101ms
  nodes=5  bulkSetEnable=202ms  bulkAdjust=202ms  bulkDelete=202ms

Generalize the fanout into fanoutInboundResults over an arbitrary per-inbound
result type and route six loops through it: BulkDelete, BulkSetEnable,
BulkAdjust, BulkDetach, BulkAttach, BulkCreate, plus applyClientFieldByEmail —
the field edit behind the Telegram bot's enable/limit/expiry buttons and the
LDAP job. Each keeps its preparation sequential and overlaps only the node
pushes, inheriting the same concurrency cap and per-inbound panic recovery.

Two ordering details the sequential loops got for free and the fanout must do
itself: the three loops that ranged a map now walk sortedInboundIds, so which
inbound wins a per-email skip reason is the lowest id instead of whatever the
map yielded; and BulkAttach de-duplicates a repeated inbound id up front,
because the second pass used to see the client the first pass had just added.

The allocating paths stay serial when a tunnel inbound is involved. WireGuard
and AmneziaWG pick a free peer address by reading every inbound's used-set
before they write, so two overlapping allocations hand out the same address and
the in-transaction re-check refuses the loser — a bulk create of two clients
onto two wg inbounds returned created=1. addFanoutLimit drops those batches back
to one at a time; every other protocol keeps the full cap.

Eight tests: seven barrier tests that a sequential caller cannot satisfy (peak
pushes in flight is 1 without the change, 4 with it), and one that pins the
tunnel allocation.
2026-09-07 02:19:16 +02:00
Sanaei
f2cf589947 fix(node): flag every hosting node before a client edit applies
A client edit fans out one transaction per inbound, and each one renames the
single shared clients row but calls MarkNodeDirtyTx for only its OWN node. So
between the first and the last commit the record already carries the new email
while every other node hosting that client is still config_dirty = false.

setRemoteTrafficLocked gates the snapshot merge on that flag, so a merge landing
in the gap is accepted, sees a pre-rename snapshot, finds no record for the old
email and inserts one through syncInboundClients' CreateInBatches — the only
place in the panel that creates a client record. The ghost is never in any later
merge's perInboundOld, so markSyncOrphan never fires and ReapSyncOrphans never
collects it: the operator is left with a permanent second client under the old
name. The same stale merge reverts an expiry-only edit instead of duplicating it.

Mark every node hosting the client dirty in one serialized write before the
fanout starts, so a merge queued behind it skips the node instead of merging a
half-applied edit. The nodes were going to be marked by their own applies
anyway; doing it up front only moves it earlier, and a client on local-only
inbounds never reaches the writer at all.

The set is the client's FULL attachment list, taken before the inboundIds
filter narrows it: the rename rewrites the one shared record, so an inbound the
filter excluded goes stale too.

Two tests, both red without the change. The first pins the ordering rather than
the end state — it reads the watched node's flag from inside another inbound's
push, so moving the marking after the fanout turns it red. The second pins that
the filtered path still covers the excluded node.

This narrows the window rather than closing it everywhere. A reconcile tick can
still clear the flag mid-fanout, and on a filtered edit the excluded inbound
keeps the old email in its settings for good, so its next merge duplicates
again. The case-drift path — a node reporting another case of a known email —
is untouched and still duplicates.
2026-09-07 01:51:18 +02:00
Sanaei
f072d0448d fix(clients): flag the restart a partly-applied edit or delete still needs
63b46cd6 made a multi-inbound client op apply its inbounds concurrently and
stop aborting at the first failure, so an error can now come back together
with needRestart=true: the inbounds that succeeded committed real changes and
their Xray still needs the restart. That commit taught the two callers it
converted — create and attach — to read the flag before the error check.

d34ec97f then routed Update, Delete, Detach and DeleteByEmail's record-less
fallback through the same fanout but touched no caller, so on a master with
several nodes a partly-applied edit or delete returned (true, err) into a
handler that returned on err first. Xray was never flagged for the work that
landed and notifyClientsChanged never fired, so the running config kept
serving the pre-edit client set and every open panel showed stale rows until
something else happened to trigger a restart.

Read needRestart before the error check in update, delete and detach, and
broadcast on needRestart || err == nil — the same shape create and attach have
had since 63b46cd6. The predicate is a strict superset of the old err == nil,
and needRestart is only ever assigned after a runSerializedTx commit, so it
firing genuinely means something landed.

The three handlers are pinned by a new controller test each: one client on two
inbounds, the second one's settings JSON corrupted so the op commits on one and
fails on the other, asserting both the success:false response and the restart
flag. All three fail without the change.

The API docs for update, del and detach now describe the partial-application
contract, as add and attach already did. Detach ends at the fanout so every one
of its errors carries the inbound prefix; update and delete write the client
record afterwards, and a failure there is reported without one.
2026-09-07 01:51:01 +02:00
Sanaei
33058c8eed fix(tgbot): use a token telego accepts in the edit-message tests
telego.NewBot validates the token against `^\d+:[\w-]{35}$` before any
option is applied, so the "test-token" literal in the two
not-modified tests failed with "telego: invalid token format" and the
go-test and race jobs went red on every run since #6340. Use a
placeholder token that matches the format; the tests now reach the
mock API server, pass with the guard in place and fail without it.
2026-09-06 17:32:01 +02:00
BlindMaster24
8e13f8b172 fix(tgbot): suppress 'message not modified' warnings in Telegram edit calls (#6340)
When users click Refresh buttons in the Telegram bot (usage_refresh,
client_refresh, ips_refresh, onlines_refresh), editMessageText and
editMessageReplyMarkup are always called even when the content has not
changed. Telegram returns a 400 "message is not modified" error which
was logged as Warning, cluttering the logs on every refresh click.

Add isTelegramNotModifiedError helper that detects this specific
Telegram API error and logs it at Debug level instead of Warning.
2026-09-05 20:54:15 +02:00
ilyusha
fc05249e0c fix(geofile): verify downloaded geo databases against published digests (#6404)
* fix(geofile): verify downloaded geo databases against published digests

UpdateGeofile wrote whatever the three upstreams returned straight into the
Xray asset folder with no integrity check. Xray parses these databases when it
builds its routing matchers, so a corrupted or substituted file takes the core
down at its next start.

The panel already does this for the other artifact it downloads: installXray
checks the release archive against the SHA-256 published in its .dgst sidecar.
The geo databases were the one download that skipped it, even though all three
upstreams publish a <asset>.sha256sum beside every .dat.

Fetch that sidecar, compare it against the bytes that actually arrived, and
stage every file in a temporary folder first, so one bad database installs
nothing rather than leaving the core running databases from two releases.

Match the digest line by base name rather than by the path it records.
Loyalsoldier and runetfreedom write "<hash>  geoip.dat" while chocolate4u
writes "<hash>  release/geoip.dat" -- the path from its own build -- so
`sha256sum --check` semantics fail on a perfectly good download.

Also skip the Xray restart when every upstream answered 304. The conditional
GET was already there, but the restart ran unconditionally and dropped every
client connection on a refresh that changed nothing.

Assisted-by: Claude Code:claude-opus-5 (mostly)

* fix(geofile): pin the release and scope atomicity to one upstream

Four corrections to the digest verification, all from review.

Pin the release. The asset and its .sha256sum were fetched as two independent
requests to releases/latest/download/, so GitHub re-resolved "latest" between
them. These upstreams publish several times a day -- 202609022346, 202609030908
and 202609031849 are three tags from one day -- so a release landing mid-batch
had release N+1's digest checked against release N's bytes, reporting a healthy
upstream as "corrupted or tampered with". Resolve the tag once per upstream from
the redirect GitHub already returns, then fetch body and digest from it. Modeling
the entry as repo + asset rather than an opaque URL is what makes that possible.

Scope atomicity to one upstream. A single failure discarded every verified
download, so one transient 5xx from one of three independent repositories threw
away four good files and re-downloaded tens of MB on the next attempt. The
integrity argument holds for a geoip/geosite pair out of one release; across
repositories it buys nothing. Each upstream now installs or aborts on its own
and errors are collected, as the code did before this feature.

Make the all-or-none test deterministic. It ranged a map, so when the corrupt
entry came first the run returned before the good file was ever requested and
the assertions held trivially -- a coin flip that would also pass against an
implementation installing each file as it verified. Iteration is sorted now, and
the test asserts the good file was actually downloaded first.

Assert which error. The error table checked only that err != nil, so its two
branches could swallow each other's cases; each row now pins the message. Also
trims three comment blocks to the two-line limit.

Assisted-by: Claude Code:claude-opus-5 (mostly)
2026-09-05 20:49:15 +02:00
Atirna
4e355edc15 fix(sub): skip AmneziaWG JSON entries (#6420) 2026-09-05 20:46:12 +02:00
Farhan Zare
0f6e1ae8d7 fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds (#6418)
* fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds

The JSON subscription's local SOCKS/HTTP inbounds had no listen address, so
every client that runs the profile verbatim bound an unauthenticated proxy on
0.0.0.0, and iOS packet-tunnel clients could not reach it at all (Happ iOS:
CONNECTED with zero traffic, same symptom as #6379 — on the same device the
mixed inbound also worked once bound to 127.0.0.1). Bind both to loopback,
which is what every client's own generated config does.

The global subJsonMux was also applied to VLESS outbounds carrying
xtls-rprx-vision. XTLS flows do not support mux.cool: Xray answers the mux
handshake with "common/mux: unexpected network TCP" and the tunnel passes
nothing, on every platform (verified with Happ iOS/Android/macOS, V2Box iOS
and desktop Xray 26.6.27 against a 3x-ui 3.7.0 box with per-client traffic
counters). Skip the mux block whenever the outbound carries a flow.

Refs #6379

* fix(sub): keep XUDP settings when disabling TCP mux on Vision outbounds

Clearing the whole mux object also dropped xudpConcurrency, xudpProxyUDP443
and any per-host muxParams override. Xray reads those only under
mux.enabled, so set concurrency to -1 instead: TCP mux.cool (which XTLS flows
reject) is off, XUDP and the UDP/443 policy stay. The test now decodes each
outbound into a fresh map.

---------

Co-authored-by: Farhan Zare <farhan.zare@openscreen.com>
2026-09-05 20:44:48 +02:00
Gleb Gudkov
ed6bc1d898 docs(api): align OpenAPI with runtime contracts (#6409)
Document the cookie-authenticated WebSocket upgrade and its emitted envelopes without exporting pseudo-paths. Align REST response schemas, paged-client filters, and subscription HEAD operations with their runtime implementations, then regenerate frontend and docs artifacts.
2026-09-04 15:23:41 +02:00
Sanaei
3b5273b1d6 fix(amneziawg): reject obfuscation values amneziawg-go's own UAPI rejects
ValidateObfuscation exists, by its own doc comment, so that a bad manual
entry cannot break the embedded device's IpcSet. It was not covering enough
to do that. Auditing the panel against amneziawg-go v3.1.20260828's full
UAPI surface turned up two holes, both confirmed by driving the values
through a real IpcSet:

  S1 = 70000        upstream parses s1-s4 as uint16
  S2 = 70000        (device/uapi.go)
  Jc = -1           jc/jmin/jmax are uint32, so no negatives
  Jmin/Jmax = -5/-1
  Jc = 5000000000   and nothing wider than uint32
  I1 = <rand 100>   newObfChain hard-fails on an unknown tag
  I1 = <r 100       ... and on a missing '>'
  I1 = <>           ... and on an empty one

All eight passed validation and were then rejected by the device. Only S3
and S4 were bounded, which is why the asymmetry went unnoticed. The inbound
saves, the reconcile fails on every tick, and the interface never comes up
with a single log line to say so.

Bound the five numeric fields to the widths upstream actually parses, and
check the I1-I5 chain's <tag value> structure against a tag set mirroring
upstream's own obfBuilders map. Each tag's value grammar stays amneziawg-go's
to enforce -- that is eight builders across several files, and duplicating
them here would drift. So <r abc> still reaches IpcSet, now as the only
remaining class rather than one of four.

Mirror the same bounds in the Zod schema, next to the max() that s3 and s4
already carried, so the form rejects the value instead of the save doing it.

TestValidatedObfuscationAlwaysApplies pins the contract itself: whatever
ValidateObfuscation accepts, a real amneziawg-go device must accept too. It
covers the specs the new grammar check deliberately allows, not just the ones
it rejects, so the allowlist cannot quietly become stricter than upstream.

The rest of the audit found no gaps: all 17 settable device keys reach
buildUAPIConfig, ServerSettings, the Zod schema and all three .conf
emitters. fwmark and persistent_keepalive_interval remain unemitted, both
deliberately -- the panel models no fwmark anywhere, and keepAlive is carried
client-side where WireGuard puts it.
2026-09-04 14:57:29 +02:00
Sanaei
be5ee3e0e1 fix(amneziawg): three defects in the embedded relay's connection handling
Half-close. Both TCP relays -- RelayTCP into Xray's SOCKS5 inbound and
relayTCPForward into a peer's tunnel address -- waited on a single `done`
receive and then closed both sides. A client that finished sending and shut
down its write side therefore had the connection torn down before the
response came back. pipeBothWays now runs both directions to completion and
propagates the half-close via CloseWrite (which *net.TCPConn and
*gonet.TCPConn both implement), falling back to a full Close for anything
that does not.

Waiting for both directions reintroduces the risk the old single-receive was
implicitly avoiding: a peer that vanishes mid-transfer would pin the pair
forever. guardedReader bounds that, but as an idle window rather than a
total one -- the deadline is re-armed on every read once armed -- so a slow
transfer is never cut, while a silent peer is. Two minutes matches the idle
window UDPRelay.pump and portForwardUDPIdleTimeout already use.

UDP session retirement. pump's teardown deleted the map entry by key alone,
so a session that lost a create race evicted whichever session currently
held that source, orphaning a live flow. It now retires only its own entry,
and Handle keeps the already-published session when it loses the race. The
map is keyed on netip.AddrPort rather than src.String(), matching
udpForwardListener next door and dropping one allocation per relayed
datagram.

SOCKS5 reply decoding. bytesReader had a value receiver, so each Read
restarted at the head of the slice, and receive never advanced past a
domain-form address because its switch only handled ATYP 0x01 and 0x04 -- a
0x03 reply decoded to a wrong source, port and payload. splitSocks5Addr
replaces it: all three address forms, length-checked at every step, with the
domain form accepting only a literal. Resolving there would have put a
blocking DNS lookup on the receive path, and a datagram's own source is an
address already. Unreachable against Xray's own inbound, which always
answers with an IP, so this is a latent-bug fix rather than an observed one.
2026-09-04 14:57:11 +02:00
Sanaei
24cb6bfe1f perf(amneziawg): return gVisor's pooled buffers on the embedded data path
Every packet crossing the embedded AmneziaWG interface allocated instead of
reusing gVisor's pools, in both directions. stackTun.Write injected each
decrypted packet and never called DecRef, so the packet buffer and its chunk
were never returned; stackTun.Read copied each view out and never released
it. gVisor's own link endpoints settle the ownership question -- loopback.go
and sharedmem.go both DecRef immediately after DeliverNetworkPacket, because
the injector owns the buffer.

AttachUDPHandler compounded it by cloning a packet buffer it then dropped on
the floor, on top of a Data().AsRange().ToSlice() that already returns an
owned copy, so the clone bought nothing and stranded a pooled buffer plus a
cloned view per datagram.

Measured with the benchmarks added here:

  stackTunWrite (upload)     794ns -> 107ns   4 -> 0 allocs
  stackTunRead  (download)   707ns -> 129ns   3 -> 0 allocs
  UDP datagram, end to end  2.69us -> 1.58us  8 -> 2 allocs

The remaining UDP allocation is the ToSlice copy itself. Through a real
handshaked tunnel -- both devices in one process over loopback, so
ChaCha20-Poly1305 and the UDP syscalls dominate -- it is worth -48% bytes/op
and -33% allocs/op, and about +4.8% throughput in each direction (n=18,
p<=0.01). On a small VPS, where the allocation pressure is not spread over
24 idle cores, the throughput share should be larger; that part is reasoning,
not something measured here.

The three regression tests assert allocations per packet rather than timing,
since the defect is the pool miss, not the nanoseconds. Thresholds leave room
for the extra allocation -race adds.
2026-09-04 14:56:54 +02:00
Sanaei
d34ec97f62 perf(node): push a client edit to every node at once, not one after another
Editing, deleting or detaching a client on a master with several nodes took
one node round-trip per node, added end to end. Create and Attach already
fanned their per-inbound applies out through fanoutInboundClientAdds, but
Update, Delete, Detach and DeleteByEmail's record-less fallback still walked
their inbounds in a plain sequential loop, and each iteration blocks on a
node RPC (10s timeout, more when a node is slow or has just gone unreachable
and the heartbeat has not marked it offline yet).

Measured with a node runtime injecting 100ms per RPC, before:

  nodes=1  create=101ms  update=101ms  delete=101ms
  nodes=3  create=102ms  update=303ms  delete=302ms
  nodes=5  create=202ms  update=504ms  delete=504ms

after, all three track create:

  nodes=3  create=102ms  update=102ms  delete=101ms
  nodes=5  create=203ms  update=203ms  delete=203ms

Generalize the existing helper into fanoutInboundApplies over an inboundApply
list and route the four remaining loops through it, so they inherit the same
concurrency cap, per-inbound panic recovery and joined errors. Each caller
still builds its payloads sequentially first: fillProtocolDefaults mints the
shared credentials on the first inbound and every later one reuses them, so
that order has to stay deterministic. Only the applies overlap; their DB work
still serializes through the single traffic writer, and the per-inbound
mutation lock is unchanged, which is exactly what Create has relied on.

Behaviour change: one failing inbound no longer aborts the remaining ones,
matching what Create already does. The error still names each failed inbound
and the record-level writes are still skipped when any inbound failed.

The snapshot merge on the same serialized writer was measured as a second
suspect and cleared: ~43ms per node at 500 clients, an order of magnitude
below the RPC serialization.
2026-09-04 11:39:56 +02:00
Sanaei
3ef06b7000 docs(readme): refresh all seven READMEs for the current feature set
The READMEs had not moved since 2026-07-07, 341 commits ago, and had
drifted far enough to misdescribe the panel: AmneziaWG and MTProto
inbounds were missing from the protocol list entirely, the outbound
list predated PIA, and the API section still advertised Swagger rather
than scoped, optionally expiring tokens.

Add the two missing protocols plus a bullet each for what makes them
notable — AmneziaWG runs on the embedded userspace netstack, so unlike
the DKMS/awg-quick shape it originally shipped with there is nothing to
install, and MTProto client edits hot-apply through the mtg-multi
management API instead of bouncing the process. Fold the smaller
additions into the bullets they belong to (HWID device limits, IP-limit
exemptions, renewal cycles, inbound cloning, balancer-to-balancer
fallback, geosite/geoip browsing, named subscription formats) and add
one for PWA installability.

Point documentation at docs.sanaei.dev, which the panel sidebar already
links to and which supersedes the wiki, using each README's own locale
where the docs site has one (fa/ru/zh). Bump the pinned install example
to the current stable tag, note the .sha256 verification install.sh and
update.sh now perform, and document XUI_NODE_TOKEN_KEY_FILE /
XUI_NODE_TOKEN_KEY, which no markdown in the repo covered.

All seven files move together so the language picker keeps pointing at
equivalent documents.
2026-09-04 09:49:25 +02:00
Sanaei
2e81865a02 style(node): tighten the comments and probe assertion from the QA pass
Two follow-ups on the preceding fixes, no behaviour change:

- The sweep comment in inbound_node.go had grown to a contiguous six-line
  block, over the two-line maximum. The prefix rationale it carried is
  already stated by nodeSelectedTagSet itself and by 6f40a51d's message.
- The probe cap test asserted only that an error came back, which cannot
  tell a size rejection from a transport failure or a success=false
  envelope. It now pins LastError to the decode rejection.

Both remain red-first: neutralizing maxProbeBodyBytes still fails the probe
test on the new assertion.
2026-09-04 02:48:44 +02:00
Sanaei
5fc4b9f463 fix(node): let a node-reported tag outrank a stale adopted alias
The alias re-application added in 0775fcaa wrote every adoptedAliases entry
onto the rebuilt map unconditionally, so an alias could override the id the
node itself reported for that same central tag. adoptedAliases is never
pruned — cacheDel clears remoteIDByTag and pushedFP only — so the entry
outlives the pairing that created it.

That inverts the intended precedence: once a push renames a node inbound to
the central tag, the node reports it directly, and a stale alias pointing at
some other inbound reusing the old name would win. Every state-changing op on
that inbound then targets the wrong one, overwriting or deleting an inbound
the operator created separately.

The alias now only fills a gap: a central tag the node already reports is
left alone.
2026-09-04 02:48:35 +02:00
Sanaei
ab4229534e fix(node): cap the status body the heartbeat probe decodes
probe decoded the node status response with json.NewDecoder(resp.Body) and no
size limit. encoding/json buffers the whole value before decoding, so the
allocation was dictated by the peer regardless of how few fields the envelope
declares — and the heartbeat job probes up to 32 nodes concurrently on a 4s
budget with no client-level timeout.

The sibling RPC path already caps every node response at 64 MiB
(readCappedBody in internal/web/runtime), so this was the one uncapped read
of node-controlled data. A status envelope holds a handful of scalars, so the
cap here is 1 MiB rather than the RPC figure.

The peer is untrusted in the skip and pin TLS modes, and the same decode is
reachable from the nodes test and probe endpoints.
2026-09-04 02:35:11 +02:00
Sanaei
0775fcaad2 fix(node): keep an adopted inbound alias across a remote id cache refresh
AdoptInboundAlias maps a central tag onto a node inbound that carries a
different name, recording the pairing in both remoteIDByTag and
adoptedAliases. refreshRemoteIDs then rebuilt remoteIDByTag from the tags the
node reports and nothing else, so the central-tag entry was dropped on the
next cache miss for any other tag.

After that every op on the adopted inbound failed to resolve, and UpdateInbound
falls back to AddInbound — creating a duplicate inbound on the node at the same
port. cacheGetTag only recovers an n<id>- prefix flip, never an arbitrary
alias, so the pairing could not be rediscovered until a master restart.

The rebuild now re-applies adoptedAliases onto the fresh map, which keeps the
map the single place a tag is resolved from.
2026-09-04 02:35:02 +02:00
Sanaei
6f40a51d62 fix(node): sweep a selected inbound the node reports without its prefix
In "selected" sync mode the reconcile sweep built its set of managed tags
verbatim from node.InboundTags. A panel-created node inbound is stored with
an n<id>- prefix (composeInboundTag) and pushed to the node with that prefix
stripped (wireInbound), so the tag the node reports never matched the set and
the sweep skipped it.

The effect is the case the sweep exists for: an operator deletes a node
inbound while the node is offline, and the node keeps serving it — and its
clients — indefinitely. Only unprefixed tags were unaffected, which is why
the existing selected-mode test did not catch it.

nodeSelectedTagSet already builds both tag forms for exactly this reason and
is used by the snapshot filter; the sweep now uses it too, so the two agree.
2026-09-04 02:34:53 +02:00
Sanaei
f6bfcfe759 refactor(ci): make the Claude workflow review pull requests and nothing else
claude-bot.yml ran three jobs: the pull-request review, an @claude mention
responder, and a conflict resolver that committed and pushed to contributor
branches. Only the review is wanted, so the other two are gone and the file
is renamed to say what is left.

Consequences worth knowing:

- secrets.CLAUDE_BOT_PAT is no longer referenced by any workflow. It was the
  only push credential handed to an agent in this repository and can now be
  deleted from the repository settings.
- @claude goes unanswered everywhere. claude-issue-analyst.yml deliberately
  excludes mentions (!contains(body, '@claude')) so the two jobs would not
  both reply; with the mention job gone, only `@claude review` on a pull
  request still reaches anything. Dropping that clause from the analyst would
  restore mention answering on issues.
- The workflow display name changes, so a branch protection rule keyed on
  "Claude Bot / review" has to become "Claude PR Review / review". The job
  name, which is what statusCheckRollup reports, is unchanged.

The review job itself is byte-identical. The workflow-level permission drops
to issues: read, which is all the remaining job needs - it already declares
its own.
2026-09-04 02:09:50 +02:00
Sanaei
41db85a096 docs(claude): teach the bot briefings about AmneziaWG and PIA
`grep -ci amneziawg` returned 0 in both .github/claude/repo-context.md and
REVIEW.md while CLAUDE.md has carried the protocol for releases. The issue
analyst and the review bot could not name internal/amneziawg/,
internal/amneziawgnet/ or internal/pia/, and the mention job's inline map
enumerated ten protocols with amneziawg missing from the list.

The 3.1 obfuscation parameters are generated twice - GenerateObfuscation31 in
internal/amneziawg/params.go and generateAwgObfuscation in
frontend/src/lib/xray/amneziawg-obfuscation.ts - so REVIEW.md now names that
pair as a divergence surface next to the three link implementations. Commit
bd1c27b0 was already a bug in exactly that pair.

Also corrects the CLAUDE.md CLI list, which omitted encrypt-tokens.
2026-09-04 02:09:28 +02:00
Sanaei
63b46cd612 perf(clients): apply a multi-inbound client create concurrently
Creating or attaching a client across N inbounds called AddInboundClient
once per inbound, strictly one after another. When those inbounds live on
different nodes each call is a full node round-trip bounded by the 10s
remote timeout, so the request cost the SUM of every node's latency: two
nodes felt instant, three took ~13s and timed out bot callers, which is
how it surfaced as "two out of four account creations fail".

Split the per-inbound preparation from the apply. Preparation stays
ordered and single-threaded because fillProtocolDefaults mints the shared
credentials on the first inbound and every later one reuses them; the
applies then run concurrently, capped at inboundFanoutConcurrency. A
4-node create measured 1.205s -> 0.307s with peak overlap 1 -> 4.

Consequences of no longer aborting at the first failing inbound:

- Every apply error is tagged with its inbound and the failures are
  joined, so all of them reach the caller instead of just the first.
- The fanout goroutines recover their own panics. Off the request
  goroutine gin's Recovery no longer covers them, and an unrecovered
  panic would kill the panel rather than fail one inbound.
- A partly-applied call commits clients on the inbounds that succeeded,
  so the controller and the LDAP job now read needRestart before the
  error check; otherwise Xray was never flagged for the work that landed.
- limitHwid is applied only when every inbound succeeded. Applying it
  after a failure rewrites limit_hwid and trims the registered devices of
  an email that already existed, which is silent data loss on an
  operation the panel reported as failed.

Update the API docs for the new partial-application contract and the
inbound-tagged error strings.
2026-09-04 01:01:20 +02:00
duqigit
2ddcf53020 Feature/fix external subscription client expiry (#6333)
* fix(sub): honor client expiry for external links

* fix(ui): show client expiry on external links

* fix(sub): address external expiry review
2026-09-03 22:32:05 +02:00
Sanaei
13e87a18c8 chore(ci): give the race job a 25m test timeout
The race job failed with "panic: test timed out after 10m0s" in
internal/web/service (FAIL at 600.106s) while every other package passed
and the non-race go-test job ran the same package in 57s.

Nothing hung. The race detector costs this repo ~8.5-10x (internal/database
8.4s -> 73s, internal/sub 17.8s -> 149s), and internal/web/service has 671
tests, ~40 of which each pay a full InitDB + AutoMigrate. That puts it right
on go test's 10-minute default per-package timeout: the last four race jobs
finished in 10m10s-10m28s before this one crossed the line.

Pass -timeout 25m in ci.yml and `make race` so the largest package has real
headroom while a genuine deadlock is still bounded. Verified locally:
ok internal/web/service 265.425s, 658 tests, no data races.
2026-09-03 21:57:38 +02:00
kuzzrus
bd1c27b03d fix(amneziawg): H1-H4 generator + queue-depth throughput fixes (#6330)
* fix(amneziawg): stop H1-H4 generator misclassifying transport packets

Both the Go generator and its frontend mirror picked a random *range*
per H1-H4 field with only a minimum width enforced (no maximum).
amneziawg-go's packet classifier only ever compares a fixed-size
ciphertext prefix against these bounds, so a wide range buys no DPI
resistance -- the boundaries themselves are never observable on the
wire. It does cost real throughput: with randomTrailers on (the
default here), the handshake-size checks relax from == to >, so a
wide H-range misclassifies a proportional fraction of ordinary
transport packets as handshakes and silently drops them
(amnezia-vpn/amneziawg-go#183). A single value per field is strictly
safer than any range, with no obfuscation trade-off.

Live-tested: narrowing H1-H4 alone took AmneziaWG upload from
2-3 Mbit/s to 200+ Mbit/s on one box, and ~20 Mbit/s to 120-156 Mbit/s
on another, single-variable, no other change.

* fix(amneziawgnet): raise tunQueueDepth to absorb slow-start bursts

1024 was sized for a single-connection buffering problem (the
gVisor-to-amneziawg-go TUN handoff channel needing slack for the
download direction). tcpip.Stack.Stats() during a real many-connection
download (20-28 concurrent TCP flows, e.g. a segmented speed test)
showed SlowStartRetransmits jump by ~770 in a single second the moment
CurrentEstablished crossed ~20 -- consistent with many connections'
simultaneous slow-start growth briefly exceeding 1024 outstanding
packets and gVisor treating the resulting silent drops as real network
loss.

* fix(amneziawg): trim comment blocks to the repo's 2-line cap

Review feedback: four comment blocks in the previous commits exceeded
CLAUDE.md's 2-line-per-block hard rule (up to 13 lines). Trimmed each to
the one non-obvious fact plus the amneziawg-go#183 reference; the fuller
rationale already lives in the commit message. Also refreshed the stale
H1-H4 range example in docs/content/docs/en/config/amneziawg.mdx to match
the new single-value generator output.
2026-09-03 21:50:23 +02:00
ilyusha
0ff3c23948 fix(api-docs): generate request bodies for all encodings (#6296)
* fix(api-docs): generate request bodies for all encodings

The OpenAPI generator only recognized generic body parameters, so JSON, form, and multipart declarations disappeared into empty application/json objects. Generate the declared media type and schema, preserve optionality and conditional requirements, and encode repeated form arrays the way Gin expects. Correct the request metadata exposed by the complete schemas and keep the panel and docs specifications synchronized.

* fix(api-docs): align alternative request schemas

Keep non-empty constraints on the selected request-body alternative without rejecting empty values for the alternatives that panel requests also include. Allow null client IP lists because model serialization emits them while cleared rows await pruning.

* fix(api-docs): send object urlencoded fields as JSON, document the inbound update body

Four defects the request-body rework exposed or left behind:

- An object-typed field in an x-www-form-urlencoded body got no encoding
  entry, so OpenAPI 3.0 serialized it form-style. Swagger "Try it out"
  and generated clients sent memberWeights=3&memberWeights=0.2 to
  /panel/api/sub-balancers, and parseSubBalancerForm json.Unmarshals the
  raw field, so every such call failed with "invalid memberWeights".
  Emit encoding.<name>.contentType = application/json instead.
- bodyRequiredOneOf names were never checked against the declared body
  params: a typo emitted an anyOf branch requiring a property that does
  not exist — unsatisfiable — and make gen still passed. Throw now, and
  extend the requestSchema guard to reject bodyRequiredOneOf as well.
- /panel/api/inbounds/update/:id advertised no request body although its
  own summary says the shape mirrors /add and updateInbound binds one.
  Both entries now share an inboundBody const so they cannot drift.
- The mixed-locations error was the only buildOperation throw without
  the method and path, aborting make gen without naming the offender.

Regenerated frontend/public/openapi.json and copied it to
docs/public/openapi.json. No MDX regeneration: no summary changed.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-03 21:20:38 +02:00
ilyusha
f294e1806d feat(release): publish SHA-256 sums and verify them in install.sh/update.sh (#6393)
* feat(release): publish SHA-256 sums and verify them in install.sh/update.sh

The installer and updater fetched the release archive and extracted it
after checking only that the file is not empty, and the release workflow
published no checksums. TLS protects the transport, not the bytes: a
truncated or swapped asset, a bad mirror or a TLS-terminating proxy was
installed as root. #5396 added this verification for the Xray archive;
the panel's own archive was the remaining unverified download.

Publish <asset>.sha256 next to every release archive (Linux and Windows)
and verify it before extracting. A mismatch aborts the install; a missing
sidecar, which every release before this change has, only warns, so
installing older tags keeps working.

Assisted-by: Claude Code:claude-fable-5-1

* fix(install): fail closed when the checksum sidecar cannot be fetched

Review follow-up. Any curl failure on the sidecar (5xx, reset, DNS) was
treated as "no checksum published", so whoever can swap the archive
could also drop the 90-byte sidecar request and skip the check. Only a
404, which every release before the sidecar existed returns, is still
tolerated with a warning; every other outcome aborts and removes the
downloaded archive.

Assisted-by: Claude Code:claude-fable-5-1

* fix(install): restore the closing brace lost in the main merge
2026-09-03 20:44:00 +02:00
ilyusha
4019f47de2 fix(x-ui.sh): put the fail2ban backend override in jail.d, not jail.conf (#6392)
* fix(x-ui.sh): put the fail2ban backend override in jail.d, not jail.conf

create_iplimit_jails switched the global fail2ban backend to systemd on
Debian 12+ and Ubuntu 22.04+ with sed on /etc/fail2ban/jail.conf. That
file is the package's conffile: the next fail2ban upgrade either drops
the edit or keeps a stale jail.conf, depending on the conffile prompt.

Write the same override to /etc/fail2ban/jail.d/3x-ipl-backend.conf,
which fail2ban reads after jail.conf and which upgrades leave alone, and
remove it together with the other 3x-ipl files on uninstall. The 3x-ipl
jail itself keeps its explicit backend=auto.

Assisted-by: Claude Code:claude-fable-5-1

* fix(x-ui.sh): only override the stock fail2ban backend, keep it on partial removal

Review follow-ups. The old sed only rewrote a literal 'backend = auto'
in jail.conf's [DEFAULT], so an operator's own backend survived it; the
override file was written unconditionally. Write it only when jail.conf
still carries the stock value. And keep the file when only the IP-limit
jail is removed: the sed was never reverted either, and deleting a
[DEFAULT] override there would flip every inheriting jail back to auto
on the restart in the same branch. The full /etc/fail2ban removal path
still deletes it.

Assisted-by: Claude Code:claude-fable-5-1
2026-09-03 20:42:14 +02:00
Sanaei
8411b1dd9e chore: upgrade Vitest to v5
Update frontend dev tooling to Vitest 5 by bumping `vitest`, `@vitest/browser-playwright`, and `@vitest/coverage-v8`, plus `@types/react-dom`. Add an override for `@storybook/addon-vitest` to pin Vitest-related packages to compatible versions and avoid dependency mismatch issues. Also bump the Go toolchain patch version from `1.27.0` to `1.27.1` in `go.mod`.
2026-09-03 20:37:10 +02:00
Sanaei
a31fa9abfa fix(node): refuse a node's claim on another inbound's client
The sync adopts each node's reported clients through SyncInbound, which resolves
a client record by email alone — and clients.email is globally unique. A node
reporting a colliding email therefore overwrote that client's UUID even when the
client is attached only to a master inbound, and the master then rebuilt its own
Xray config with the node-supplied credential: the real user locked out.

Skip a reported client whose record is attached only to inbounds of other nodes.
A record attached nowhere stays adoptable, so the soft-orphan reattach path a
flapping node depends on is unaffected.
2026-09-03 18:06:35 +02:00
Sanaei
f17e4684e0 fix(sub): apply the device limit to ?view=raw
subJsons and subClashs served the raw body and returned before enforceHwid ran,
so appending ?view=raw to a JSON or Clash subscription URL handed out a complete,
client-consumable config however many devices were already registered. The branch
exists to stop a browser's Accept: text/html from being answered with the info
page, not to skip the gate.

Gate the raw branch and leave the other gate where it was, below
maybeServeSubPage, so the HTML info page stays ungated as before.
2026-09-03 18:06:35 +02:00
Sanaei
f9de0226fe fix(xray): confine log paths written under any key case
resolveXrayLogPaths looked the log object up by the exact keys "access" and
"error", but xray-core decodes that object with encoding/json, which falls back
to a case-insensitive field match. "Access": "/tmp/pwn.log" therefore reached
AccessLog untouched and Xray — root, in a standard install — created the file
there, reopening the arbitrary write that GHSA-jm48-m3rr-9hgg closed.

Fold every case variant onto the canonical key before confining it. When both a
canonical key and a variant are present the canonical value wins, so a
"none" cannot be overridden by a smuggled "Access" path.
2026-09-03 18:06:15 +02:00
Sanaei
25d0c06f89 fix(ci): skip a head the review bot already reviewed, and report a refused run
Ten review runs fired in under two hours on 3 September and every one after
11:25 came back rejected: the five-hour usage window was at 100 percent
(overageStatus rejected, org_level_disabled) while the seven-day window sat at
29. Two of them reviewed the same head SHA and one pull request was reviewed
four times, because a draft/ready toggle re-fires pull_request_target and the
skip decision is only reachable after a full checkout and a model boot.

Settle it in the workflow instead: a bot comment carrying "Reviewed head:" and
the pinned SHA means this head is done, so the pr-head checkout, the brief and
the action are all skipped. An explicit "@claude review" is exempt, so a
maintainer can still force one.

A refused run also failed the job twice over - the action's exit 1 plus "the
review posted nothing" - with nothing on the pull request to say why, which
reads as a broken bot rather than an exhausted budget. The job now classifies
its own transcript: a rejected rate_limit_event, or a 529 that survived every
retry, posts one line on the pull request and stays green. Anything else still
fails loudly.

Also tightens that check, which counted ANY bot comment quoting the head SHA as
a legitimate skip; the conflict-resolution job quotes SHAs too, so a dead run
could go green on one.
2026-09-03 17:26:00 +02:00
MRVX
47964afbc5 fix(clients): render all tunnel configs for multi-inbound client (#6346) (#6349)
When a client belongs to multiple AmneziaWG or WireGuard inbounds (e.g. across
remote nodes), findAmneziaWGInbounds and findWireguardInbounds only returned the
first matching inbound. Consequently, ClientInfoModal and ClientQrModal rendered
only one config block, making other inbounds' configs unreachable.

- Add findAmneziaWGInbounds and findWireguardInbounds returning all matching inbounds
- Add formatTunnelConfigMeta helper to unify label, fileName, and qrRemark resolution
- Support addressOverride in buildWireguardClientConfig from tunnelAllowedIPs
- Render all tunnel configs in ClientInfoModal and ClientQrModal with node remarks
- Distinguish download filenames with inbound remark suffix to avoid collisions
- Add component integration tests covering multi-inbound modal rendering
2026-09-03 17:03:48 +02:00
Mapioe
de18c5a006 fix: do not type successfull login twice (#6374)
Co-authored-by: Mapioe <Mapioe@users.noreply.github.com>
2026-09-03 17:00:25 +02:00
ilyusha
195988bdc1 fix(install): fetch x-ui.sh and unit files from the installed release tag (#6391)
* fix(install): fetch x-ui.sh and unit files from the installed release tag

install.sh and update.sh pin the panel archive to a release tag but always
took x-ui.sh, x-ui.rc and the service units from main, so the management
script and the binary of one installation came from different commits:
the fail2ban templates and setting flags the script writes drift silently
against an older binary, two installs of the same tag differ, and a
reviewed or digest-pinned installer still runs unreviewed code from main.

Use the same ref as the archive, keeping main only for the rolling
dev-latest build. The menu's "update menu" and update_shell paths now
fetch the script matching the installed version and fall back to main
with a visible notice when no script is published for it.

Assisted-by: Claude Code:claude-fable-5-1

* fix(install): fall back to main for files a pinned tag does not publish

Review follow-ups. install.sh accepts tags down to v2.3.5, but x-ui.rc
only exists from v2.8.4 and the split x-ui.service.* files are newer
still, so pinning those to the tag made an Alpine install of an old tag
404 after the previous install was already removed. Probe the tag for
each file and fall back to main with a notice when it is missing, as
the menu already does for x-ui.sh.

The fail2ban auto-setup probe also trusted the exit status of
'x-ui setup-fail2ban', but scripts before v3.4.0 have no such
subcommand and exit 0 from the usage banner, so the installer reported
a setup that never ran. Skip with a notice when the installed script
does not know the subcommand.

Assisted-by: Claude Code:claude-fable-5-1

* fix(install): refuse a tag that does not publish a needed script

Falling back to main reintroduced the binary/script mismatch the tag
pinning exists to remove, and it fired at points where install.sh and
update.sh have already stopped and removed the previous installation --
so the quiet path was also the one that could not be undone.

Probe the tag instead, before anything is touched, for every file that is
always fetched from GitHub (x-ui.sh, plus x-ui.rc on Alpine), and abort
with the HTTP status when one is missing. The unit files stay unprobed:
they are only fetched when the release tarball omits them, so an old tag
that ships x-ui.service inside its tarball still installs. Their existing
failure message now names the ref it tried.

Also tighten the setup-fail2ban probe to the dispatcher's case arm rather
than any mention of the string, which also matches a comment.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 16:50:53 +02:00
ilyusha
23511108bf fix(database): keep the SQLite store owner-only (#6390)
* fix(database): keep the SQLite store owner-only

InitDB created the data directory 0755 and let SQLite create x-ui.db
and its -wal/-shm side files under the default umask, so on a stock
install they are world-readable. The store holds client UUIDs, Reality
private keys and the admin password hash, so any local account could
read them.

Create the directory 0700 and chmod the database files to 0600 right
after opening. SQLite gives -wal/-shm the mode of the main file, so
files created later inherit it; existing installs are tightened on the
next start. PostgreSQL deployments are untouched.

Assisted-by: Claude Code:claude-fable-5-1

* fix(database): tolerate chmod failures, keep the dump and install dir owner-only

Review follow-ups. A store the panel cannot chmod (root_squash NFS, a
foreign uid in a container) refused to start, which is worse than the
0644 it had before; log and continue instead, as the backup-directory
cleanup above already does. install.sh reset /etc/x-ui to 0755 right
after the binary created it 0700, so the directory hunk was inert on
real installs; create it 0700 there too. The migrate-db dump in the same
directory is a plaintext copy of the same secrets and was written 0644.

Assisted-by: Claude Code:claude-fable-5-1
2026-09-03 16:37:35 +02:00
Rouzbeh†
540caa4e93 fix(hysteria): standard geco share links and persistent uTLS None (#6325)
- Export standard gecko obfs query params in hysteria2 share links
- Enforce packet size bounds across Go and TypeScript link handlers
- Persist uTLS None explicitly and initialize new TLS inbounds to chrome
- Tear down stackTun safely without closeMu deadlock against WriteNotify

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
2026-09-03 16:35:07 +02:00
ilyusha
f9898e0b24 fix(sub): randomize fresh panel subscription paths (#6375)
* fix(sub): randomize fresh panel subscription paths

Seed distinct cryptographically random paths for base64, JSON, and Clash subscriptions when a panel database is first created. Persist them so restarts keep published URLs stable while upgrades preserve existing settings.

Generated-by: OpenCode:gpt-5.6-sol

* fix(sub): regenerate paths on settings reset

Keep subscription paths unpredictable after a factory reset, close the test database on failure, and update the builder, OpenAPI, and localized docs to describe panel-specific paths instead of obsolete fixed defaults.

Generated-by: OpenCode:gpt-5.6-sol
2026-09-03 16:34:37 +02:00
dawn
ded2aa150c fix(frontend): isolate subscription language preference (#6394)
* fix(frontend): isolate subscription language preference

* fix(frontend): defer date locale resolution
2026-09-03 16:33:37 +02:00
dawn
0c72dd8384 fix(sub): restore compatible SOCKS subscription inbound (#6395) 2026-09-03 16:33:14 +02:00
dawn
04e8458054 fix(frontend): improve dense QR readability (#6396)
Dense AmneziaWG configs crossed a QR version boundary at the fixed display size, and the generated symbol had no quiet zone. Use low error correction and a four-module margin to reduce module density while keeping the payload unchanged.
2026-09-03 16:32:27 +02:00
dawn
e95fe80fc4 fix(amneziawg): avoid manager lock inversion (#6397)
* fix(amneziawg): avoid manager lock inversion

Packet handlers re-entered the manager mutex while device reconfiguration and teardown held it and waited for receiver goroutines. Publish immutable peer indexes atomically so the data path can finish without participating in lifecycle locking.

* test(amneziawg): exercise UDP relay hit path
2026-09-03 16:31:53 +02:00