Commit Graph
100 Commits
Author SHA1 Message Date
Sanaei 789a03065a chore(docs): bump dependencies and adapt to fumadocs-core 16.15.11
Updates the docs site's dependencies, including the Fumadocs packages,
Next 16.3.5, React 19.3 and three majors: mermaid 12, vitest 5 and
pnpm 12. Two code changes follow from the bump:

- fumadocs-core 16.15.11 makes `llms().index()` return a Promise, so
  the llms.txt route now awaits it; tsc rejected the old synchronous
  call
- lucide-react 1.46 renamed the BookMarked icon to BookBookmark. The
  old name is still exported, but lucideIconsPlugin looks names up in
  lucide's `icons` map, which only has the new one, so the Reference
  section lost its sidebar icon in all four locales. The build only
  printed a warning.

minimumReleaseAgeExclude gains entries for the newly installed
versions.

Checked with typecheck, lint, vitest (106 tests) and a full build: no
plugin warnings, and each locale's rendered /docs page contains the
book-bookmark icon.
2026-09-15 16:00:05 +02:00
Sanaei bc424f0968 fix(xray): stop a lone dns qType 0 from matching every query
The core reads a dns rule's qType as a PortList, which drops a bare numeric
0 (infra/conf/common.go: `if number != 0`), and a rule with no qTypes
matches every query. A stored `"qType": 0` therefore does not target query
type 0: it drops, refuses or hijacks all DNS through that outbound.

A qType the panel writes has to be read by the core as exactly the query
types it names. Four writers broke that:

- DNSOutboundLegacyKeysFix rewrote a lone blockTypes [0] into "qType": 0,
  so "block type 0" became "block everything" on upgrade.
- That seeder shipped in v3.8.0 and is recorded as done, so fixing it does
  not reach installs that already ran it. DNSOutboundQTypeZeroFix spells
  any stored numeric qType 0 as "0" once, protocol id matched like the core.
- The outbound form adapter turned a typed "0" into the number 0.
- The Xray template editor saves raw JSON past that adapter; the save now
  applies the same rewrite.

Each writer is pinned by a test that fails without its part. The rewrite
and the repair compare policies as the pinned core builds them, and the
repair runs through runSeeders over a database whose legacy-keys seeder
already ran, on SQLite and PostgreSQL 16.
2026-09-15 16:00:05 +02:00
Sanaei 837addf66e v3.8.0 2026-09-14 19:11:03 +02:00
Sanaei 840a40edcd chore(deps): update frontend and Go deps
Update Ant Design, React i18n, Zod, testing utilities, Oxc tooling, GORM Postgres, Pion transport, and sing dependencies to their latest specified versions.
2026-09-14 19:10:48 +02:00
Sanaei cfd4f64a79 fix(amneziawg): bound the SOCKS5 UDP associate exchange
newSocks5UDPSession only bounded the dial. The greeting, auth and UDP
ASSOCIATE reads on the control connection had no deadline, and they run
inline in UDPRelay.Handle -- on the peer's receive goroutine that delivers
decrypted packets into gVisor. A SOCKS5 server the kernel accepts for but
that never answers (a wedged Xray: its listen backlog still completes TCP
handshakes) therefore parked that goroutine, and every later packet from
the peer behind it, TCP included, for as long as Xray stayed wedged.

One deadline now covers the dial plus the whole exchange and is cleared
once the association is up, since after that the control connection is
only held open. The test drives the exchange against a listener that is
never accepted, which is exactly the hung-server shape.

This was the last of the three defects confirmed on the issue: the header
protection key that could not be cleared went with cfd596a4, the missing
PersistentKeepalive with 8f162994, and the manager lock inversion the same
thread flagged with e95fe80f. The session death the issue was opened for
is not a panel defect. The reporter's own capture on the host NIC shows
the client's packets stop reaching the VPS after the first burst, the
server never sees a second handshake initiation from it, and nothing the
panel sends is outside what a stock amneziawg-go 3.1 server sends (the
Apple client embeds the same library build). That is a client- or
path-side stop, which no server-side change can address.

Closes #6323
2026-09-13 23:29:40 +02:00
Sanaei 22346eef78 fix(node): import a newly selected node inbound instead of sweeping it
Saving the node form writes the grown selection and marks the node dirty
in one transaction. On the next tick ReconcileNode runs before the
snapshot merge, and its delete sweep treats a selected tag with no
central row as "deleted on the master" — so an inbound the operator just
ticked in the picker (or every unselected one, when switching the node
to "all") is deleted from the node before the import that would have
created its row ever runs.

Nothing on disk separates "pending import" from "deleted while the node
was unreachable", but the pre-adoption guard already expresses the
former: while inbounds_adopted_at is zero the sweep waits for a clean
sync to adopt. A save that grows the managed set now zeroes it again,
and the same clean sync re-stamps it, so the offline-delete sweep is
only deferred by one successful sync, not disabled.

The trade: an inbound deleted on the master while the node was
unreachable is re-imported instead of swept if the operator grows the
node's selection during that same outage. That is visible and
recoverable, where the previous behaviour destroyed a live inbound.

Closes #6329
2026-09-13 22:44:50 +02:00
Sanaei c3b08b6d9f fix(tgbot): guard the mock Telegram server's call counts
staleButtonServer increments its per-method map from the HTTP handler, which
httptest runs on one goroutine per connection. #6491's
TestAdminListReadersShareTheWriterLock is the first test to reach it from
several goroutines at once, so CI's race job flagged the helper's map rather
than the code under test.
2026-09-13 12:01:56 +02:00
Sanaei b98f947efe fix(tgbot): answer only the link callbacks that match nothing
#6493 was written against the if/else chain where every served link action
returned early, so its trailing answer ran only for an unrouted payload. #6489
had already turned that chain into a switch that falls through, so after the
merge every served link tap also got an error toast, while an unknown payload
still returned from the !ok branch unanswered.

Move the answer into the !ok branch, the one place nothing matched. This
turns TestClientLinkCallbackServesOwnClient and TestUnroutableCallbackIsAnswered
green again on main's go-test job.
2026-09-13 12:01:55 +02:00
Sanaei 6a159683d5 docs: update star history badges
Replaced the legacy starchart.cc widget with Star History chart and badge embeds in the main README and all localized variants. This keeps the star visual consistent and adds ranked/trending badges for easier repository context.
2026-09-12 11:33:46 +02:00
Sanaei dd46a06761 Update deps and fix AntD Space API
This change bumps the frontend and Go dependency set to newer patch/minor releases, including Vite, react-hook-form, zod, and the x/* Go modules. It also fixes a compatibility issue in the settings UI by switching Ant Design's Space usage from the deprecated `direction` prop to the current `orientation` prop.
2026-09-12 10:59:32 +02:00
Sanaei 19a692e074 fix(install): stop copying tuic-server over /usr/local/bin
install_tuic_server copied the downloaded sidecar onto
/usr/local/bin/tuic-server with cp -f, replacing an operator's own
tuic-server install. The panel resolves bin/tuic-server first
(GetBinaryPath in internal/tuic/process.go) and never reads that copy, so
the copy could only ever clobber a foreign binary.
2026-09-12 10:40:41 +02:00
Sanaei 5815254fc3 fix(tuic): evict the oldest relay flow instead of refusing new clients
udpRelay.flowFor returned "max relay flows reached" once the table held
maxRelayFlows entries, and only the idle sweep (every minute, two-minute
cutoff) freed slots. One host sending a single datagram from each of 4096
source ports therefore locked every new TUIC client out of the inbound for
up to two minutes, repeatably. A full table now evicts the flow last seen
longest ago, which under such a flood is one of the junk flows, and the
newcomer is admitted. TestUDPRelayFullTableAdmitsNewClient fails on the
refusing code with a read timeout for the third client.
2026-09-12 10:40:41 +02:00
Sanaei 3f1e52f09e refactor(panel): drop two duplicated helpers
SettingService.GetDefaultJSONConfig was a byte-identical copy of
GetDefaultXrayConfig with no callers anywhere in the tree.

amneziawgnet.normalizeDNSServer re-implemented the exported
amneziawg.NormalizeDNSServer line for line, in a file that already imports
that package for EffectiveMTU three lines above it. Its two callers now use
the exported one, so the bare-IP-to-host:port rule has a single definition.
2026-09-10 21:08:13 +02:00
Sanaei 0fbdf0f9bf fix(ui): keep the empty-group placeholder legible in dark mode
The "no group" em dash in the clients table and in two client-picker
modals was drawn with an inline color: rgba(0,0,0,0.45). The panel renders
every page under antd's darkAlgorithm as well, so on a dark container that
near-black placeholder is effectively invisible.

All three now use Typography.Text type="secondary", the idiom the rest of
the panel already uses for muted text — GroupAddClientsModal itself uses it
58 lines further down.

Mid-greys such as #888 elsewhere in the panel stay legible in both themes
and are deliberately left alone.
2026-09-10 21:08:05 +02:00
Sanaei 02f2a63c53 refactor(tgbot): extract the shared numeric keypad builder
Six callback flows — limit_traffic, reset_exp, ip_limit and their
add_client_* counterparts — each carried the same 28-line inline keyboard:
cancel, confirm, the 1-9 grid, clear, 0 and backspace. Only the callback
prefix, the threaded email argument, the cancel target and the confirm
label key ever differed, and the labels had already drifted apart between
otherwise identical flows.

numericKeypad now builds that grid from a numericKeypadSpec, cutting 168
lines to 6. The callback data is unchanged: every one of the 84 strings
and all 6 label keys were diffed against the pre-refactor router and are
byte-identical, which matters because encodeQuery hashes any query over
64 chars and buttons already sitting in a user's chat carry these
strings.
2026-09-10 21:07:57 +02:00
Sanaei 8b9cf260b6 perf(clients): batch the client record lookup in bulk operations
BulkResetTraffic resolved every address with its own GetRecordByEmail
call, one SELECT per email, purely to find the disabled clients it has to
re-enable. Resetting 30 clients issued 30 queries before the batched
transaction even started, while BulkAdjust, BulkDelete and BulkSetEnable
next to it already loaded their rows with a single chunked IN query.

Those three carried a verbatim copy each of both the trim/dedupe loop and
the chunked record load, so the reuse is the fix: trimmedUniqueEmails now
delegates to the existing uniqueNonEmptyStrings, and clientRecordsByEmail
holds the one chunked lookup all four call sites share.

A DB failure during the lookup now aborts the reset instead of being
swallowed per email; a missing row is still skipped, as before.

The new test drives BulkResetTraffic with 3 and with 30 emails and fails
unless both issue the same number of SELECTs against clients.
2026-09-10 21:07:49 +02:00
Sanaei 8076d5edfa chore(frontend): bump React and Zod deps
Update frontend dependencies to React/ReactDOM 19.3.0, Zod 4.6.1, and matching React type packages. Also replace Storybook addon-vitest override placeholders with explicit Vitest/browser-playwright versions to keep dependency resolution stable.
2026-09-10 10:13:39 +02:00
Sanaei 33e6c2ec0c chore(deps): raise the swagger-ui-react js-yaml override to 4.3.2 2026-09-10 09:22:54 +02:00
Sanaei 6ee74f2032 fix(amneziawgnet): wait for the client netstack goroutines before closing its device
TestPortForwardRoundTripTCPAndUDP flakes in the race job: closing the test's
client WireGuard device races the goroutines still writing into its netstack.

amneziawg-go's device.Close() calls tun.Close() before it stops the routine
draining the tun, and netTun.Close() closes the unbuffered incomingPacket
channel that WriteNotify sends on. A goroutine still inside a netstack write
when the deferred clientDev.Close() runs therefore closes and sends on the same
channel -- reported as a data race, and on a bad interleaving a "send on closed
channel" panic.

The TCP echo listener, its per-connection copies and the UDP echo all write into
clientNet, and teardown only closed the two listeners before the device: nothing
waited for the goroutines themselves. A WaitGroup deferred right after
clientDev.Close() supplies the missing edge, since LIFO then puts the wait
between the listener closes and the device close.

Confirmed by flooding the existing UDP echo goroutine under GOMAXPROCS=1 and 2,
which failed 3/6 and 2/6 runs with the stack CI reported and 0/12 with the fix.
2026-09-09 09:37:12 +02:00
Sanaei d0edbcec81 feat(xray): update xray-core to v26.9.9 and follow the udpHop move
Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in
DockerInit.sh and release.yml in lockstep.

Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a
standalone "udphop" UDP mask with a different shape (mode / interval /
remotePorts / remoteIPs). The old key is gone from QuicParams, and since the
config loader ignores unknown fields it is now silently dropped rather than
rejected — port hopping just stops.

The panel adapts where that key was live:

- Both link importers rebuilt quicParams.udpHop from the standard mport param,
  so an imported hysteria2 link produced an outbound that no longer hops. They
  now emit a udphop mask in intervalremote mode, which is what the old key did.
  The mode is required: UDPHop.Build() rejects an empty or unknown one.
- validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go
  link generator strips the mask from every link and sub, and Zod strips it on
  the next form round trip.
- mport generation (Go and frontend) reads the mask first and keeps reading the
  legacy key, so inbounds stored before the upgrade still advertise their range.

On an inbound the old key was always inert — only hysteria's dialer consumed
it — so nothing regresses server-side and no migration is needed. udphop stays
out of the mask dropdown on purpose: it is client-only in core, which refuses
to wrap a server socket, and that form is shared with the inbound editor.
2026-09-09 01:53:00 +02:00
Sanaei cfd596a489 fix(amneziawg): let a cleared header protection key reach a running device
amneziawg-go reads an absent UAPI line as "keep the current value", and
addressFingerprint keys only on the addresses and MTU, so an obfuscation-only
edit reconfigures in place rather than rebuilding. Clearing headerProtectionKey
therefore never took effect: the device kept protecting headers with the old
key. The stale key also keeps the S1-S4 minimum in force, so lowering S3/S4 in
the same edit made every later IpcSet fail with -22 — after replace_peers had
already dropped the peers.

Send the all-zero key when the field is empty, which is how the UAPI expresses
"disabled"; an empty value would be rejected, since it decodes to zero bytes.
2026-09-09 00:57:24 +02:00
Sanaei efc603f59c fix(settings): show the SMTP failure reason instead of a raw i18n key
classifySMTPError returned keys already carrying "pages.settings.", while the
four keys TestConnection returns directly do not, and the alert renders every
Message under that one prefix. Any classified failure therefore looked up
pages.settings.pages.settings.smtpErrorAuth, which does not exist, so the panel
printed the key instead of "Authentication failed — check username and
password". The unknown case was worse: it appended the raw error to the key, and
its own text interpolated {{ .Error }}, Go template syntax the frontend's
i18next never fills.

Return the keys unprefixed like the rest, and point the unknown case at the
panel log, which already carries the underlying error.
2026-09-09 00:51:44 +02:00
Sanaei c392f367e1 fix(dns): stop offering a port field that DoH entries discard
Xray ignores port for DoH/DoHL/DoQL, so valuesToWire deliberately stores none
for an encrypted address and a non-standard port has to go inside the URL. The
form kept offering the field anyway, pre-filled with the 53 from its own
defaults: a port typed there was dropped on save and redrawn as 53 on reopen,
which reads as the panel losing the value.

Render the port field only where it is actually stored. DoT keeps it, since
tls:// is not an encrypted-address scheme for this purpose.

Closes #6403
2026-09-09 00:47:18 +02:00
Sanaei 705b291d34 fix(amneziawg): stop losing an inbound and its server keys on the API path
Two saves that the panel UI never makes, but the documented REST API does.

A client whose allowedIPs normalized to empty passed validation, then
InstanceFromInbound skipped the peer and dropped the whole instance when it was
the only one. Nothing logged it, so an enabled inbound simply never opened its
socket. Refuse an enabled peer with no address, naming the client, the way the
injection and collision checks already do.

The server keypair was regenerated whenever a payload omitted privateKey, which
invalidates every client config already distributed, and a payload carrying only
privateKey left publicKey empty so rendered configs got a blank "PublicKey =".
An omitted key now means unchanged: the stored pair is carried forward, a
half-supplied pair has its public half derived, and generation is reserved for
an inbound that has no stored keys at all. UpdateInbound loads the stored row
before normalizing so those keys are available.

Closes #6407
2026-09-09 00:42:52 +02:00
Sanaei 4e423fa452 fix(sub): drop Reality parameters when a host forces plain TLS
A Host row may set Security to tls on an inbound whose own stream is Reality.
The emitted link then carried security=tls next to pbk, sid, spx and the
Reality dest as sni: the endpoint no longer performs a Reality handshake, so
those describe a server the client will never reach, and clients that honour
them fail to connect. Only the security key was rewritten at emit time, and the
existing strip covered alpn/sni/fp/pcs for forceTls=none alone.

Clear the Reality-only parameters before the endpoint's own TLS overrides are
applied, so a host that supplies its own sni or fingerprint still wins.

Closes #6424
2026-09-09 00:37:31 +02:00
Sanaei 65c5580e7d fix(clients): keep per-peer keys when a client spans several tunnel inbounds
A client attached to several WireGuard/AmneziaWG inbounds is that many
independent peers, each with its own keypair, preshared key and tunnel address.
The client edit form can only represent one peer, so Update's per-inbound loop
stamped that single field set onto every attached inbound: every peer ended up
with identical keys and one inbound's address, and the tunnels on all the other
nodes stopped working with no way to recover the overwritten values from the
panel. The only guard covered AllowedIPs, and only for AmneziaWG.

When more than one tunnel inbound is in scope and the caller sent no per-inbound
override, clear the shared peer fields so UpdateInboundClient's existing
carry-forward preserves each inbound's own. A scoped update (?inboundIds=) still
narrows to one inbound and edits it normally.

Closes #6372
2026-09-09 00:30:02 +02:00
Sanaei 2004340d1d fix(inbounds): let a node-adopted inbound keep its own protocol on edit
UpdateInbound restores the stored NodeID before the node-eligibility check, so
the payload can never introduce an assignment there — the check could only ever
fire on a row that already had one. A node's MTProto inbound arrives on the
master by adoption, which does not go through that check, so every later edit of
it was refused with "mtproto inbounds cannot be assigned to a node". That made
the share address of a node-managed MTProto inbound impossible to change from
the panel that generates its subscription links.

Refuse only a protocol change into an ineligible protocol, which is the one way
an update can still strand a row the master's sidecar loops would never
reconcile.

Closes #6415
2026-09-09 00:24:01 +02:00
Sanaei bc57548a35 fix(clients): withdraw the delete tombstone when the email is re-created
Deleting a client tombstones its email for 90s so a node snapshot captured
before the deletion cannot resurrect it. Nothing withdrew that tombstone when
the operator re-created the same email, so on a master with at least one node
the next merge filtered the live client out of the snapshot and SyncInbound
pruned its inbound link. The client reappeared only once the tombstone expired,
which is the 90-120s detach window reported.

Withdraw it on a successful create, single and bulk, so a tombstone can never
outlive the identity it was meant to bury. A failed create still leaves it
standing, which is what keeps the stale-snapshot guard intact.

Closes #6370
2026-09-09 00:17:54 +02:00
Sanaei 246d9207a5 fix(sub): emit a bare host in Clash proxies
A Clash "server" is a bare host, not a URI authority, but the custom share
address strategy stores an IPv6 literal with brackets so the address normalizer
can hand it to the raw link generators. The Clash renderer copied that value
into every proxy verbatim, so mihomo received server: "[2001:db8::1]" and
failed to parse the node. Raw links were unaffected because joinHostPort strips
the brackets and re-adds exactly one.

Strip them once where the renderer takes the resolved dest, which is the single
place all three proxy builders read the address from.

Closes #6373
2026-09-09 00:17:45 +02:00
Sanaei 20d7f91c65 refactor(ci): add an adversarial pass and name the analyst briefing
REVIEW.md told the reviewer which repository rules to check but never to try
breaking the change, so the conditions this panel actually meets went
unexamined. "Try to break it" adds six, each tied to a mechanism here rather
than to a generic checklist: an upgrade over an operator's existing rows and
the rollback that reads them again, a restart that drops in-memory state under
the cron jobs, a sub-node racing the master on the same row, an operation
applied twice, an inbound or client at the empty and the thousand end, and a
dependency that is down. It closes with the gate that running a case is not
reporting it - each one still has to clear the verification bar below it, so
the section cannot become a licence for hypotheticals.

repo-context.md said nothing about which bot reads it. Only the issue analyst
does, since the review job's briefing moved inline in acf3603d, so it becomes
issue-analyst-context.md and its title names the analyst instead of "the Claude
bot". bot_context_test.go pins that path in a constant, so the rename carries
through the constant, the four test names and the two comments that named the
old file - one of which still said "the bot prompts", plural.

Backticks come off mtg-multi in the new section: the same test file reads any
hyphenated backticked token in REVIEW.md as a CI job name, and fails on one
ci.yml does not define.
2026-09-08 21:29:21 +02:00
Sanaei acf3603dc8 refactor(ci): review pull requests with one senior-engineer role
The review job ran the official code-review plugin, which fans a pull
request out to five Sonnet reviewers plus a Haiku scorer per finding and
drops everything scored under 80, and the briefing file spent most of its
lines overriding that plugin. Both are gone: the job hands one Senior
Software Engineer prompt to the action inline, the way the issue analyst
does, and denies the Agent tool so the single role is mechanical rather
than a request.

REVIEW.md moves from the emoji markers to CRITICAL/HIGH/MEDIUM/LOW with a
pre-existing qualifier. The uncapped rule is scoped to findings the pull
request introduced or worsened so it cannot collide with the cap of three
pre-existing ones. "A finding is a report, not a patch" stays as it was.

Workflow housekeeping: GH_TOKEN, REPO and PR live in the job env instead
of six step copies; the skip gate is per pull request, so a head pushed
after the automatic review is reviewed only on @claude review; the comment
counters sum gh's per-page jq output, which read "0\n0" as a review on a
pull request with more than 100 comments; --max-turns rises to 300 because
every read now costs the single agent a turn instead of a subagent.
2026-09-08 18:46:30 +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
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
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
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
Sanaei 65b9bfed8b fix(ci): stop the review bot handing over fixes in prose
The `suggestion` blocks stopped once the briefing moved into its own file, but
the carve-out that survived — "one clause naming where the fix belongs" — was
being stretched from a location into an instruction. #6397 dictated what to
write in a comment and which existing test to copy; #6394 named the fix
outright. The clause now permits a file, a function, a symbol or a layer and
nothing about what happens there, and closes the stretch three ways: prose is
a patch the moment a verb describes the change, so is holding up an existing
symbol as the model to copy, and a clause the maintainer could apply as
written is the fix however it is punctuated.

Three rules the rubric was missing, none of which existed anywhere. A 🔴 or 🟡
says in one clause what the change did to the code it is about, the way a 🟣
already says it predates it — otherwise nothing in the comment shows the
marker was earned. A claim about a caller or a callee needs that file read:
the dispatch-rule violation this repo cares most about sits a frame outside
the diff, and the skill is told to avoid reading past the changes. And nothing
pads the comment.

The briefing's one named override aimed at a step that does not exist. The
plugin the job loads defines no `--comment` flag and mentions suggestions
nowhere, so `max --comment <target>` is inert trailing text. Replaced with the
six overrides that are real: the skill calls pre-existing issues and unmodified
lines false positives, drops every finding its confidence pass scores under 80
and then posts nothing at all (a nitpick scores 50, so that filter empties all
five nit slots), says to avoid emojis against a severity system that is three
of them, mandates a "Found N issues" format, and forbids reading build signal.
2026-09-03 13:42:48 +02:00
Sanaei 38dd9bcc70 Bump Go dependency versions
Refresh the Go module set in go.mod and go.sum to newer patch/minor releases, including xray-related dependencies, gRPC, WireGuard, and supporting indirect libraries. This keeps the project aligned with upstream fixes and compatibility updates without changing application code.
2026-09-02 21:59:39 +02:00
Sanaei e264ea89c1 chore(deps): bump docs and frontend deps
Update dependency versions across `docs` and `frontend`, including Next/Fumadocs packages in docs and Ant Design, React Query, Storybook, and related tooling in frontend. Also updates lint/format tool versions (`oxlint`, `oxfmt`), bumps docs `pnpm` package manager version, and refreshes workspace release-age exclusions for the newly upgraded docs packages.
2026-09-02 21:37:55 +02:00
Sanaei ac193cd9d3 refactor(ci): split the issue analyst out and brief the review job from a file
The issue analyst moves verbatim from claude-bot.yml into its own
claude-issue-analyst.yml, so claude-bot.yml now holds only the pull-request
side: review, @claude mentions and conflict resolution.

The review job's briefing was a single 2,600-character quoted string inside
claude_args, unreadable and unreviewable. It now lives in
.github/claude/review-job.md, assembled at run time with a "This run"
section that hands the reviewer the pinned head SHA, the pull request and
the exact check-runs command, and reaches the CLI through
--append-system-prompt-file. The agent-mode action sets no system-prompt
append of its own, so the file flag cannot collide with one.

Findings no longer carry the fix: REVIEW.md and the brief both forbid
suggestion blocks, patches and replacement snippets, overriding the
code-review skill's --comment step, which attaches a committable suggestion
to any small fix. A finding states what is wrong, where, what triggers it
and what breaks; the maintainer decides the change.
2026-09-02 21:06:58 +02:00
Sanaei f727d04f65 v3.7.0 2026-08-24 15:07:15 +02:00
Sanaei fcf60eb2e2 chore: bump dependencies and clear deprecated frontend APIs
Routine dependency refresh: telego 1.11.2, go-sqlite3 1.14.50, grpc 1.83.1,
miekg/dns 1.1.73, sing 0.8.14 and the usual indirect churn on the Go side;
react-query 5.102.2, i18next 26.4.0, react-hook-form 7.86.0, Storybook
10.5.10 and vite 8.2.2 on the frontend, which also lifts the private frontend
package to 1.0.0.

That left npm run lint:deprecated with five call sites. Zod 4 deprecates the
ZodTypeAny alias in favour of the bare z.ZodType constraint, and react-query
renamed queryClient.fetchQuery to queryClient.query ahead of removing the old
name in the next major — the two share an implementation, so the swap in the
settings test is behaviour-identical.

Also untracks internal/web/dist/.gitkeep. 1872659d dropped its gitignore
exception on the grounds that nothing under dist/ is ever meant to be
tracked, but the file was already in the index, so the rule never applied and
every frontend build that empties dist/ resurfaced it as a spurious deletion.
make dist-stub and every CI job recreate it on disk.
2026-08-24 14:56:40 +02:00
Sanaei 103b0dfe8d fix(job): expire stored client IPs of offline clients
ipStaleAfterSeconds was only applied while a row was being rewritten, and
rows are only rewritten for clients present in the current online scan. A
client that stopped connecting therefore kept its last addresses forever
in inbound_client_ips, and node_client_ips rows (including those of
deleted clients) were never revisited at all. Sweep both tables every five
minutes, dropping entries past the cutoff and deleting rows that end up
empty. The sweep runs ahead of the fail2ban and api-mode gates so
retention holds even on panels that collect nothing.

Closes #6286
2026-08-24 13:27:40 +02:00
Sanaei 2d30ab3ada fix(panel): stop one poisoned DNS answer from blocking outbound tests
SanitizePublicHTTPURL rejected a hostname as soon as any single resolved
address was blocked, so a resolver returning a bogon AAAA for the test URL
host (e.g. 2001::1 for www.google.com, inside the Teredo range blocked
since b51f0976) failed the outbound Check button outright — including TCP
mode, which never uses the test URL. Mirror SSRFGuardedDialContext instead:
one usable address is enough, because the guarded dialer skips blocked
answers at connect time; a hostname with nothing usable is still refused.

Closes #6290
2026-08-24 13:27:40 +02:00
Sanaei d175050f2e fix(job): force-disconnect over-limit Hysteria2 clients
disconnectClientTemporarily still gated on the protocol list from before
XrayAPI.AddUser learned hysteria, so an over-limit Hysteria2 client kept
its QUIC session until the fail2ban ban aged out, while a VLESS client in
the same situation was dropped at once. buildUserAccount handles hysteria
and model.Client already marshals the auth field the re-add needs, so admit
the protocol. wireguard stays excluded: its keepAlive marshals as a JSON
number, which the string-only user-field parsing rejects after the user was
already removed.

Closes #6256
2026-08-24 13:27:40 +02:00
Sanaei 7a595cb46d fix(sub): keep Hysteria2 mport on external-proxy links
genHysteriaLink only looked up the UDP hop range on the no-endpoint path,
after the externalProxy fan-out had already returned. An inbound with Hosts
therefore emitted per-host links without mport, so clients pinned themselves
to the single listening port and silently lost port hopping. Set the param
before the fan-out so every endpoint inherits it, matching the frontend
link builder and the Clash emitter.

Closes #6264
2026-08-24 13:27:39 +02:00
Sanaei d9b599b9aa fix(sub): forward tlsSettings.cipherSuites into the JSON subscription
tlsData rebuilds the client-side tlsSettings from a whitelist of keys
and never copied cipherSuites, so an inbound configured with e.g.
"TLS_AES_256_GCM_SHA384" handed clients a config that negotiated any
suite. Copy it through when non-empty; it is a real xray-core
tlsSettings field, unlike the non-standard "cs" share-link param.
2026-08-24 02:38:40 +02:00
Sanaei cc245a908e style: format struct literals and whitespace
Clean up trailing braces, commas, and unnecessary blank lines in struct initializations across sub and network packages.
2026-08-24 00:06:01 +02:00
Sanaei c26ff59b47 chore(sub): drop the inlined externalLinkEnabled test helper
81fcacab applied the helper's own //go:fix inline directive, rewriting its
only call site to new(false). The function was left behind with no callers.
2026-08-23 23:18:49 +02:00
Sanaei 81fcacab11 chore(build): bump Go toolchain to 1.27.0
Go 1.27.0 shipped on 2026-08-19. Raise the go directive and the builder
image so Docker and release builds pick it up; every CI job already reads
the version from go.mod, and golangci-lint v2.13.1 release binaries are
themselves built with go1.27.0, so the lint job needs no pin change.
2026-08-23 21:57:03 +02:00
Sanaei a3e617215c fix(ci): pin the head the review job checks out
The review job checked the pull request out through refs/pull/N/head, a
ref the author can move after a maintainer types "@claude review". Code
scanning flagged it twice on the issue_comment path: an untrusted
checkout in a privileged context (alert 111) and the time-of-check /
time-of-use race that ref creates (alert 110).

Resolve the head once, up front, and refuse the run when the fork was
pushed to after the request that vouched for it, mirroring the freshness
gate resolve-conflicts already uses; the checkout then names that
immutable SHA. pull_request_target runs take the head SHA straight from
the payload, so they skip the comparison. The trailing "posted nothing"
check no longer fires on top of a refusal, which would otherwise report
a second, misleading failure.
2026-08-22 21:09:24 +02:00
Sanaei 5321665d5b feat(ci): give the review bot a severity scale and a tally
REVIEW.md said what blocks and what does not, but never how to mark a finding,
so every review invented its own shape and none carried a severity. It now
names the three markers the hosted Code Review service uses - Important, Nit,
Pre-existing - and keys them to what the pull request did rather than to how
alarming a defect looks alone: a defect it introduces or worsens is Important,
one it merely brought into view is Pre-existing and cannot be a reason to hold
it. Pre-existing was missing entirely, and checking what this panel emits means
reading far outside the diff, so those findings had nowhere to go except a
wrong Important or silence.

The volume cap said how many and never which. It now collapses a nit repeated
across files into one finding, prefers a nit in code the pull request wrote
over one in code it only moved, caps pre-existing findings at three, and states
that Important findings are never capped - a section listing two caps otherwise
reads as licence to trim what matters. The review opens with a tally so the
author sees the shape before the detail.

Two contradictions went with it. The file told the reviewer to skip what CI
enforces and then to check that a new i18n key reaches all 13 locales, which
i18n-dead-keys.test.ts pins in both directions - the rule moves to "Do not
report" with the reason. "Anything CI already enforces: npm audit" overstated
what runs; CI audits production dependencies at high and above, so a
dev-dependency advisory is out of scope by design.

The reviewer could not read its own CI. Only postgres-durable-first runs
against PostgreSQL, and XRAY_E2E_BINARY and XUI_SCALE_TEST are set by no job,
so a dialect or migration change can carry a wall of green while the paths it
touches never executed. That belongs to the verification bar, next to the rule
that a behaviour claim needs a file:line citation, and "CI passed" now needs a
run actually read. Also names the two house choices no linter defends: neither
golangci-lint nor oxlint rejects a testify or Tailwind import.

Both kinds of claim rot on a rename, so a test pins them the way
repo-context.md's claims are already pinned - the CI jobs REVIEW.md names must
exist in ci.yml, the skip gates it calls unset must stay unset, and the locale
count must match the directory.

The review itself moves from high to max effort, and the prompt records why it
names REVIEW.md at all: the code-review skill reads CLAUDE.md on its own but
not REVIEW.md, so dropping that clause would silently stop the file applying.
Drops a CLAUDE.md reference to tools/seedperf/, which no longer exists - the
review reads that file as project context, so a stale path there misleads it.
2026-08-21 03:37:12 +02:00
Sanaei 73a971c2d1 fix(ci): give the review bot the pull request's own code and CI verdict
Three consecutive review runs (#6105, #6265, #6272) posted accurate findings
but ended with the same "nothing was verified" paragraph, and the transcripts
show why: under pull_request_target the only checkout is the base branch, so
every Read of a changed file returned the pre-merge version and the agent
fell back to fetching blobs one at a time through the API — 452 Bash calls on
#6105 alone. It tried `git fetch origin pull/N/head` in all three runs and was
denied every time.

Check the head out read-only beside the base tree and say so in the prompt, so
the reviewer greps the code actually under review. Nothing builds or executes
from pr-head/: this job carries a write-scoped token, which is exactly the
pwn-request REVIEW.md classes as blocking.

CI had already run the full gate on each head SHA, but no run ever looked —
`check-runs` appears in none of the three transcripts. Point the reviewer at
it so a red or missing required check becomes a finding instead of a
disclaimer.

Also pass an explicit review level: with none given the skill reuses the last
one typed, which in CI does not exist (ReportFindings recorded level=null on
#6272). And allow WebFetch/WebSearch — the PIA review was denied both while
trying to confirm the bundled PIA public key, then had to file that same check
as unverified.
2026-08-20 23:12:35 +02:00
Sanaei 19a2c23c01 fix(ci): repair the review comment and the conflict-resolution guard
Two failures from the same afternoon, both in the bot workflow.

The review of #6272 ran for 34 minutes across four subagents and posted
"No issues found. Checked for bugs and CLAUDE.md compliance." — three
lines for a 73-file diff. The agent had written a per-area coverage
summary in its own last turn and then dropped it on the floor, because
the code-review skill's comment template carries findings and nothing
else. A comment that cannot distinguish a thorough clean review from a
run that died early is not evidence, so REVIEW.md now states what the
posted comment must show and the system prompt points the run at it.

The same run logged 67 permission denials. Only the inline-comment MCP
tool was named in --allowedTools, so `gh api`, writing the diff to a
scratch file, and reading it back were all auto-denied: agents spent
turns hunting for a writable directory, and the openapi.json copy check
REVIEW.md calls blocking could not be run at all ("gh api was
unavailable in this sandbox"). Name the tools the review actually uses.

The conflict resolution on #6243 resolved both conflicted files
correctly and was then rejected by its own guard: "Edits outside the
conflicted set: CLAUDE.md". The agent never touched CLAUDE.md — it had
Edit rights on exactly two paths and no shell. claude-code-action
deletes and restores CLAUDE.md, .claude/, .mcp.json and friends from
the base branch before it runs, because the PR head is untrusted, and
that restore is what dirtied the tree. Name that set once, exclude it
from the stray-edit check, and hand back rather than resolve when a
conflict lands inside it — the restore would silently overwrite the
resolution and stage the base copy.
2026-08-20 20:14:24 +02:00
Sanaei e4798a027c chore(lint): adapt to staticcheck v0.8.0 under golangci-lint v2.13.1
golangci-lint v2.13.0 pinned honnef.co/go/tools v0.8.0-rc.1, whose
staticcheck never terminates on internal/web/service/tgbot: the run pins
~520% CPU with RSS climbing past 700MB rather than deadlocking, so it
reads as a hang. controller/, job/ and service/... only appeared stuck
because they pull tgbot into the analysis graph. v2.13.1 ships the final
v0.8.0 and clears it — that package goes from unbounded to 0s, and a
cold full run to 22s. CI needs no pin; it already tracks latest.

The same bump reworded SA1019 from parser.ParseDir to go/parser.ParseDir,
which silently voided the openapigen exclusion, so the pattern now
matches either spelling.

fasthttp Client.RetryIf is deprecated in favour of RetryIfErr. The old
path left resetTimeout at its zero value, so returning false preserves
the existing retry timing exactly.

The rest are gofumpt redundant-paren removals from the stricter
formatter — semantic no-ops.
2026-08-20 19:37:40 +02:00
Sanaei 845abc380e fix(ci): make the review bot post its findings and acknowledge mentions
Three separate ways the bot went silent after the move to the official
code-review skill:

- The skill skips a PR it has already commented on without comparing the
  reviewed head to the current one, so #6272 got no review of the commits
  pushed after the first pass. A prior review now only justifies a skip
  when its "Reviewed head:" SHA matches the current head, and never when
  the run came from an explicit "@claude review".
- The review agent launched its subagents in the background and ended its
  turn to wait for them. A headless run terminates on end_turn, so the
  findings were discarded and the job still reported success. The prompt
  now requires foreground subagents, and a new step fails the job when a
  run posts nothing for the current head, instead of passing green.
- A custom prompt puts claude-code-action in agent mode, which never adds
  the eyes reaction, so a mention gave no sign it had been picked up.
2026-08-20 15:59:51 +02:00
Sanaei 58669f6146 refactor(ci): replace the in-house review lanes with the official code-review skill
The four pull_request_target review jobs in claude-bot.yml (Senior
Developer / QA / Tester / Arbiter and their shared rubric) are replaced by
a single review job running the official code-review plugin - the same
skill behind Anthropic's hosted Code Review and the review workflow
/install-github-app generates. The hosted service needs a Team/Enterprise
organisation, so the plugin runs in CI on the maintainer's subscription
instead: inline findings on PR open and ready-for-review, plus manual
(re-)review when the owner or a collaborator comments "@claude review".

The official example triggers on pull_request, but GitHub withholds
secrets from fork runs and essentially every 3x-ui pull request is from a
fork, so the job keeps the lanes' pull_request_target posture: the
workspace is the base revision and nothing from the pull request is
checked out or executed.

What the lanes uniquely knew is distilled into REVIEW.md, handed to the
skill via --append-system-prompt and pinned by bot_context_test.go the way
repo-context.md is: the runtime.Runtime dispatch rule, migration and
upgrade safety, the four-step route contract chain including the unchecked
docs copy, the i18n rule, the three link implementations, and the
wire-format verification bar. The mention job now ignores "@claude
review" comments on pull requests so the review trigger does not also
wake the generic bot, and the lane-only rubric file goes with the lanes.

The remaining prompts also lose their tone micro-rules (no emoji, no
exclamation marks, no filler) and the workflow's comment banners are
removed.
2026-08-20 15:09:48 +02:00
Sanaei 19e71d9acc refactor(ci): move the bot's repository briefing into versioned files a test pins 2026-08-20 05:18:13 +02:00
Sanaei f7db247b07 perf(clients): write client_inbounds deltas and check identity from the clients table
Client CRUD latency scaled with the number of client-inbound edges rather
than with the size of the change. On a 5k-client / 8-inbound / ~56k-edge
PostgreSQL panel, creating one client took 60-120s (#6252).

Two independent causes, both confirmed by the reporter's pg_stat_statements
and reproduced locally at their topology.

SyncInbound deleted every client_inbounds row for an inbound and re-inserted
the whole set, so a one-client edit rewrote thousands of unrelated rows. The
dominant caller was not user CRUD: the node traffic poll re-syncs every node
inbound from its snapshot every 5s, so the panel churned the entire
membership table continuously in the background. SyncInbound now reads the
current links and writes only the difference - insert missing, update a
changed flow_override, delete departed. Callers are unchanged, so every
reconciliation path benefits, and the four hot client CRUD paths additionally
pass only the clients they touched via ApplyInboundClientDelta.

The insert needs clause.OnConflict: the unconditional delete it replaces also
serialized concurrent syncs of one inbound, and the node poll commits in its
own transaction outside the serialized writer, where a duplicate key would
abort the whole poll on PostgreSQL.

Identity and membership questions expanded every inbound's settings.clients
JSON - 5.75s per call under the reporter's load. They now read the indexed
clients and client_inbounds tables, which every read path already trusts,
over just the emails being checked. A LOWER(email) expression index keeps
the case-insensitive matching indexed; a struct tag cannot declare one.

Measured on PostgreSQL 17 at 8 inbounds x 6000 clients, rows written to
client_inbounds per operation, before -> after:

  create across 8 inbounds   48008 ins / 48000 del  ->  8 ins / 0 del
  update the client          48008 ins / 48008 del  ->  0 ins / 0 del
  detach from 4 inbounds     24000 ins / 24004 del  ->  0 ins / 4 del
  delete the client          24000 ins / 24004 del  ->  0 ins / 4 del

Two behavior changes worth naming. An email seen with two different subIds
across two inbounds' JSON used to be locked so that no add could claim it,
including the one with the correct subId; the clients row now adjudicates.
And on an install whose settings JSON holds an email with no matching link,
"is this email on another inbound" now answers no, so deleting it elsewhere
purges its traffic rows; compactOrphans and the startup heal already
converge such drift.

Every added test was verified against a hand-written mutation of this change,
so none of them pass regardless of the fix. One mutation survives on purpose:
swapping OnConflict DoUpdates for DoNothing is only observable when two
transactions race the same row, and a timing-dependent test would be flaky.

Per-node batching of remote pushes and the metadata-only inbounds list from
the same report are deliberately not in this change.

Closes #6252
2026-08-20 04:09:57 +02:00
Sanaei c8a3a2d723 fix(security): require a 2FA code to replace the stored TOTP secret
The confirmation gate in updateSetting only covered the true -> false
transition, so a settings save that kept twoFactorEnable=true while
carrying a non-blank twoFactorToken silently rebound the authenticator.
preserveRedactedSecrets restores the stored secret only when the
submitted one is blank, so a non-blank value went straight through
without any branch asking for a code.

Not reachable pre-auth or cross-site (CSRFMiddleware rejects unsafe
methods without the session token), but it matters after a session
hijack or with an admin API token, which sets api_authed and
short-circuits the CSRF check: the attacker gains persistence and locks
the legitimate operator out of their own authenticator.

Now a code is required whenever 2FA is currently on and the submitted
secret differs from the stored one. Enabling from off is untouched, as
no code exists yet to verify, and a blank secret still means
"unchanged", so the panel's normal save path is unaffected.

Reported by @n0ctal (GHSA-xqqw-jqqv-99h6).
2026-08-19 19:54:15 +02:00
Sanaei b51f09768b fix(netsafe): classify IPv6 transition and CGNAT ranges as internal
IsBlockedIP leaned entirely on Go's net.IP predicates, which judge an
address by its own range only. 6to4 (2002::/16), NAT64 (64:ff9b::/96 and
64:ff9b:1::/48) and Teredo (2001::/32) each tunnel an arbitrary IPv4
destination inside an IPv6 address, so all five predicates returned false
for e.g. 64:ff9b::7f00:1 and the SSRF guard waved it through. CGNAT
(100.64.0.0/10) and the deprecated site-local block were unclassified for
the same reason. Reported as GHSA-cfpf-wmjp-gh6c.

Reaching the embedded IPv4 needs a 6to4 tunnel, NAT64 gateway or Teredo
client on the host, none of which exist by default, so this is hardening
rather than a live path off a stock install. The guard backs outbound
subscription fetches, node sync, reality scan, the tgbot API URL and the
xray setting test URL, which is reason enough to close the gap.

The deprecated and local-use prefixes are blocked outright since nothing
public routes through them. The NAT64 well-known prefix is judged by the
IPv4 it embeds instead: on a DNS64 network every public IPv4 host resolves
into it, so blocking it wholesale would break legitimate fetches.
2026-08-19 19:37:14 +02:00
Sanaei 3c087f6fd9 chore(docs): update dependencies and adapt to zbsearch 4
fumadocs-core 16.14.5 switched its search engine from Orama to zbsearch 4,
so the panel docs follow it up to the same major.

zbsearch 4 still rejects locale codes as tokenizer languages ("en" throws,
only "english" is accepted), so the custom search dialog that forces an
English index stays necessary — verified by loading the built static index
for all four locales and searching it through fumadocs' own client.

Around that:
- use `staticClient`, as `oramaStaticClient` is now a deprecated alias
- drop @orama/orama, which nothing depends on or imports any more
- correct the two comments that still described Orama and pointed at its
  docs and tokenizer package, one of them suggesting a language zbsearch
  does not have
- restore the corepack integrity hash on `packageManager`, which CI reads
  through pnpm/action-setup
- prune minimumReleaseAgeExclude entries for versions no longer installed

The API reference MDX changes are serialization-only: fumadocs-openapi
11.2.4 emits plain scalars where it used folded ones. Parsed frontmatter
and page bodies are unchanged.
2026-08-19 18:38:40 +02:00
Sanaei ce63bf3e66 fix(frontend): restore the two rolldown bindings npm dropped from the lockfile
The from-scratch lockfile regeneration in b9eda09d bumped rolldown 1.2.4 ->
1.2.5 but wrote back only 14 of its 16 optional platform bindings: npm
removed the old @rolldown/binding-darwin-x64 and
@rolldown/binding-linux-arm64-gnu entries and never added the 1.2.5 ones.
Both are still listed in rolldown's optionalDependencies, so the packages
section no longer matches the dependency graph.

npm ci validates the whole ideal tree, not just the packages installable on
the current platform, so it aborted with EUSAGE everywhere and took down all
four workflows that install the frontend - CI, Release, CodeQL and Docs
Deploy - each at its first npm ci step. The Go jobs were unaffected.

Regenerated with a clean npm install --package-lock-only, which resolves from
registry metadata alone and keeps every optional binding regardless of the
host platform. The diff is purely additive - the two missing blocks, no
version changes.
2026-08-19 18:18:49 +02:00
Sanaei b9eda09da9 chore(frontend): update dependencies and adapt to oxlint 1.79
npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7
and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11,
and npm would not move either. Neither npm update, a targeted install, nor
--package-lock-only broke the cycle, so node_modules and package-lock.json
were regenerated from scratch (601 packages, 0 vulnerabilities).

oxlint 1.79.0 then promoted five React Compiler rules into the correctness
category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree,
so nothing in our code changed - the rule set grew. They are fixed rather
than suppressed:

- refs (31): latest-value ref writes moved out of render into an effect.
  onlineClientsRef turned out to be write-only and is gone; expireDiffRef
  and trafficDiffRef were replaced by reading the values directly.
- set-state-in-effect (55): reset-on-open modals now adjust state during
  render; where an effect mixed a synchronous reset with an async fetch, the
  reset moved to render and the effect kept only the request. useMediaQuery
  became useSyncExternalStore.
- preserve-manual-memoization (11): optional-chained deps the compiler cannot
  match, hoisted to locals or dropped where the memo wrapped a string concat.
- purity (3): Date.now() in render replaced by a state-backed clock, which
  also refreshes the expiry tag every 60s instead of freezing it until the
  next unrelated re-render.
- immutability (1): applyClientStatsEvent merged websocket traffic into
  DBInbound rows in place; it now rebuilds only the rows it touches.

Two things fell out of that. clientCount is derived with useMemo instead of
an imperative rebuildClientCount() called from five sites, which also fixes a
staleness bug where changing the expiry or traffic threshold left the counts
alone until some later rebuild. statsVersion existed only to force a
re-render after an in-place mutation, is meaningless now that rows are
replaced, and nothing read it, so it is removed.

Also adds a lint:fix script - oxlint --fix was previously only reachable
through the lint-staged hook.
2026-08-19 17:48:28 +02:00
Sanaei 92fb94d856 Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint

TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.

Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.

Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.

oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.

The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.

Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.

Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
  cast, which hid the optional chain from ESLint and would throw on a
  null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
  accessible but oxlint cannot evaluate it, so it gets a scoped disable.

* chore(docs): replace Prettier with oxfmt

oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.

The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)

The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.

.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.

oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.

* style(frontend): adopt oxfmt and format src

frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.

Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.

Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.

Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
  leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
  line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.

* ci: enforce formatting in CI and make verify

Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.

Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.

Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.

No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.

* ci: trigger CI on Makefile changes

The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.

* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components

`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.

Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.

Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.

Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.

* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard

Addresses the review on #6262.

The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.

The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.

The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.

Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
  hand-written lint logic in the repo is no longer the least covered
  file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
  CI gate in this PR while the hook only ran the linter, so a commit
  could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
  and make msw-worker-check byte-compare stay safe even if oxfmt is
  invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
  carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
  both accept JSONC, so relocating them was unnecessary.

Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
2026-08-19 15:36:27 +02:00
Sanaei 1872659d83 chore(gitignore): fully ignore internal/web/dist, build stub included
The !internal/web/dist/.gitkeep exception kept the dist build stub
permanently visible as untracked noise and let it slip into commits four
separate times (each reverted with a 'drop the accidentally committed
dist build stub' commit). Nothing under dist/ is ever meant to be
tracked — make dist-stub and CI recreate the stub on disk — so drop the
whole exception block and let the plain dist/ rule cover it.
2026-08-18 12:42:49 +02:00
Sanaei 6638ac4a1e i18n: translate importKeepHostSettings keys
Translate the `importKeepHostSettings` and `importKeepHostSettingsDesc` keys from English placeholders into 10 locale files (ar-EG, es-ES, fa-IR, id-ID, ja-JP, pt-BR, tr-TR, vi-VN, zh-CN, zh-TW).

Also removes `main_test.go` which contained a test for a `commandHelp` function.
2026-08-18 12:27:33 +02:00
Sanaei 8cec47a8a5 fix(ci): resync the bot prompts with the repo and close the gaps an audit found
The three prompts still enforced the comment ban CLAUDE.md replaced with
the 2-line cap on Aug 1 (1ff90c5b), so the review bot would flag every
legitimate short comment; frontend/CLAUDE.md and CONTRIBUTING.md carried
the same stale rule. The PR reviewer's recipe for reading a post-change
file (headRefOid + pr diff) was unfulfillable with its allowlist - it now
fetches refs/pull/N/head and reads blobs via git show, object-only, no
checkout. Conventions the reviewer checks now include the unchecked docs
openapi.json copy step, the docs/lib/xray third link implementation, the
both-ways route contract, and the i18n dead-key half of the rule.

Also: drop the SUBPROCESS_ENV_SCRUB=0 override on the two untrusted-input
jobs (the mention job proves gh works scrubbed); teach the triage prompt
the issue forms (pre-applied labels, required fields, no re-asking); add
a security-report exception plus SECURITY.md so vulnerabilities are not
confirmed publicly; add a clarification follow-up job so a reporter's
reply to "clarification needed" is actually processed; review PRs again
on ready_for_review and skip drafts; stamp the reviewed head SHA so
force-pushes visibly date a review; scope gh issue/pr edit to label and
title flags; per-job concurrency; comment guards now match the actual
bot login after the run started; artifact names survive re-runs; the
mention prompt's repo map and env-var facts corrected (XUI_PORT,
XUI_TUNNEL_HEALTH_*, distro env files, memory.high, encrypt-tokens).
The bug and feature forms also referenced a "needs triage" label that
does not exist in the repo and was silently never applied - dropped.
2026-08-17 02:41:21 +02:00
Sanaei b53a5515d6 fix(frontend): make the jalali expiry clear button actually clear
persian-calendar-suite seeds today's date and emits it whenever it mounts
without a value. Clearing the expiry remounts the picker with a null value,
so the library immediately fired onChange(today) and the date came straight
back — and it also painted that seeded date into its read-only input.

Swallow the mount-time emit (re-armed on every clear-remount) and hide the
seeded text while the value is empty, so a cleared expiry stays empty and a
fresh client/inbound form no longer silently adopts today as its expiry.
2026-08-15 23:35:00 +02:00
Sanaei f22df49a71 fix(sub): restore the subscription info page for browser visits
Revert 43bc9153 and its follow-up 338822ab. The copy-only notice replaced the
themed sub page for every browser request, so mobile users got a bare "This is
a subscription link" screen instead of their traffic, expiry and links — and it
left serveSubPage plus the custom-theme renderer as dead code.
2026-08-15 22:11:37 +02:00
Sanaei 338822ab07 fix(sub): keep copy page within mobile viewport
Constrain the copy-only subscription page to the dynamic viewport, wrap long localized text, and infer text direction so mobile browsers cannot render a horizontally shifted desktop-width page. Add regression coverage for the responsive layout contract.
2026-08-15 21:16:13 +02:00
Sanaei acbf09e710 fix(frontend): restore responsive table height
Remove viewport-capped vertical scrolling so page size controls the rendered table height and page scrolling remains responsive.
2026-08-15 18:13:10 +02:00
Sanaei 03950b1295 fix(frontend): disable table virtualization
Removes the `virtual` table option from Clients, Inbounds, and Nodes list pages. This aligns table behavior across key admin views and avoids virtualization-related rendering/interaction issues with the existing scroll and pagination setup.
2026-08-15 17:33:56 +02:00
Sanaei d291e1c5ee Bump Go toolchain and x dependencies
Refresh the Go toolchain from 1.26.5 to 1.26.6 and update the related x/* and protobuf dependency set in go.mod/go.sum. This keeps the project aligned with the current patch releases and ensures the module graph matches the expected transitive versions.
2026-08-14 17:02:17 +02:00
Sanaei 5b80d4562d chore(docs): bump docs dependencies
Update fumadocs-core/mdx/ui to 16.14.3, lucide-react to 1.31.0, @types/node to 26.2.0, typescript-eslint to 8.67.0, esbuild to 0.28.2, shiki to 4.4.3, and various other transitive dependencies.
2026-08-12 19:59:41 +02:00
Sanaei f3f57e66f5 fix(frontend): wait out Collapse fade before a11y scan in ConfigBlock story
Collapse animates opacity in over motionDurationMid; the Collapsed story's
play function only waited for visibility, so the addon-a11y color-contrast
check could sample a mid-fade, lower-contrast frame and fail flakily in CI.
Wait for the panel's opacity to settle to 1 first.
2026-08-12 16:43:57 +02:00
Sanaei 8a8da88548 fix(frontend): isolate swagger deps from main vendor chunk
Keep swagger-ui-react and its transitive dependencies in the lazy swagger chunk so the initial panel bundle stays smaller. This avoids eager loading the OpenAPI UI on first paint while keeping the API docs route unchanged.
2026-08-12 16:11:47 +02:00
Sanaei 1f846c3cb2 fix(frontend): clean test validation output 2026-08-12 15:35:20 +02:00
Sanaei 1c255fc00c chore(frontend): bump npm dependencies
Refresh frontend package versions and regenerate the lockfile. This updates core UI and tooling packages including Ant Design, React Hook Form, Storybook, Vite, eslint/typescript-eslint, @noble/hashes, persian-calendar-suite, and swagger-ui-react to pick up the latest fixes and minor improvements.
2026-08-12 14:06:18 +02:00