Commit Graph
100 Commits
Author SHA1 Message Date
Sanaei b6473004ac fix(ci): harden the conflict resolver against the branch it checks out
resolve-conflicts is the one job that puts a pull request's own tree in
the working directory while holding CLAUDE_CODE_OAUTH_TOKEN,
CLAUDE_BOT_PAT and a write-scoped token, which is what CodeQL alert 101
(actions/untrusted-checkout) points at. Nothing in the job executes that
tree and the trigger is gated on the repository owner, so the alert is
not reachable as written, but two of its guards were weaker than they
read.

Git hooks were neutered only after gh pr checkout had already run, so
the guard sat one step behind the checkout it exists to cover; it now
precedes it. The conflicted paths are concatenated into the
--allowedTools value handed to the model, so a path carrying a comma or
a parenthesis would widen that allowlist. Only both-modified paths reach
that code today, which means they already exist in the base repository,
but the merge is now handed back to the maintainer unless every
conflicted path is plain [A-Za-z0-9._/-].

The file's header comment block is dropped.
2026-07-28 20:11:22 +02:00
Sanaei 4605f00a15 fix(nodes): keep the credential-presence flag on the node heartbeat push
The Nodes page cache is overwritten wholesale by the heartbeat websocket
push, but the job broadcast a raw []*model.Node while the REST list returns
[]*service.NodeView. model.Node tags the api token json:"-" and carries no
hasApiToken field, so every push stripped the flag the edit form reads to
decide whether a token is already stored.

One 5s tick after the page loaded, editing any non-mTLS node then failed
with "Name, address, port and API token are required" — and stayed failed,
because setQueryData refreshes dataUpdatedAt, so the query never goes stale
and never refetches the intact REST payload.

Broadcast the NodeView read contract instead.
2026-07-28 17:38:46 +02:00
Sanaei dc6a16019e fix(xray): reject configs xray-core refuses, and check the fixtures against it
The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
2026-07-28 14:43:55 +02:00
Sanaei fea6a20f7c fix(xray): stop the runtime user API from crashing xray-core
Exercising the whole XrayAPI surface against a real xray-core 26.7.28 (the
version go.mod pins) turned up a way for ordinary panel activity to kill the
core process, plus two smaller mismatches with what the core actually does.

buildUserAccount picked the shadowsocks account type by falling through to a
2022 account whenever the cipher was not one of six hardcoded names. xray's
legacy and 2022 inbounds cast the account they are handed without checking
(proxy/shadowsocks/validator.go, proxy/shadowsocks_2022/inbound_multi.go), so
the wrong type is not an error — it panics the core and drops every connection
on the server. The fallback was reachable without any misconfiguration:
autoRenewClients hands AddUser the client object straight out of the inbound's
settings, where the cipher lives under "method", never "cipher", so every
auto-renewed client on a legacy-cipher shadowsocks inbound took xray down. The
xray-valid aead_* aliases hit it too. The cipher is now read from either key,
matched with the same table (and case-insensitivity) the core's own conf
package uses, and an unrecognized one is an error instead of a guess.

The legacy shadowsocks validator is also the only one that accepts a second
user under an email it already holds, and RemoveUser then drops just one of
them — a disabled or expired client kept connecting. AddUser now drops the
email first on that account type so a single removal fully revokes the client.

GetTraffic skipped every stat the first time it saw it. xray creates a
counter on a user's first use, so that dropped a new client's traffic for a
whole polling interval, as did the counter reset after a core restart. Only
the first poll of a process is a baseline now; later, unseen and rewound
counters both count from zero.

Also fixes three unchecked settings["method"].(string) assertions that panic
the panel on a shadowsocks inbound whose settings carry no method, and bounds
TestRoute's port so an out-of-range value cannot wrap into the uint32 the
core is asked about.

Tests: api_users_e2e_test.go drives add/remove for every protocol against a
real core and asserts it survives each one (skipped unless XRAY_E2E_BINARY is
set); the account-type, traffic-delta and renew paths get unit coverage.
2026-07-28 13:52:10 +02:00
Sanaei 7f7b7e16a4 feat(xray): update xray-core to v26.7.28 and adapt panel
Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
2026-07-28 13:14:06 +02:00
Sanaei fd17255f1d Revert "fix(sub): keep the client identity on every subscription link (#6098)"
This reverts commit c004c18d90.

Showing {{EMAIL}}/{{USERNAME}} on the first subscription-body link only is
intentional, not an oversight in 876d55f2. Restoring the behaviour and the
tests that pin it.

Making the identity tokens configurable is the sanctioned route for the
operators asking for them on every link (#5935), rather than flipping the
default for everyone.
2026-07-27 19:43:01 +02:00
Sanaei 8bc00d1e90 style: drop the line comments added with the triage fixes
CLAUDE.md rules out // line comments in committed Go. The rationale they
carried is in the commit messages for each fix; doc comments that already
existed are kept, updated where the code they describe changed.

Also replaces reflect.Ptr with reflect.Pointer and rewrites the YAML keyword
alternation as a lookup table, both flagged by golangci-lint.
2026-07-27 14:37:57 +02:00
Sanaei 6f4cc1e53c fix(xray): emit an empty client array instead of null in the generated config (#6117)
finalClients was a nil slice, so an inbound that has a clients key but whose
clients are all filtered out — disabled by an admin, or cut by the traffic
job for quota or expiry — was handed to xray-core as "clients": null.

The panel already treats a stored null client list as invalid data and
coerces it to [] at startup, and null is what reporters see in bin/config.json
when they go looking for a connectivity problem, which sends the diagnosis
after a serialization bug that is not there. Build the slice empty so the
same state serializes as [].

The reported inbound also needs the clients table to be in sync, which is a
separate question still open on the issue.
2026-07-27 14:34:09 +02:00
Sanaei 0e69f64e56 fix(job): bound the traffic-notify POST so a stalled receiver can't wedge it (#6115)
informTrafficToExternalAPI posted through the package-level fasthttp.Do,
which carries no read or write deadline. Run() is scheduled @every 5s under
cron.SkipIfStillRunning, so a receiver that accepts the connection and then
neither answers nor closes did not just delay one notification — it held the
job, and every following tick was skipped for the duration.

What stops with it is more than counters: AddTraffic runs autoRenewClients
and disableInvalidClients in the same call, so quota and expiry enforcement
stall too, and an over-quota client keeps transiting for the whole hang. The
online-client refresh and the websocket broadcasts sit later in the same tick.

Give the endpoint its own client with read/write deadlines and a DoTimeout
budget under the poll cadence, close the connection rather than pooling it
for a call this infrequent, and skip the POST outright when there is nothing
to report. Retries stay off: the payload carries per-tick deltas, so a resend
after a failed response leg would double-count on the receiver.

Verified against a listener that accepts and stalls: fasthttp.Do was still
blocked after 8s, the new client returns at its 3s budget.
2026-07-27 14:30:48 +02:00
Sanaei 7fe9932d7b fix(sub): quote Clash scalars a YAML parser would read as numbers (#6104)
A REALITY short-id like 2351e1 is valid hex, but as a bare YAML scalar the
resolution rules read it as the float 23510. mihomo hex-decodes the resulting
five-digit string, fails with "invalid REALITY short ID", and the whole
provider loads zero nodes — one proxy takes the entire subscription down.

The encoder quotes the forms it recognises (plain integers, hex, booleans)
but not the exponent-float form, and its own parser reads that token back as
a string, so nothing in a round-trip through it reveals the problem. Check
the values against the resolution rules instead, and force quotes on any
plain scalar that would resolve to a non-string.

Applied to every string in the document rather than to short-id alone: the
panel's own short-id generator emits random hex, and passwords, obfs-
passwords and pre-shared keys reach the output the same way. Unambiguous
values are untouched, so the document is otherwise byte-identical.

The existing Clash tests assert on the config map, never on the serialized
text, which is why this survived; the new tests assert on the output.
2026-07-27 14:28:34 +02:00
Sanaei c004c18d90 fix(sub): keep the client identity on every subscription link (#6098)
876d55f2 put EMAIL/USERNAME in the same first-link-only bucket as the usage
tokens, so a client attached to several inbounds got its email on whichever
inbound sorted first and bare inbound names on all the rest. With the shipped
default template ({{INBOUND}}-{{EMAIL}}|...) that makes every profile after
the first indistinguishable between clients — the point of the token.

The two are not alike: the usage block repeats identical numbers on every
link, while the identity is what tells one imported profile from another.
Restore identity on all body links and leave usage first-link-only.

Reported again in #6029 and #5659, which asked for the same revert.
2026-07-27 14:24:28 +02:00
Sanaei f8e9f2f087 fix(node): stop a departed master's frozen traffic from disabling clients (#6113)
client_global_traffics rows are keyed by (master_guid, email) and are only
ever overwritten by a push from that same master. A master that stops
pushing — decommissioned, reinstalled under a fresh GUID, or detached from
the node — therefore leaves its last snapshot behind permanently.

depletedClientsCond's cross-panel EXISTS branch matched any such row, so a
node kept comparing a client's quota against counters frozen weeks earlier.
Once they exceeded the quota the node disabled the client on every traffic
poll, and the node -> master enable merge latched that off on the master too,
where nothing sets it back. The reported symptom is exactly this: a client at
11 GB of a 24 GB quota, enabled on two nodes, disabled on the third, which
still held a 27-day-old row from a previous master reporting 30 GB.

Bound both the enforcement predicate and the display overlay to rows a master
refreshed within globalTrafficFreshWindow. Masters push every 30s, so a live
master is never affected; a master that is merely unreachable for a while
keeps enforcing for a full day before its numbers are set aside.

The one-way enable merge that makes such a disable permanent on the master is
deliberate (12d84c2a, #4917) and is left alone.
2026-07-27 14:21:56 +02:00
Sanaei 5accd8a611 fix(ci): stop the conflict job trusting the branch it is merging
A second audit of the hardened workflow found the "no shell at all"
claim in resolve-conflicts was still false, by two routes that live
outside this file.

The job runs the model in the workspace right after `gh pr checkout`,
so for a fork pull request the working directory is attacker-controlled.
claude-code-action writes `enableAllProjectMcpServers = true` into
~/.claude/settings.json before starting Claude Code
(base-action/src/setup-claude-code-settings.ts), and the CLI honours a
project `.mcp.json` unless `strictMcpConfig` is set, which the action
never sets. A contributor branch carrying an `.mcp.json` therefore got
its command spawned at session start, with --allowedTools gating tool
calls but not server startup. The same tree also supplied CLAUDE.md and
.claude/ as project instructions. The job now passes
`--strict-mcp-config` and `--setting-sources user`, so nothing in the
merged tree configures the session.

The second route was `Edit` with no path scope, the only unscoped file
grant left. Editing `.git/config` to set `core.fsmonitor` or a
`credential.helper` gets a command run by the next step's git calls,
which hold CLAUDE_BOT_PAT, and the stray-file guard could never see it
because `git diff --name-only` lists tracked paths only. The merge step
now emits one `Edit(//<workspace>/<file>)` rule per conflicted path and
the model gets exactly those plus /tmp, with `.git/**` denied outright
and Bash, WebFetch, WebSearch and Task denied by name. Hooks are
disabled for the run (`core.hooksPath=/dev/null`, `commit --no-verify`).

Conflict handling gets three real gaps closed: modify/delete, rename and
both-added conflicts (git status DD/AU/UD/DU/AA/UA) leave no markers, so
they used to sail through the marker check and get committed unresolved
- they are now detected up front and handed back untouched; the marker
scan covers `=======` and `|||||||`, not just the outer pair; and after
staging, `git diff --diff-filter=U` must come back empty or nothing is
committed. A `=======` markdown underline of exactly seven characters in
a conflicted file will now hand the merge back rather than commit it,
which is the safe direction.

Smaller things the audit was right about:
- the mutating gh rules are prefix rules, so `Bash(gh issue close:*)`
  reached every issue in the repository. They now carry the triggering
  number: `Bash(gh issue close ${{ github.event.issue.number }}:*)`.
- `Write(//tmp/**)` is granted alongside `Edit(//tmp/**)`: the docs say a
  Write(path) rule is never matched by the file checks, so the Edit rule
  is what authorises it, but the tool has to be listed to exist at all.
  Without this the model could not create /tmp/comment.md.
- the mention prompt lost its thread context when it moved to agent mode
  and referred to "<number>" literally; it now gets repo, number, title
  and whether the thread is a pull request.
- `git log`/`git show` are gone from mention: `--output=<file>` makes
  them a file-write primitive.
- `@claude resolve pr conflicts` on a plain issue matched no job at all.
- the commit step gated on `skip != 'true'`, so it also ran when the
  merge step died before writing any output; it now needs `skip ==
  'false'`.
- bot-authored pull requests (dependabot opens three ecosystems' worth)
  no longer start a review run that the action refuses to serve.
- resolve-conflicts drops to `contents: read`, since the push is the
  PAT's job, and fails with a comment when that PAT is missing.
2026-07-25 22:27:09 +02:00
Sanaei f46b1726cf fix(ci): close the write paths an audit found still open in the bot
Making the jobs read-only in the previous commit was not enough: two of
the mechanisms that grant write access were invisible in the workflow
file itself.

Every job now passes a `prompt:` input. Without one, claude-code-action
picks tag mode for a mention, and src/modes/tag/index.ts then appends
`--permission-mode acceptEdits`, its own allowedTools including
`Bash(git commit:*)` and a push wrapper, and calls setupBranch. So the
mention job could edit files and commit them no matter what its own
allowedTools said, and its system prompt claiming otherwise was simply
wrong. A `prompt:` selects agent mode, which adds nothing. It also
removes tag mode's hidden requirement that the comment contain the
trigger phrase, which would have made resolve-conflicts a no-op for a
comment that said only "resolve pr conflicts".

resolve-conflicts no longer hands git to the model. `Bash(git:*)` is a
prefix rule, so it permitted `git push origin HEAD:main`, `--force`,
`git remote set-url`, and shell execution through `git config alias.x
'!sh -c ...'` - the action ships scripts/git-push.sh precisely because
`git push:*` allows `--receive-pack='sh -c ...'`. The job now splits in
three: a step checks out the PR branch, merges the base and collects the
conflicted paths; the model gets Read/Glob/Grep/Edit and no shell at all;
a final step verifies and pushes. That step refuses to commit if a
conflict marker survives, if the model wrote /tmp/ABORT, or if anything
outside the conflicted set was touched, and it stages those paths
individually instead of `git add -A`. The PAT is now written to the push
URL only in that last step, after the model's session has ended, instead
of sitting in .git/config while untrusted branch content is read.

The bare `Write` grant in the three answering jobs becomes
`Edit(//tmp/**)`, since only prose kept it out of the checkout and out of
$GITHUB_ACTION_PATH, whose scripts run after the model step. Each prompt
now says to fall back to an inline --body if the write is refused, so a
denied write cannot silently cost a reply. mention gains the transcript
upload and the no-reply guard the other jobs already have, keyed to the
triggering comment's timestamp.

Restores the header note about the 21000-character expression cap, with
the current block sizes.
2026-07-25 22:03:10 +02:00
Sanaei acbb879f80 refactor(ci): make the bot read-only except for PR conflict resolution
The bot is meant to investigate and explain, not to write code. It could
do considerably more than that: handle-pr-fix applied fixes and pushed
them to any trusted author's PR, an @claude mention on a pull request
could edit files, and an @claude mention on an issue opened a pull
request against main. All of it is gone.

Now every job that answers automatically runs with a contents: read
token, so pushing is impossible rather than merely forbidden:

- handle-pr-fix is deleted. handle-pr-review takes every pull request
  instead of only the ones from outside contributors, and it comments.
- mention drops contents: write, the push-URL routing step, and the
  Edit tool. Its Bash allowlist is now an explicit read-only set - the
  gh subcommands it needs plus git log/show/diff/blame - so gh api,
  gh pr merge and gh pr create are no longer reachable. Asked for a
  fix, it now writes the change out in full instead of applying it.

One narrow exception replaces all of that: resolve-conflicts. It runs
only when the repository owner comments "resolve pr conflicts" on a
pull request, and it may merge the base branch into that PR's head
branch and resolve the conflicts, nothing else. It keeps both sides of
every conflict, takes the base version of generated artifacts it cannot
regenerate here, and aborts the merge rather than guess when a hunk
needs a human. It never force-pushes, merges, or closes.

Also removes the pull-request-opening step whose guard never worked:
gh api prints the 404 body on stdout, so `ahead=$(gh api ... || echo 0)`
became `{"message":"Not Found",...}0`, never equal to "0", and every
reply-only mention run ended red on `gh pr create`. Uploads the
handle-pr-review transcript the way handle-issue already does, so a run
that dies inside the sandbox leaves evidence.
2026-07-25 21:39:28 +02:00
Sanaei 1358f65bec fix(ci): unbreak the issue-triage bot, which answered nothing
Since 2026-07-20 every `issues` run reported success while posting no
comment at all - #6094 through #6103 carry zero replies. The cause is
the sandbox, not the prompt or the model.

`handle-issue` and `handle-pr-review` pass allowed_non_write_users,
which is what lets the bot run for reporters who have no write access.
claude-code-action reacts to that input by turning subprocess isolation
on and installing bubblewrap, and that sandbox cannot start on the
runner: every Bash call dies during setup, before the command itself
runs, with

    bwrap: Can't create file at /home/.mcp.json: Permission denied

`gh` is reachable only through Bash, so the triage investigated the
issue, wrote its reply to /tmp/comment.md, and could never post it.
The action itself did not crash, so the job stayed green.

Opt both jobs out with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0. The scrub is
a best-effort wipe of secrets from subprocess environments, not an
access control; what actually bounds these jobs is unchanged - a
contents: read token that cannot push, and a Bash allowlist holding
only specific `gh issue`, `gh label`, `gh search` and `gh release`
subcommands. Code changes stay confined to handle-pr-fix and mention,
which only trusted actors and the owner can trigger.

Add a step to each job that fails the run when no bot comment landed on
the issue or pull request, so the next silent breakage shows up red
instead of green, and lower retention-days to the repository maximum of
7 so the artifact upload stops warning.
2026-07-25 20:14:53 +02:00
Sanaei f4e79e70ea chore: refresh dependencies, fix Linux tool tasks, modernize Go idioms
Frontend deps: @hookform/resolvers 5.4.0 -> 5.4.3 and react-hook-form
7.82.0 -> 7.83.0. The @typeschema/valibot override is what makes this
installable at all. Resolvers 5.4.3 re-declares 25 optional peers for its
validator matrix, and npm resolves them into the ideal tree even though none
are used here; two of them contradict, since resolvers wants valibot ^1 while
@typeschema/main -> @typeschema/valibot pins valibot ^0.39. Both target the
same node_modules/valibot, so a plain npm update dies with ERESOLVE. The
override settles that one edge and nothing extra lands in node_modules.

Backend deps: telego 1.10.0 -> 1.11.1 (Telegram Bot API v10.2, additive
only), klauspost/compress 1.19.1, plus the indirect bumps that came with them.

VS Code tasks: the golangci-lint and modernize tasks assumed Windows PATH
semantics, where PATH is a persistent user variable that every process
inherits, so ~/go/bin was always visible. On Linux that directory is exported
from ~/.bashrc, which the non-interactive `bash -c` behind a task never
sources, and both tasks failed with exit 127. Adds linux/osx option blocks
that prepend the Go bin directories and leaves the Windows path untouched,
plus tasks to install the two tools; those are split because go install
rejects packages from different modules in one invocation.

Go sources: modernize -fix output, covering range-over-int, slices.Backward,
maps.Copy, strings.CutPrefix and strings.SplitSeq. Behaviour is unchanged.
2026-07-25 16:08:09 +02:00
Sanaei edb487a005 chore(deps): migrate to react-router 8 and refresh frontend dependencies
react-router-dom 7 is superseded by react-router 8, which folds the DOM
bindings back into the core package. RouterProvider now comes from
`react-router/dom`, while the hooks and `createBrowserRouter` move to
`react-router`. Updates the nine importing modules and the router line in
docs/architecture.md to match.

Also refreshes antd, react-i18next, storybook, eslint, lint-staged and
playwright to current patch/minor releases, and restores alphabetical order
in devDependencies for the @vitest/browser-playwright and playwright entries.

Bumps brace-expansion to 5.0.8, the only release outside the affected range
of GHSA-mh99-v99m-4gvg (unbounded expansion length causing an OOM crash).
`npm audit fix` could not apply this on its own: the lockfile pinned 5.0.7
and npm will not re-resolve a transitive-only dependency in place, so the
entry was updated directly and reinstalled.
2026-07-25 02:07:15 +02:00
Sanaei 35cf6be6f9 fix(ci): keep the triage prompt under the 21000-char expression cap
The previous commit pushed handle-issue's prompt to 21587 characters and
GitHub stopped parsing the file: "(Line: 39, Col: 19): Exceeded max
expression length 21000". Because the prompt interpolates ${{ }}, GitHub
treats the whole block scalar as a single expression, and the cap applies
per expression. The failure mode is quiet and total - no job fails,
the workflow itself disappears, its registered name reverts from "Claude
Bot" to the file path, and the only signal is a run attributed to the
push with no jobs in it.

Drop the hand-written stack description, repository map and runtime-fact
list from that prompt and point at CLAUDE.md and docs/architecture.md
instead. Both are maintained, both are already in the checkout, and the
copy in the prompt had drifted from them anyway - it still described the
mtg worker, omitted internal/tunnelmonitor/ and memory.high, and filed
internal/web/runtime/ under "wiring". Only the support-facing facts that
live in neither file are kept: the install one-liner, the random initial
credentials, the distro-dependent env file, the Docker image and the
capabilities fail2ban needs.

handle-issue is now 15069 characters, and a header comment records the
limit so the next edit does not rediscover it in production.
2026-07-25 01:31:40 +02:00
Sanaei 0f7329c3ce fix(ci): repair the Claude bot and narrow what it can reach
Three problems, all in .github/workflows/claude-bot.yml.

It was silently dead. No comment had been posted since 2026-07-20 while
every run reported success: roughly twenty issues and pull requests each
burned 18-56 turns and up to $2.59, ended with permission denials, and
published nothing. Comment bodies are markdown, markdown is full of
backticks, and inside a quoted `--body "..."` backticks are command
substitution, so the write was rejected and a failed triage looked
exactly like a clean one. The body now goes to /tmp through Write and out
through --body-file, in every branch of both jobs, and each job re-reads
the thread afterwards so a rejected write fails loudly instead of
reporting success. The run transcript is kept as an artifact.

It could reach much further than it claimed. Both jobs that any GitHub
user can trigger declared themselves READ-ONLY in prose while holding
Bash(gh:*), which is not a GitHub-scoped allowlist: `gh alias set --shell`
runs its argument through sh -c and `gh extension install` fetches and
executes code, both as single commands whose first token is gh. That is a
general shell on a runner holding CLAUDE_CODE_OAUTH_TOKEN, which does not
expire with the job. `gh api` accepted any method, issues: write is
repo-scoped rather than issue-scoped, and `gh pr review --approve`,
`gh pr close` and `gh pr checkout` were forbidden in prose only. Those two
jobs now list the subcommands they actually run. The untrusted title and
body are fenced in tags carrying github.run_id, unguessable at the time
the issue is written, and the invariants an allowlist cannot express -
one issue number, labels and title only, /tmp as the sole writable path,
never $GITHUB_ENV - are stated explicitly. Both checkouts get
persist-credentials: false. handle-pr-fix and mention keep their
wildcards: only owners, members and collaborators can trigger them, and
narrowing the maintainer's own path risks more than it protects.

Its review hid findings and its triage quoted stale facts. "Prefer a few
high-signal findings over many low-value ones" is read literally by
Opus - it finds the bug, judges it below the stated bar and says
nothing - while the Severity and Confidence tiers already existed to do
that filtering. The review also never said that the working directory is
the base revision, so it could assert that a case was unhandled in code
the pull request had already rewritten, and label it confirmed, on an
outside contributor's first patch. Four CLAUDE.md conventions were
missing, each a guaranteed miss: openapigen's StructAllow allowlist, the
layering rules including the runtime.Runtime dispatch requirement that
silently breaks multi-node when bypassed, the assertion standard, and
golden share-link fixtures regenerated to turn a red test green. On the
triage side the invalid and duplicate branches were gated three times
over and so never fired, leaving spam to collect a full investigation and
a courteous reply; /etc/default/x-ui was given as the env file when it is
distro-dependent, making the PostgreSQL migration advice a silent no-op
on RHEL and Arch; an env list labelled "full" omitted XUI_PORT and the
XUI_TUNNEL_HEALTH_* family; XTLS was offered as a security option the
panel does not have. docs/architecture.md was invisible to both prompts
despite being maintained and already in the checkout. From the bot's own
output: it published a trigger only the maintainer can use, retitled
issues without saying so, asked for screenshots it cannot open, and once
invented a reason for a number it had miscounted.

All four jobs move to Opus 5, at xhigh effort rather than max - the
recommended tier for agentic work, and one below the overthinking that
max invites on routine triage.
2026-07-25 01:22:08 +02:00
Sanaei 29557e2153 fix(sub): gate the VLESS flow in JSON subscriptions like raw and Clash links
genVless emitted client.Flow unconditionally, while the raw link
(service.go:806) and the Clash proxy (clash_service.go:251) both gate it
behind vlessFlowAllowed. A flow_override left on client_inbounds after its
inbound moved to a transport Vision cannot use -- ws, grpc, httpupgrade --
therefore survived only into the JSON subscription, handing that client an
outbound xray-core rejects while its other two formats were correct.

Apply the same gate at the call site, reading the network from the
per-host stream so a host that rewrites the transport is judged on what it
actually emits. Verified by seeding a flow_override on a ws+tls inbound:
before, raw and Clash dropped the flow and JSON kept it.
2026-07-25 00:51:48 +02:00
Sanaei 0b60154383 fix(docs): force transitive sharp up to patched 0.35.3
sharp <0.35.0 inherits four libvips CVEs (GHSA-f88m-g3jw-g9cj). It comes
in as an optional dependency of next, which still declares ^0.34.5 on its
current release, so only an override reaches the fixed line. Brings
libvips 8.18.3 via @img/sharp-libvips-* 1.3.2.
2026-07-25 00:51:48 +02:00
Sanaei c3967e57dc perf(clients): take one email snapshot per client fan-out, not one per inbound (#6091)
Create and Attach called the exported AddInboundClient once per target
inbound, and that wrapper passes a nil email→subId map, so every iteration
re-ran getAllEmailSubIDs -- a JSON_EACH expansion over the settings blob of
every inbound in the panel. Adding one client to 24 inbounds on a panel with
~300 users meant 24 full expansions of ~7k rows to answer the same question.

Hoist the snapshot above the loop and call the unexported addInboundClient
with it, exactly as BulkAttach (client_bulk.go:63) and BulkCreate
(client_bulk.go:1151) already do. The snapshot goes stale from the second
inbound onward, but the identity being added is the same on every iteration,
so its own entry can only ever match itself -- checkEmailsExistForClients
accepts an email whose stored subId equals the incoming one, and an absent
entry is accepted too.

This is the database half of #6091. The dominant cost there is the other
half -- one synchronous 10s-capped node round-trip per remote inbound,
which multiplies again on chained nodes -- and that needs the push batched
per node rather than per inbound; left for a separate change.
2026-07-25 00:51:48 +02:00
Sanaei aa60d54ea5 fix(wireguard): widen the client address pool past a full /24 (#6089)
allocateWireguardAddress scanned exactly one /24, so a WireGuard inbound
was hard-capped at 254 clients with no way out -- the pool is not
configurable anywhere in the UI or API.

Fill the inbound's own /24 first, then widen to the enclosing /16 instead
of failing. A wireguard inbound carries no interface subnet and xray
routes purely by each peer's allowedIPs, so nothing constrains the wider
address. Capped at /16 to keep the worst-case scan bounded; IPv4 only.
2026-07-24 22:21:18 +02:00
Sanaei a652cb8cea fix(clients): keep a client editable when its subId is already shared (#6065)
The subId collision check in Update ran on every save, unlike the email
check above it. Because Update defaults an omitted subId to the stored
one, any client already sharing a subId was rejected on every later edit
-- even a pure totalGB or expiry change that never mentions subId.

Gate the check on an actual change. Pre-existing duplicates are reachable
because SyncInbound has no such check, and 88a36773 meant to leave them
untouched. Editing a client onto another subscriber's subId is still
rejected, so the typo guard is intact.
2026-07-24 22:18:39 +02:00
Sanaei cd674c8d4f feat(sub): expose live online status and add ?format=info endpoint
Custom subscription templates only received the lastOnline timestamp, so
template authors had to fake an online indicator by comparing it against
the current time, and the page was a one-shot server render with no way
to refresh usage without reloading the whole HTML.

The template context (and window.__SUB_PAGE_DATA__) now carries isOnline,
computed from the panel's own online-client tracking (local xray plus
remote nodes) at render time. The subscription URL also answers
?format=info with the page view-model as JSON — minus the links, with
emails deduplicated — so templates can poll live status cheaply. The
shared view-model construction moved into buildSubPageData/subPageContext
so the HTML page, the SPA payload and the info JSON cannot drift apart.

Also documents the previously injected but undocumented announce
template variable.
2026-07-23 23:57:29 +02:00
Sanaei b319dd0c3a fix(panel): align telegram icon with its label in home card actions
The .tg-icon override (display: inline-block; vertical-align: -2px)
defeated the default .anticon flex centering that every other card
action icon relies on, so the icon rendered ~2px below the @XrayUI
text. Dropping the override lets AntD center it like its neighbors.
2026-07-23 20:20:32 +02:00
Sanaei 8ef2eec3d1 fix(hosts): assign group ids to imported hosts and repair empty ones
Host rows created from a legacy streamSettings.externalProxy during
inbound import got an empty group_id, and the one-time HostGroupIds
seeder had already been gated off, so the UI rendered them under a
synthetic fallback_<id> group the update/delete API could not resolve,
failing every edit with "host group not found".

Assign a real group id in externalProxyEntryToHost at creation, and
replace the seeder with backfillEmptyHostGroupIds, an idempotent
startup repair that runs on every boot so rows from older builds and
restored backups are healed too. Also rename the leaked internal
error "host group not found" to "host not found" since groups are not
a user-facing concept.
2026-07-23 18:52:42 +02:00
Sanaei 941c6116a9 chore(openapi): regenerate schemas with int64 formats on node fields
Output of make gen: the generator now stamps format int64 on the node
status schema's 64-bit integer fields (timestamps, net counters,
uptime), syncing the committed OpenAPI doc and generated schemas with
the Go structs.
2026-07-23 16:28:49 +02:00
Sanaei 892c06c8bc Bug-label issue sweep: 16 fixes (#6083)
* fix(xray): block private-range egress in default freedom finalRules (#6037)

With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.

Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.

* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)

Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.

Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.

* fix(xray): resolve geodata assets before building outbound configs (#5928)

Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.

Call ensureXrayAssetLocation before both build paths.

* fix(api): use a real i18n key in the client get handler (#5911)

The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.

* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)

The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.

Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.

* fix(metrics): accept Unicode outbound tags in the observatory (#5972)

The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.

Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.

* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)

With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.

Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.

* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)

validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.

Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.

* fix(xray): stop deleting hand-written direct routing rules on save (#6056)

The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.

Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.

* fix(clients): resolve email lookups through client_inbounds after a move (#6059)

GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.

When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.

* fix(nodes): replicate inbound fallbacks to nodes (#5963)

Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.

Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.

* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)

A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.

Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.

* fix(xray): force a full restart when REALITY stream settings change (#6010)

A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.

Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.

* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)

An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.

Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.

* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)

The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.

Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).

* fix(frontend): default sockopt fields when editing a stored inbound (#5956)

Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.

Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
2026-07-23 15:34:42 +02:00
Sanaei 16b9b3ce1c chore(deps): bump docs and frontend dependencies
Routine minor/patch updates: Next.js 16.2.11 + eslint-config-next,
fumadocs, React 19.2.8, Storybook 10.5.3, and assorted tooling.

Docs stays on ESLint 9 (^9.39.5): eslint-config-next pulls in
eslint-plugin-react 7.37.5, whose newest release still calls the
context.getFilename API that ESLint 10 removed, so eslint crashes on
every file under ESLint 10. The frontend workspace already ran ESLint
10 without eslint-plugin-react and is unaffected.
2026-07-22 19:57:34 +02:00
MHSanaei 16b2bcf9aa fix(database): repair legacy string tgId in inbound settings on upgrade
Old panel builds and external tools writing directly to the DB store
tgId as a JSON string; parsing such settings into the strict int64
Client model fails with "json: cannot unmarshal string into Go struct
field Client.tgId of type int64" and blocks every client operation on
that inbound. The one-shot InboundClientTgIdFix seeder already ran on
existing installs, so the repair is re-registered as
InboundClientTgIdFix2 to run once more on upgrade, and it now preserves
numeric string ids instead of zeroing them. The Client model itself
stays number-only.
2026-07-18 13:06:05 +02:00
MHSanaei 2f156c8eb0 fix(ci): publish dev-latest edit-first instead of probing for existence
The dev-latest publish step probed for the release with gh release
view before choosing edit or create. During the api.github.com 503
storm the probe itself failed, mis-routing an existing release into
the create path, which then died on a permanent 422 already-exists
error that no amount of retrying can fix.

The release exists on every run but the very first, so edit first and
fall back to create only when the edit fails. The retry log line now
names the gh subcommand instead of just the binary.
2026-07-17 01:34:47 +02:00
MHSanaei 455d1cd0f7 fix(ci): resolve the mtg-multi tag from the release-page redirect
The api.github.com outage that failed the previous release run outlasted
the retry window, while asset downloads from github.com kept working the
whole time. The latest-release lookup was the only api.github.com
dependency left in the build jobs, so resolve the tag from the release
page redirect on github.com instead: tag resolution now shares exactly
the failure domain of the downloads it feeds, and an API-only outage can
no longer fail a build that could otherwise finish. No token needed for
the redirect, on either platform.
2026-07-17 01:24:36 +02:00
MHSanaei ab1a922806 fix(ci): survive transient GitHub 5xx outages in the release workflow
A GitHub API 503 storm failed the release run four attempts in a row:
the mtg-multi latest-release lookup died on every platform (even s390x,
which never packages the sidecar), and the matrix default fail-fast then
cancelled the six healthy builds alongside the one that hit the outage.

Retry every external fetch with backoff (curl --retry-all-errors, wget
--retry-on-http-error, Invoke-* -MaximumRetryCount), scope the mtg-multi
tag lookup to the platforms that actually package the sidecar, disable
fail-fast on the build matrix, and retry the idempotent dev-latest
publish commands. The workflow file itself now triggers the run's path
filters, so editing it exercises the release build immediately instead
of failing silently at the next code push.
2026-07-17 01:06:14 +02:00
Sanaeiandclaude[bot] 5e1cb7693b Repo-wide self-correcting audit: 54 verified bug fixes (#5970)
* fix(email): resolve a name-addr smtpFrom into bare envelope address and display name

The save-time validator accepts any RFC 5322 address form, so a value
like '3x-ui Panel <panel(at)example.com>' passes validation, but Send and
TestConnection fed that raw string to MAIL FROM, which strict servers
reject with 501, and buildMessage mangled it into a quoted local part.
Parse the configured sender at the point of use: the envelope gets the
bare address and, when no explicit sender name is set, the display name
embedded in the setting is used for the From header.

* fix(email): report a missing sender address from the SMTP connection test

TestConnection skipped the empty-from guard that Send enforces, so with
no sender and no username configured the test issued the null reverse-path
and could report success against a lenient relay while every real
notification send kept failing with the missing-sender error. Guard the
test path the same way and surface a dedicated translated message.

* fix(sub): fall back to the raw subscription when an auto-detected format has no content

With format auto-detection enabled, a client whose User-Agent matched the
Clash or JSON regex was routed straight to that format handler. For a
subscription whose entries convert to neither format (an MTProto-only
subscription, for example) the handler returns an empty document and the
request ended as 404, breaking a URL that served the raw list before the
toggle. The auto-detect branches now serve the detected format only when
it produces content and otherwise continue to the raw response; the
explicit format endpoints keep answering 404 for empty documents.

* fix(node): match prefixed central tags when filtering a selected-mode node snapshot

FilterNodeSnapshot compared a node snapshot's inbound tags against the
raw selected-tag list with an exact match, while its two siblings
(SnapshotHasUnadoptedInbounds and the reconcile tagToCentral map) expand
each selected tag to both its bare node-side form and its n<id>- prefixed
central form. A panel-created node inbound is recorded in the selected
list under the central prefixed tag but reported by the node under the
bare tag, so the exact match dropped it from every snapshot and the
orphan sweep then deleted its central row one tick after creation. Expand
the allowed set with the same prefix flip the siblings use.

* fix(client): refuse a bulk quota reduction that would fall to or below zero

BulkAdjust clamped a client's new traffic limit with max(total+addBytes, 0).
Because 0 is the unlimited sentinel, reducing a client's quota by more than
it had left silently granted that client unlimited traffic. The sibling
expiry branch already refuses an over-reduction; mirror it for quota so the
adjustment is skipped with a clear reason instead of crossing the sentinel.

* fix(client): persist a bulk adjustment's applied field even when the sibling field is skipped

In a mixed BulkAdjust (both a days delta and a bytes delta), a per-field
planning skip such as "unlimited expiry" or "unlimited traffic" was recorded
in the same map that gated the client_traffics write. The applied field was
already written to the inbound JSON and the clients table, but the enforcement
row was left untouched, so the depletion job cut the client on the old limit
while the panel showed the new one. Gate the traffic-row write on an actual
inbound-processing failure rather than on any planning-phase skip note.

* fix(inbound): always create in AddInbound instead of overwriting a row whose id was posted

The add controller binds the inbound model's id form field and never clears
it, and AddInbound persisted with GORM Save, which updates in place when the
primary key is non-zero. A client that reused an existing id (for instance by
duplicating an inbound fetched from /get and changing the port) silently
overwrote that stored row instead of creating a new inbound. Zero the id at
the top of AddInbound, matching how it already zeroes the client-stat ids.

* fix(inbound): accept WireGuard clients when creating an inbound

AddInbound's per-client validation switch had cases for every protocol
except WireGuard, so a WireGuard client fell through to the default branch
that requires a non-empty id. WireGuard clients are keyed by their public
key and carry no id, so importing a WireGuard inbound or re-adding one to a
reconciling node was rejected with "empty client ID". Add a wireguard case
that validates the client key, mirroring addInboundClient.

* fix(client): stop holding the inbound-lock registry mutex while waiting on one inbound

lockInbound acquired the global registry mutex and then blocked on the
per-inbound mutex without releasing the registry first. A slow client
operation holding one inbound's mutex (for example a bulk delete pushing to
an unreachable node) made the next waiter park on that inbound while still
holding the registry mutex, which in turn blocked lockInbound for every
other inbound — freezing client mutations panel-wide. Release the registry
mutex before taking the per-inbound lock.

* fix(client): honor keepTraffic when deleting a client that is attached to inbounds

Delete, DeleteByEmail and BulkDelete all pass keepTraffic to their final
cleanup transaction, but each called the per-inbound delete helper with a
hardcoded false. That helper purges the client's traffic, IP and stat rows
before the gated cleanup runs, so keepTraffic=true still destroyed all
traffic history for any client actually attached to an inbound (the pinned
test only covered a record with no inbound mappings). Thread the caller's
keepTraffic through to the per-inbound helper at all three call sites.

* fix(inbound): defer a local MTProto inbound edit's sidecar push until after commit

UpdateInbound applied a local MTProto inbound change by calling the runtime
UpdateInbound (which stops/starts the mtg sidecar or talks to it) from inside
runSerializedTx. That runs process and network I/O on the single traffic-writer
goroutine while a DB transaction is open, so a slow sidecar stalls traffic
accounting and every concurrent client mutation, and a later step failing the
transaction leaves the sidecar ahead of the rolled-back row. Move the push into
the post-commit hook, matching the xray branch. Adds a SetLocalRuntimeOverride
test seam mirroring the existing node override so the deferral is regression
tested.

* fix(client): delete external-link rows when bulk-deleting clients

The single-client Delete path removes a client's client_external_links rows,
but BulkDelete (and the DelDepleted reaper that routes through it) deleted the
record, mappings and traffic while leaving the external-link rows keyed by the
now-dead client id, so they accumulated as orphans. Delete them in the same
cleanup transaction, keyed by client id like the single path.

* fix(inbound): request an xray restart when toggling a routed MTProto inbound

AddInbound, DelInbound and UpdateInbound all flag needRestart when an inbound
routes MTProto through xray, so the egress SOCKS bridge is regenerated. Only
SetInboundEnable's local path omitted it, so toggling a routed MTProto inbound
off then on left the bridge out of the running config while the sidecar dialed
its loopback port, blackholing that inbound until an unrelated restart. Flag the
restart on the local enable path too.

* fix(client): apply enable-by-email to every inbound a client is attached to

ToggleClientEnableByEmail (Telegram bot) and SetClientEnableByEmail (LDAP sync)
resolved a single inbound via the legacy client_traffics pointer and flipped
enable only there. A client attached to several inbounds kept connecting through
the siblings' running Xray after being disabled, and the next edit could
re-enable it everywhere from a stale sibling. Route both through the
applyClientFieldByEmail fan-out (the #5039 fix path) so the whole multi-inbound
identity is toggled at once, dropping the circular Set/Toggle dependency.

* fix(traffic): commit a traffic tick even when a best-effort maintenance helper fails

addTrafficLocked stages the inbound and client deltas, then runs three helpers
(auto-renew, disable depleted clients, disable depleted inbounds) that are meant
to log and continue. All three reused the function-scope err that the deferred
commit/rollback inspects, so the last helper's error decided the whole tick: a
failure in disableInvalidInbounds rolled back the already-staged traffic while
AddTraffic reported success, and because xray had already advanced its counter
baseline that traffic was lost for good. Give each best-effort helper its own
error variable so only a genuine staging failure rolls the tick back.

* fix(traffic): re-enable clients and serialize the write in Reset All Client Traffic

ClientService.ResetAllTraffics zeroed up/down but, unlike every sibling reset
path, never restored enable=true, so clients that had been auto-disabled for
exceeding their quota stayed cut with zero usage after a reset. It also wrote
client_traffics directly on the shared DB handle instead of through the serial
traffic writer, reintroducing the cross-transaction lock-order deadlock the
writer exists to prevent. Restore enable and run the reset inside
submitTrafficWrite within one transaction.

* fix(traffic): keep node reset propagation out of the serial traffic writer

ResetAllTraffics and ResetInboundTraffic performed their remote-node reset HTTP
calls inside submitTrafficWrite. Each call can block up to the remote timeout,
and Reset All Traffics loops every node serially, so the single traffic-writer
goroutine was held for seconds — long enough that the concurrent 5s traffic poll
timed out submitting its own write and dropped the deltas it had already drained
from xray. Do the DB reset inside the writer, then propagate to the nodes after
it returns, matching how the mtproto quota reset is already sequenced.

* fix(sub): stop the subscription from 500ing on valid-but-unusual stream settings

The raw share-link generators used unchecked type assertions and unguarded
array indexing: an empty Reality shortIds/serverNames array (random.Num(0)
panics), a tcp-http header with no request block or an empty request.path, a
grpc block missing its keys, empty stream settings, and a non-string Host
header all panicked mid-generation. Because getSubs loops every client's link
with no recover, one such client 500s the entire subscription for everyone. The
sibling JSON, Clash and frontend generators already guard these; make the raw
generators match with comma-ok assertions and length checks.

* fix(sub): tolerate a hysteria inbound without hysteriaSettings in the JSON subscription

genHy asserted stream["hysteriaSettings"].(map[string]any) without the comma-ok
form, so a hysteria inbound whose StreamSettings omit the hysteriaSettings key
(a valid, representable shape the raw generator renders fine) panicked and 500ed
the entire JSON subscription. Use comma-ok; the downstream reads already guard
each key, so a nil map degrades gracefully.

* fix(sub): emit the pinned peer cert sha256 in Clash subscriptions

The Clash stream builder computed tlsSettings["pin-sha256"] from the inbound's
pinnedPeerCertSha256, but applySecurity's tls case never copied it onto the
proxy, so it was written with no reader and silently dropped. Clash subscribers
lost certificate pinning while JSON subscribers kept it. Surface pin-sha256 on
the proxy in the tls case, matching the JSON emitter.

* fix(link): parse the snake_case and extra-blob xhttp fields when importing a share link

The panel's share-link emitters (Go and TS) carry advanced xhttp knobs as a
snake_case x_padding_bytes plus an extra=<json> payload, but the Go parser's
xhttp branch read only top-level camelCase params, so importing an xhttp link
via the outbound-subscription feature dropped xPaddingBytes, scMaxEachPostBytes
and the rest, silently reverting them to the stream defaults and producing a
non-working outbound. Mirror the TS parser: read the snake_case alias, merge the
extra JSON blob, then let explicit camelCase params win.

* fix(frontend): decode URL-safe base64 when parsing an imported share link

Base64.decode called window.atob directly, which rejects the base64url
alphabet (- and _) and unpadded input. But the panel's own share-link emitter
uses Base64.encode(x, true) (URL-safe, unpadded), and real SIP002 links do too,
so importing a Shadowsocks link whose method:password encodes with a - or _ threw,
fell back to the raw undecoded string, and produced a wrong method and garbage
password (the vmess parser shared the same limitation). Normalize base64url and
re-pad before atob so decode round-trips every emitted link.

* fix(link): honor the vmess ws path and hysteria2 vcn params on import

Two Go/TS parser parity gaps in the outbound share-link import path: parseVmess
only applied a ws link's path when the inner JSON also carried a host key, so a
generator that omits host dropped the path back to the default; and parseHysteria2
hardcoded verifyPeerCertByName to empty, ignoring the vcn param the panel emits,
so a hysteria2 outbound with a decoy SNI and a distinct cert name failed TLS
verification after import. The TS parser handles both; make the Go parser match.

* fix(ui): stop the sniffing form island from clobbering unrendered fields

antd's Form.useWatch only reports registered fields, so while the
sniffing toggle was off the island emitted { enabled: false } upward and
replaced the full Sniffing object in form state. Saving a VLESS reverse
outbound then crashed in sniffingToWire on the missing ipsExcluded
array; the loopback outbound and the inbound sniffing tab shared the
same hole. Watch the store with preserve: true so unrendered fields
keep their values, and seed a missing value from the schema defaults
instead of an empty cast.

* fix(sub): drop empty remark segments instead of leaving a stray separator

expandSegment dropped a "|" segment only when its tokens rendered the unlimited
mark, so a segment whose only token resolved to the empty string (a client with
no comment, an unlimited client's expiry date) was kept as bare decoration,
leaving a trailing "|" or a dangling emoji on every share link's remark. Drop a
token-bearing segment whenever none of its tokens produce a real value, while
still keeping pure-literal segments.

* fix(xray): keep source- and domains-scoped routing rules when an inbound is deleted

removeInboundTagFromRules drops a routing rule whose inboundTag list becomes
empty only if the rule has no other matcher, but routingMatcherKeys omitted
xray-core's canonical source and domains keys. A rule scoped by source or domains
(common in hand-authored or imported configs) therefore lost its whole body —
including a security-relevant block — when its single listed inbound was deleted,
instead of just having the tag trimmed. Recognize source and domains as live
matchers.

* fix(xray): guard RemoveUser against an uninitialized handler client

Every XrayAPI handler method returns an error when HandlerServiceClient is nil,
except RemoveUser, which dereferenced it directly. A depletion sweep runs Init
with the port ignored and, during a restart window where the fresh process's
api port is still 0, Init fails and leaves the client nil — so RemoveUser
panicked (recovered by the traffic writer, but re-thrown every poll) instead of
returning an error. Add the same nil guard the siblings have.

* fix(xray): do not revive a manually stopped Xray on a background restart

RestartXray cleared isManuallyStopped unconditionally at its top, so the @30s
pending-config cron (and warp/ldap/outbound reconcile jobs) that call
RestartXray(false) resurrected an Xray the admin had deliberately stopped —
unlike the crash-detector, which honors the manual-stop flag. Skip a non-forced
restart while the stop flag is set; only an explicit forced restart clears it.

* fix(xray): retry a failed pending-restart instead of dropping the config change

The 30s cron consumed the need-restart flag with IsNeedRestartAndSetFalse before
calling RestartXray and only logged a failure. If RestartXray failed early (a
transient GetXrayConfig DB error) the old process kept running the old config,
the crash detector saw a running process and never retried, and the flag stayed
cleared — so an admin's saved change silently never reached the core. Move the
consume/restart/retry into ApplyPendingRestart, which re-arms the flag on
failure so the next tick retries.

* fix(xray): synchronize the process version and apiPort fields

Start writes p.version and p.apiPort (via refreshVersion/refreshAPIPort) after
flipping the process to running, while GetXrayVersion and GetAPIPort read them
lock-free from the status and traffic poll goroutines. The struct mutex
deliberately excluded these fields, so a restart racing a poll was a real data
race — a torn read of the version string header can crash. Extend the mutex to
cover version and apiPort, doing the blocking version probe before taking the
lock.

* fix(settings): detect a wildcard listen collision between the web and sub ports

The web/sub same-port check compared the two listen addresses as raw strings, so
binding both on all interfaces with different spellings (webListen 0.0.0.0 vs an
empty subListen) slipped past validation and only failed at startup with an
opaque bind error. Treat any wildcard listen ('', 0.0.0.0, ::) as overlapping so
the clash is reported up front, while still allowing two distinct specific
addresses to share a port.

* fix(db): mark the IP-limit cleanup seeder done on a fresh install

ResetIpLimitNoFail2ban is a one-time migration that, on a host without fail2ban,
zeroes every existing client's limitIp because the limit can't be enforced. It
was missing from the fresh-install fast-path seeder list, so on a brand-new DB it
did not run on the first boot but fired on the second — wiping any IP limits the
admin had set in between. Add it to the fast-path so a truly fresh install marks
it done up front (there is nothing to clean), leaving later admin-set limits
intact.

* fix(security): dial outbound subscriptions through the SSRF guard

The outbound-subscription fetch validated the URL host once (resolving DNS and
rejecting private targets) but then fetched with a plain HTTP client that
re-resolves the host at dial time, so a subscription domain the attacker controls
could pass validation as a public IP and rebind to 127.0.0.1 / a cloud metadata
endpoint / an internal host for the actual dial — a blind SSRF into the panel's
network. Route the direct fetch (and its redirects) through
netsafe.SSRFGuardedDialContext, which resolves, checks and dials the same IP
atomically, carrying the subscription's AllowPrivate flag on the request context;
a configured egress proxy still dials its loopback bridge unguarded.

* fix(security): bound the login-limiter attempts map

The login rate limiter keys its records on the caller-supplied username and only
evicted a record when that exact key was revisited or the login succeeded. An
unauthenticated attacker replaying one CSRF token while rotating a fresh username
per request seeded a record that was never revisited, growing the map without
bound until the panel OOMs. Cap the map: before inserting a new record, reclaim
records whose block has lapsed and whose failures aged out, and if the map is
still at the ceiling under a broad flood, drop one so memory can never grow past
the cap.

* fix(tgbot): require admin for privileged callbacks, not just the first switch

answerCallback wraps only its first callback switch in an isAdmin guard; the
second switch (server usage, inbound/online enumeration, database backup export,
ban logs, mass traffic reset, client creation) ran for every caller. Telegram
delivers a callback with the tapping user's id, so a non-admin who can see an
admin's inline keyboard — as when the bot runs in a group — could tap Backup and
receive the full database and config, or reset all traffic. Default-deny before
the second switch: a non-admin may only run the per-user client_* callbacks that
resolve their own data from their Telegram id.

* fix(eventbus): dispatch each subscriber in its own goroutine

The fan-out loop called every subscriber's handler sequentially on the
single dispatch goroutine. The email and Telegram notifiers block on
network I/O for tens of seconds (or minutes when the remote is slow), so
one slow subscriber stalled the whole loop: the 256-slot channel then
filled and Publish silently dropped later events — including high-value
xray.crash and node.down notifications unrelated to the slow handler.

Hand each delivered event to every handler in its own goroutine so a
blocking subscriber can no longer stall delivery to the others. safeCall
already recovers panics, so a detached handler cannot take down the bus.

* fix(integration): cap WARP API response body size

doWarpRequest read the response with an unbounded io.ReadAll, unlike the
sibling NordVPN client which already caps every read at maxResponseSize.
A hostile panel egress proxy or a MITM on the Cloudflare WARP endpoint
could stream an arbitrarily large body and force the panel into an
unbounded allocation. Wrap the body in an io.LimitReader(maxResponseSize)
to match the NordVPN client.

* fix(email): bound every SMTP step with a connection deadline

The "starttls"/"none" transport delivered through net/smtp.SendMail, which
dials with an untimed net.Dial and never sets a socket deadline. When an
SMTP server accepted the TCP connection but then stalled (or was a
blackhole), the caller was released by Send's 30s select, but the sender
goroutine and its socket stayed blocked until the OS TCP timeout — minutes
per notification, leaking a goroutine and a connection each time.
sendWithTLS dialed with a timeout but likewise armed no deadline on the
protocol phase, and TestConnection (called synchronously from the settings
handler, with no select guard) could hang the request indefinitely.

Replace SendMail with sendPlain, which dials with smtpConnectTimeout and
arms conn.SetDeadline(smtpDeadline) before the greeting read, preserving
SendMail's opportunistic STARTTLS upgrade. Arm the same deadline in
sendWithTLS and TestConnection so every SMTP step is bounded.

* fix(server): guard access-log parser against malformed lines

GetXrayLogs split each Xray access-log line on whitespace and then read
fixed offsets — parts[1] for the timestamp and parts[i+1] after the "from",
"accepted" and "email:" markers — without checking the line had that many
fields. A truncated or malformed line (the logged destination is
attacker-influenced) indexed past the slice and panicked; the panel handler
returned a 500 via Gin's recovery.

Extract the per-line field parsing into parseAccessLogFields and length
guard every positional lookup so a short line yields a partial entry
instead of panicking.

* fix(server): guard xray key-generator output parsing

GetNewX25519Cert, GetNewmldsa65 and GetNewmlkem768 parsed xray's stdout by
reading lines[0], lines[1] and each line's second colon-separated field
without any length check — unlike GetNewEchCert, which already guards its
line count. If the xray binary printed fewer than two lines or reformatted
its labels (a version change, or a silent failure that emitted nothing),
the fixed slice index panicked and the handler 500'd.

Extract the shared parsing into parseXrayKeyPairOutput, which length guards
the line count and each label split and returns an error instead of
panicking, then route all three generators through it.

* fix(tgbot): stop auto-deleted messages from resetting wizard state

SendMsgToTgbotDeleteAfter spawns a goroutine that, after the display delay,
deleted the transient message and then unconditionally cleared the chat's
conversation state. Every caller that ends a wizard step already clears the
state synchronously, so that call was redundant — and harmful: if within
the delay the user advanced to the next step (a callback sets a fresh
awaiting_* state), the late goroutine wiped it, and the user's next message
fell through unrecognized, silently dropping their input.

Move the delayed deletion into deleteMessageAfterDelay, which only removes
the message and no longer touches the conversation state. Guard
deleteMessageTgBot against a nil bot so the deletion path is unit-testable.

* fix(frontend): refetch a fresh CSRF token on 403 instead of reusing the stale meta tag

On a 403 to an unsafe method the client cleared its cached CSRF token and
called ensureCsrfToken to retry. But ensureCsrfToken prefers the
<meta name="csrf-token"> tag baked into the page, which the production
panel always injects, so the "refresh" re-read the same stale token and the
/csrf-token refetch was never reached — the retry re-sent the token that had
just been rejected and the save failed with an error toast.

The token lives in the session and rotates when the session is regenerated
(for example re-login in another tab), leaving the tab's baked-in meta token
stale. Fetch the current token straight from /csrf-token in the 403 branch so
the retry uses the authoritative server value. The existing tests only passed
because they strip the meta tag; the new test keeps a stale tag present.

* fix(frontend): surface backend error text from failed requests

HttpUtil.get/post read the thrown HttpError body as response.data.message,
but the backend error envelope (entity.Msg) serializes its text as msg. On
any non-2xx JSON response the real reason was therefore dropped and the
operator saw only the generic "Request failed with status N" toast.

Read response.data.msg first (keeping message and the native error text as
fallbacks). The sibling test had pinned the wrong body shape ({ message });
correct it to the real backend shape ({ success:false, msg }) so it exercises
the actual envelope.

* fix(frontend): share one WebSocket connection across bridge and hooks

websocketBridge.ts and useWebSocket.ts each declared their own
module-scoped sharedClient plus an identical getSharedClient, so the
"shared" client was not shared between them: whenever a page using
useWebSocket (Clients/Inbounds) mounted alongside the always-mounted
bridge, the panel opened two sockets to /ws. The server then pushed every
traffic/stats/nodes/inbounds snapshot to both, doubling WebSocket bandwidth
and running two independent reconnect loops, and the hook's socket was never
disconnected on unmount.

Hoist a single getSharedWebSocketClient into api/websocket.ts and route both
the bridge and the hook through it, so exactly one connection is opened.

* fix(frontend): guard the outbounds WebSocket handler against non-array payloads

onOutbounds wrote the raw WebSocket payload straight into the
outboundsTraffic cache, unlike the sibling onNodes/onInbounds handlers which
first check Array.isArray. A malformed non-array push (for example an object)
would land in the cache with staleTime Infinity; consumers that call
.find()/.map() on the outbounds list would then throw and crash the Outbounds
tab. Add the same Array.isArray guard so a bad push is ignored.

* fix(frontend): key the node table by the computed row key, not id

The desktop node table used rowKey="id", but transitive sub-nodes (the
read-only rows surfaced from downstream nodes) all carry id 0, so a topology
with two or more transitive rows gave React duplicate keys. antd's rowKey
prop overrides the row object's own computed `key` (`t-${guid}` for
transitive rows, the numeric id otherwise), so the unique key the code
already builds was ignored — causing row-state/DOM mis-association on any
re-render (heartbeat refetch, address-eye toggle). The mobile card path
already keyed by record.key.

Key the table by "key" so transitive rows get their distinct t-${guid}
identity; direct nodes keep key === id, so row selection (filtered to numeric
keys) is unchanged.

* fix(frontend): map routing row actions through the rule's real index

The routing table hides balancer-loopback rules (`_bl_*`) but keeps each
visible row's original index in `key`, then handed antd's positional row
index straight to edit/delete/toggle/move/drag — all of which mutate the
full, unfiltered routing.rules array. Once a hidden loopback rule precedes a
visible one (e.g. a balancer whose fallback is another balancer, plus any
rule added afterwards), the positional index no longer matches the array
index, so deleting or editing a rule silently hit the wrong one — including
destroying the loopback rule that keeps the balancer alive.

Add originalRuleIndex to translate a positional row index back through the
row's `key`, and route every mutating handler (openEdit, confirmDelete,
toggleRule, moveUp/moveDown, drag) through it. When no loopback rows are
hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): map outbound row actions through the outbound's real index

The outbounds table hides balancer-loopback outbounds (`_bl_*`) but keeps
each visible row's original index in `key`, then passed antd's positional
row index to edit/delete/move and to the per-row probe (onTest) and its
result lookup — all of which address the full, unfiltered outbounds array.
Once a hidden loopback outbound precedes a visible one, the positional index
diverges from the array index, so deleting or editing an outbound hit the
wrong one (its deletion-impact plan and removal targeting the wrong entry),
and the test button probed / showed results against the wrong outbound.

Add originalOutboundIndex and route the mutating handlers through it; key
the probe trigger and test-result columns by record.key. With no loopback
rows hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): tolerate a malformed happyEyeballs value in the Xray Basics tab

BasicsTab derived directHappyEyeballs by calling HappyEyeballsSchema.parse
during render, guarding only against null/non-object. A wrong-typed field
(e.g. happyEyeballs.tryDelayMs as a string) or any other shape mismatch —
reachable via the Complete Template JSON editor or an imported config — threw
straight out of render, white-screening the default Xray landing tab.

Use safeParse and fall back to null so a bad value degrades to "no override"
instead of crashing the page.

* fix(frontend): preserve routing-rule fields the form does not surface

The rule form rebuilt the rule from a fixed literal of only the fields it
edits, and RoutingTab replaces the rule wholesale on confirm. Fields the
form never exposes — localPort, localIP, process, ruleTag, webhook — are in
the rule schema and can arrive via the advanced JSON editor or Import Rules;
opening such a rule in the form and saving silently dropped them.

Carry over every key of the original rule the form does not manage before
applying the form-derived fields, so an edit only touches what it surfaces.

* fix(frontend): re-sync the sniffing island when its value changes externally

The sniffing config editor froze its seed value at mount and only watched
its own inner AntD form, never reflecting a later change to the shared RHF
`sniffing` path. Because the inbound form mounts every tab with
forceRender, the friendly Sniffing tab and the Advanced JSON editor are live
at once: editing sniffing in the JSON editor updated the RHF value but not
the frozen island, so the next interaction with the friendly tab emitted the
stale value and silently discarded the JSON edit.

Add an effect that pushes an external value change into the inner form,
guarded by the same lastEmitted marker the emit path uses so the island
never re-seeds from its own echo and no update loop forms.

* fix(frontend): don't drift a client's byte quota on a no-op save

The quota field shows the total in GB rounded to two decimals; editing a
client and saving converted that display value straight back to bytes. A
byte total not aligned to 0.01 GB — one set via the API or an import — was
therefore rewritten to the rounded value on any save that never touched the
field, losing a few MB each time.

Add resolveTotalBytes: keep the original byte total when the displayed GB
still matches it, and only re-derive from GB when the user actually changed
the field.

* fix(eventbus): deliver events on a bounded per-subscriber worker

The previous fix dispatched each event to every subscriber with a bare
`go safeCall`. That unblocked the dispatch loop, but removed the bus's
backpressure: under a login-attempt flood (which both notifier subscribers
process without rate-limiting) with email/Telegram enabled, every attempt
spawned handler goroutines that each block on network I/O for up to ~30s,
with no bound — a goroutine and outbound-connection storm. It also let a
subscriber's handler run concurrently with itself, racing the Telegram
notifier's lazily-cached hostname.

Give each subscriber its own bounded queue drained by a single worker
goroutine. Dispatch does a non-blocking send per subscriber (dropping only
that subscriber's event when its queue is full), so a slow subscriber still
can't stall the others, concurrency is bounded to one in-flight handler per
subscriber, per-subscriber event order is preserved, and Stop again waits
for in-flight handlers to finish.

* fix(frontend): map outbound mobile-card actions through the real index too

The desktop outbounds table was keyed by the outbound's real index, but the
mobile card list was left keying the probe trigger and every test-state
lookup by the positional row index. With a hidden balancer-loopback outbound
present, tapping Check on a mobile card probed the wrong outbound and the
Test-All results landed on the wrong card. Key onTest and the
testResult/isTesting reads by record.key, matching the desktop columns.

* fix(frontend): meet WCAG AA contrast on the config-block link text

The Storybook accessibility test flagged the share-link <code> block: with no
explicit color it inherited a muted grey that renders as #888888 on the
#f8f8f8 tertiary-fill background in CI's Chromium — a 3.33:1 contrast, below
the 4.5:1 AA threshold. Set the text to the theme's primary text token so the
colour is explicit and high-contrast in both light and dark themes instead of
depending on an inherited value that varies by browser.

* style(sub): simplify a negated conjunction to satisfy staticcheck QF1001

golangci-lint (staticcheck QF1001) flagged the `!(a && b)` guard in
expandSegment. Rewrite it via De Morgan's law to the equivalent
`!a || !b` form so the linter passes; behavior is unchanged.

* fix: close panics and races the audit's own fixes left nearby

Second-pass review of the 54-commit self-correcting audit. Each item below
was confirmed by reading the surrounding source (and, where practical, the
pre-fix code) before being changed; regression tests are included for every
behavioral fix.

Concurrency:
- eventbus: Bus.Subscribe called wg.Add with no synchronization against a
  concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a
  Telegram-bot settings save racing panel shutdown/restart). Stop now flips a
  mu-guarded `stopped` flag before waiting, and Subscribe checks it under the
  same lock, so Add and Wait can no longer race.

Security:
- login_limiter: evictForRoom's fallback eviction picked an arbitrary map
  key, including ones still under an active cooldown - an attacker flooding
  /login with fresh usernames could evict their own (or anyone's) blocked
  record and reset the lockout. The fallback now skips actively-blocked
  records, only falling back to an unconditional evict if the map is
  somehow entirely full of active blocks (preserves the hard memory cap).

Subscription-endpoint panics (reachable by any client hitting /sub):
- internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp
  with no path settings object) and the TLS alpn readers in three places
  used unchecked type assertions - exactly the bug class abab7cd0 patched
  elsewhere in the same switch statements, just not these call sites.
- internal/sub/json_service.go, clash_service.go: the externalProxy loops
  in the JSON and Clash generators used unchecked assertions on a
  legacy/admin-supplied field (missing "port", non-object entry, etc.).
- internal/sub/json_service.go: realityData's shortId/serverName selection
  could assert a non-string array element.

Other correctness:
- client_traffic.go: ResetAllTraffics (touched by 3eb214d0) still skipped
  clearing NodeClientTraffic node-sync baselines, unlike its sibling reset
  paths in the same file - a node's next sync would re-add pre-reset delta
  on top of the freshly-zeroed counter.
- inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were
  silently discarded; now logged so a backend-level commit failure (e.g. an
  aborted Postgres tx from a best-effort helper) doesn't masquerade as a
  successful tick.
- outbound_subscription.go: the new subscriptionFetchClient doc comment was
  wedged between fetchAndStore's existing comment and fetchAndStore itself,
  leaving fetchAndStore undocumented and the comment describing the wrong
  function.

Convention cleanup:
- Removed narrative // comments added by the audit that violate this repo's
  no-inline-comment rule (mostly narrating the specific bug/fix rather than
  a lasting contract, and mostly on new Test functions, which this repo's
  existing tests never comment) - calibrated against this exact codebase's
  own pre-existing comment style so legitimate godoc-style doc comments
  were left alone.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-17 00:33:06 +02:00
MHSanaei b11ceac18e fix(ci): install the docs-pinned pnpm instead of floating on 11.x
pnpm/action-setup resolved 'version: 11' to the newest 11.x, and its self-installer crashes upgrading to 11.12.0 (Cannot use 'in' operator to search for 'integrity'), failing both docs workflows at setup. Reading docs/package.json instead installs the exact packageManager pin (pnpm@11.9.0), which also keeps the workflows and the lockfile toolchain on a single source of truth.
2026-07-14 03:50:16 +02:00
MHSanaei bbc4163768 chore: standardize the toolchain on Node 24 LTS
The repo now pins Node 24 everywhere instead of mixing 22 and hardcoded workflow versions. The docs workflows read .nvmrc like the main CI already did, so the Storybook bundle in the Pages deploy builds on the same runtime as the PR gate. The docs gen:api script runs its TypeScript entry natively, dropping the experimental type-stripping flag that Node 24 makes default; the matching frontend cleanup (engines and gen:api) landed with the Storybook commit.
2026-07-14 03:39:03 +02:00
MHSanaei ee9a6067c2 refactor(frontend): migrate off deprecated Ant Design 6 props
The repo's type-aware deprecation sweep (eslint.deprecated.config.js) reported fourteen findings; it now reports zero. Alert message becomes title and closable+onClose becomes closable.onClose; Select optionFilterProp moves into showSearch.optionFilterProp and suffixIcon becomes suffix; Drawer width becomes size; Progress trailColor becomes railColor. Behavior is unchanged apart from a few single-mode selects gaining type-to-filter, which the old prop already implied.
2026-07-14 03:38:41 +02:00
MHSanaei 60316c831f fix(frontend): resolve every axe accessibility violation in the component library
Running the stories under axe surfaced real panel defects, not just story cosmetics. FormField never associated its Form.Item label with the wrapped control, so no RHF form field in the panel had a programmatic label; it now generates an id and wires htmlFor. Unnamed controls get accessible names: the prompt and text modal inputs (from the modal title), the client traffic progress bar (used/limit values), the CPU and RAM threshold inputs in the notification groups (event label threaded through the extra renderer), and the JSON editor's contenteditable surface.

ConfigBlock's collapse header carried role=button around focusable action buttons; collapsible=header scopes the toggle to the label. Light theme gains contrast-safe tokens shared by the panel and Storybook: darker description, placeholder, error and success text, a darker primary button blue, and a readable gold tag, all meeting the WCAG AA 4.5:1 ratio. The infinity badge swaps a prohibited bare aria-label for role=img.
2026-07-14 03:38:14 +02:00
MHSanaei df3ba568d1 feat(docs): publish the component Storybook on the docs site
The docs site and the component workbench were entirely disconnected. The Pages deploy now builds the frontend Storybook and bundles it into the artifact under /storybook, so the live component reference ships with the documentation, and the navbar links to it. Story changes trigger a redeploy so the published workbench cannot go stale.
2026-07-14 03:37:55 +02:00
MHSanaei 7078abc14a feat(frontend): make Storybook a validated, fully covered component workbench
Storybook existed only as an undocumented local tool: 9 of 24 reusable components had stories, autodocs pages were bare prop tables, nothing built or tested the stories, and no contributor doc mentioned the workbench existed.

Every reusable component under src/components/ now has a co-located story with enriched autodocs (component descriptions plus per-prop argTypes, kept as string metadata since the repo bans line comments). Stories double as headless Chromium tests through the Storybook vitest addon, with axe accessibility checks enforced as errors and play-function interaction tests covering the modals, the RHF field bridge, the config block, and the select-all buttons. The preview now mirrors the panel's real theme DOM (body class, shared AntD theme config, seeded theme storage) so what stories render matches production.

CI and make verify gain a static Storybook build as a compile gate, and the frontend test job installs Chromium so story tests run on every PR. Contributor docs (frontend README, CONTRIBUTING, agent guides) document the workbench, the story conventions, and the Controls setup. Node engines move to 24 LTS and gen:api drops the type-stripping flags that Node 24 makes default.
2026-07-14 03:37:21 +02:00
MHSanaei 4e928a1ce0 v3.5.0 2026-07-12 22:08:19 +02:00
MHSanaei e211a5cc47 feat(frontend): hide redundant migration download on sqlite panels
Back Up's .db now restores directly into a PostgreSQL panel, so the
SQLite-side Download Migration row only duplicated it; the row stays on
PostgreSQL panels where it is the only PG-to-SQLite path. Restore
accepts .dump and .db everywhere, the backup modal texts describe the
accepted formats in all locales, and the orphaned migrationDownloadDesc
key is removed.
2026-07-12 20:14:42 +02:00
MHSanaei 77dffe9a85 feat(server): sniff sqlite panel restore uploads and keep the fallback on failure
The SQLite panel's Restore now detects the upload by content like the
PostgreSQL panel does: migration dumps are rebuilt with RestoreSQLite,
pg_dump archives get a clear error instead of 'Invalid db file format',
and every upload passes the panel-schema pre-flight before Xray stops.
The .backup fallback survives a failed Xray start and is named in the
error, the DB pool is reopened on every error path after CloseDB, and a
failed InitDB closes the imported file before restoring the fallback so
the rename cannot hit a Windows sharing violation.
2026-07-12 20:14:22 +02:00
MHSanaei 54fc0fd47c fix(database): make cross-db migration lossless, transactional, and pre-checked
migrationModels was missing ClientGroup and ClientGlobalTraffic, so both
migration directions silently dropped client groups and global client
traffic; the model list is now extracted to allModels and a parity test
keeps the two lists from drifting again. MigrateData runs its truncate
and copy inside one transaction so a failed import rolls back instead of
leaving the destination truncated (sequences resync after commit since
setval is non-transactional). New PrepareSQLiteForMigration rejects
uploads that are not a panel database and AutoMigrates old backups so
their missing tables cannot break the row copy.
2026-07-12 20:14:07 +02:00
MHSanaei 30b611614b feat: import SQLite migration dumps through the PostgreSQL panel restore
The SQLite panel's Download Migration produces a portable SQL text dump
advertised as seeding a PostgreSQL panel, but the PostgreSQL Restore only
accepted pg_dump custom archives, so the migration file was rejected with
'Invalid file' even though the upload picker asked for .dump. importDB now
sniffs the upload header: PGDMP archives keep the pg_restore path, while
raw SQLite databases (.db) and SQL text migration dumps are rebuilt,
integrity-checked, and copied into PostgreSQL with the same MigrateData
engine as 'x-ui migrate-db --dsn'. The restore picker accepts .dump/.db on
PostgreSQL and the backup modal texts describe the accepted formats in
every locale.
2026-07-12 18:04:38 +02:00
MHSanaei 44f2f426d8 feat(frontend): split wireguard inbound export into config and links tabs
The per-inbound export modal only showed the joined .conf blocks for wireguard, with no way to grab the wireguard:// share links the QR modal already generates. TextModal gains an optional tabs prop (copy and download follow the active tab), and the wireguard export now offers a Config tab with the .conf blocks alongside a Links tab with the per-client wireguard:// URLs. Tab labels reuse the existing pages.clients.config / pages.clients.tabLinks locale keys. Other protocols keep the single untabbed view.
2026-07-12 15:39:00 +02:00
MHSanaei c4a1139d3f feat(frontend): treat wireguard inbounds as multi-user in client actions
WireGuard has been first-class multi-client on the backend for a while (key generation, tunnel address allocation, attach/detach/delete all flow through the shared client apply path), but isInboundMultiUser still excluded it, so wireguard rows only offered Export Inbound / Reset Traffic / Clone / Delete. Adding it to the multi-user set surfaces Export All URLs (per-client .conf blocks), the subscription export, and the attach/detach/group/delete-all client actions, and makes wireguard inbounds valid targets in the attach-clients picker. The now-dead isWireguard guard on the inbound-info branch is dropped. The clients-page bulk attach/detach modals carried the same stale protocol set, also missing mtproto, so both now match the single-client form's inbound picker.
2026-07-12 15:31:27 +02:00
MHSanaei 476bec451d fix(frontend): show zero client count for mtproto and wireguard inbounds 2026-07-12 15:27:28 +02:00
MHSanaei f905c2dcec chore(frontend): bump version and deps
Update the frontend package version from 0.4.1 to 0.4.3 and refresh key dependencies. This includes i18next/react-i18next, Storybook packages (10.5.0), and ESLint (10.7.0), with corresponding lockfile updates to keep dependency resolution in sync.
2026-07-12 15:10:27 +02:00
MHSanaei 814cda3fb4 feat(xray): update xray-core to v26.7.11 and adapt panel
Bump xtls/xray-core to 50231eaf (v26.7.11) and the three binary pins
(DockerInit.sh, release.yml x2) in lockstep.

Adapt the panel to the upstream changes:

- Shadowsocks "none"/"plain" and VMess "none"/"zero" were removed from
  the core. A migration rewrites stored none/plain SS methods to a
  supported cipher and none/zero VMess security to "auto" (on both the
  clients column and inbound settings JSON); the SS build-time heal does
  the same so a row injected after boot cannot brick startup. The removed
  values are dropped from every frontend option list, schema and adapter,
  and coerced to "auto" at the Go link/sub/Clash emit sites and both link
  importers. Fix the CipherType_NONE sentinel that no longer compiles.

- Unencrypted vless/trojan outbounds to a public address are now refused
  by the core. Validate outbounds through the vendored config loader when
  saving the xray template and when storing/merging outbound
  subscriptions, so one such outbound cannot keep the core from starting.

- New TCP finalmask type "xmc" (Minecraft mimicry): add it to the sub
  link allowlist, the frontend enum and the FinalMask form (hostname,
  usernames, required password), and document it.

- streamSettings gained a "method" alias for "network"; canonicalize it
  to "network" at inbound save time and in the form adapters/schema so a
  method-keyed config keeps its transport.

- New root "env" config key is passed through xray.Config, compared in
  Equals, and forces a restart in the hot diff.

- REALITY now defaults minClientVer to 26.3.27; update the form
  placeholder.
2026-07-12 00:30:47 +02:00
MHSanaei cbd2940a63 fix(node): adopt a node inbound's host overrides into the master
Per-inbound Host overrides (Security/SNI/Fingerprint/ALPN and friends)
are looked up by the local inbound id when subscriptions render, but
nothing in the node sync ever fetched the node's hosts table: an
inbound adopted from a managed node got zero Host rows on the master,
so its subscription configs fell back to a bare TLS block without the
fingerprint/SNI the node was configured with.

When a traffic snapshot carries a tag with no central row yet - the
only moment adoption can happen - the sync job now also pulls the
node's existing hosts/list endpoint (best-effort, so old nodes just
skip it) and the adoption branch materializes that inbound's groups
against the new central id inside the same transaction, reusing the
group-to-rows projection the hosts API already uses. Master stays
authoritative afterwards: this is a one-time import, not a continuous
sync, matching how the inbound's own settings are adopted.

Closes #5890
2026-07-11 23:17:57 +02:00
MHSanaei e6bef229ae fix(web): opt panel pages out of Cloudflare Rocket Loader
Behind Cloudflare with Rocket Loader enabled, the panel's entry bundles
were rewritten and executed through Rocket Loader's own loader instead
of as native ES modules (a reporter's network capture shows the main
bundle initiated by rocket-loader.min.js). That breaks module semantics
and script ordering, leaving a blank page after login even though every
asset returns 200 - most visibly with a custom URI path, where the
injected base path must be set before the bundle boots.

Stamp data-cfasync="false" - Cloudflare's documented per-script opt-out
- on the built entry script tags via a build-time transformIndexHtml
hook (Vite regenerates entry tags, so a source-HTML attribute would be
stripped), and on the runtime-injected base-path/version inline script
in serveDistPage.

Closes #5868
2026-07-11 22:48:59 +02:00
MHSanaei 975b1f1acc fix(iplimit): ban a dead connection once instead of every scan
When a client's connection drops without a clean TCP close, xray-core
keeps its online-map entry until the session context ends (idle policy),
minutes after the kernel socket is gone. The 10s IP-limit scan kept
seeing that stale IP as the oldest live one and re-emitted the same
[LIMIT_IP] Disconnecting OLD IP line plus a RemoveUser/AddUser cycle
every scan - operators measured 100+ repeats over ~1000s for a single
network switch, forcing absurd fail2ban maxretry values to avoid
banning legitimate mobile users.

The core refreshes an entry's lastSeen only when a new connection from
that IP is dispatched, never on traffic, so a frozen lastSeen across
scans is a dead connection, not a reconnect. Track the lastSeen of each
banned (email, ip) pair and skip the log line and disconnect until it
advances; a real reconnect moves lastSeen and is enforced exactly as
before, and an age cutoff that could misclassify long-lived active
tunnels is deliberately avoided.

Closes #5893
2026-07-11 22:48:58 +02:00
MHSanaei 6aa87f4e57 fix(clients): finish deleting from every inbound when one fails
Delete aborted its per-inbound loop on the first error, so a client
attached to inbounds across several nodes lost at most one per attempt:
the loop never reached the remaining nodes, the record cleanup after
the loop never ran, and each retry started over with whatever was left.
Operators with many nodes had to delete the same client once per node.

Collect per-inbound failures and keep going so every reachable inbound
and node is cleaned in a single pass, then keep the client record only
when something failed - its settings JSON still holds the client there,
so the next delete retries exactly the leftovers - and return the
joined failures instead of silently reporting success. DeleteByEmail's
legacy fallback loop gets the same treatment.

Closes #5845
2026-07-11 22:48:57 +02:00
MHSanaei 200ea09157 fix(node): never sweep a node's inbounds before their first adoption
Adding a node imports nothing; its pre-existing inbounds only become
central rows on the first clean traffic-sync tick. But any save of the
node (switching sync mode, picking tags after "Load inbounds from
node") marks it config-dirty, and the next tick then ran ReconcileNode
before that first adoption: with zero central rows the delete sweep saw
every remote tag as undesired and destroyed the node's real inbounds -
in "all" mode all of them - disconnecting live clients with no
confirmation, and the master then reported "record not found".

Track the first completed clean sync in nodes.inbounds_adopted_at and
skip the sweep (pushes still run) until it is set, so "absent locally"
can no longer be conflated with "deleted on the master". A node that
has synced before still sweeps normally, including the offline
last-inbound-deleted case. Existing nodes are seeded as adopted on
upgrade to keep their behavior unchanged.

Closes #5898
2026-07-11 22:48:57 +02:00
MHSanaei fc625d8f66 fix(database): drop the legacy UNIQUE constraint on inbounds.port
Inbound.Port carried a unique gorm tag before multi-node existed; the
tag was removed from the model long ago, but AutoMigrate never drops a
constraint, so SQLite databases created in that era still physically
enforce global port uniqueness. On such installs the node-scoped port
conflict logic is correct yet the raw insert fails - manual "Deploy to
node" saves and setRemoteTraffic's central-inbound adoption both die
with "UNIQUE constraint failed: inbounds.port" when two nodes use the
same port.

Add a SQLite-only migration that drops an explicit unique index on port
directly, and rebuilds the table (create from the current model, copy
shared columns, swap) when the constraint is inline, since SQLite can't
ALTER it away. Fresh databases and Postgres are untouched.

Closes #5894
2026-07-11 22:48:56 +02:00
MHSanaei c4448f4ea8 fix(clients): rename client record atomically with inbound settings
Update committed an email rename to the clients table with a standalone
write before the per-inbound loop rewrote each inbound's settings JSON.
In that window the record held the new email while the JSON still held
the old one, so any concurrent SyncInbound (traffic poll, another edit)
found no record for the old email and inserted a duplicate seeded from
the stale JSON - carrying the same subId, which then failed every later
edit with "Duplicate subId". The subId collision check also ran after
that write, so even a rejected update permanently renamed the email.

Move the rename inside UpdateInboundClient's serialized transaction,
next to the settings save and SyncInbound, so no other writer can see
one without the other; skip it when a record already owns the target
email (the merge case). Update now only runs collision checks before
the loop and falls back to a direct rename solely for records with no
attached inbound. This covers both the REST API and the web UI editor,
which share this path.

Closes #5870
2026-07-11 22:48:54 +02:00
MHSanaei 3b731cd657 fix(clients): reuse stored credentials when re-adding an existing identity
Create permits a repeat add for an email that already exists when the
payload subId matches the stored one (the documented way to attach an
identity to more inbounds), but it never seeded the payload from the
existing record, so an omitted id minted a fresh UUID via
fillProtocolDefaults. SyncInbound then overwrote the shared clients.uuid
row by email while previously-attached inbounds kept the original UUID
in their settings JSON, silently desyncing panel credentials from
subscription links. BulkCreate had the identical gap.

Seed ID/Password/Auth/Secret from the existing record in both paths
(mirroring what Update, Attach and BulkAttach already do), and preserve
Secret in Update too so partial edits of MTProto clients cannot rotate
the stored secret.

Closes #5903
2026-07-11 22:48:51 +02:00
MHSanaei 1c789c3e4d fix(script): confirm auto-detected public IPv4 before issuing IP certificate
On networks with asymmetric routing (or proxies/multi-WAN), external
IP-echo services can return a transit or gateway address instead of the
server's real incoming IP, and every IP-certificate flow silently
issued for that wrong address. The x-ui.sh menu flow (option 20 -> 6)
and the install.sh/update.sh SSL menus now show the detected IPv4 for
confirmation (Enter keeps the old behavior), and declining falls into
the same validated manual-entry loop already used when every provider
fails. Non-interactive installs are untouched - XUI_SERVER_IP already
pins the address there.

Closes #5867
2026-07-11 22:35:31 +02:00
MHSanaei 201d4731de fix(xhttp): stop XMUX maxConcurrency from reverting on save
XHttpXmuxSchema defaulted maxConnections to 6 (added to mirror xray-core
v26.6.27's anti-RKN client default), so load-time hydration backfilled a
non-zero maxConnections onto every config whose saved xmux lacked the
key. Since maxConnections and maxConcurrency are mutually exclusive on
the wire, the save-time exclusivity rule then saw both fields set and
silently deleted the user's maxConcurrency; the missing key came back as
the '16-32' schema default on the next load, so edits appeared to never
save.

Revert the bare schema default to 0 and seed the anti-RKN
maxConnections=6 only when XMUX is freshly toggled on
(XMUX_FRESH_DEFAULTS, with maxConcurrency left blank — xray-core parses
an empty range string as 0), so the two strategies never start out
conflicting. The inbound and outbound XMUX forms now also clear the
opposing field live as soon as the user sets one, so whichever strategy
was actually typed is the one persisted.

Closes #5864
2026-07-11 20:43:47 +02:00
MHSanaei c62e8c6bbe ci(claude-bot): structure PR review and issue triage prompts
Rework the handle-pr-review, handle-pr-fix, and handle-issue prompts to produce professional, structured output. The review job now rates findings by severity and confidence across explicit review areas and reports a Summary, Findings, and a text-only verdict in one plain comment; the fix job reuses the same lens to prioritize what it applies versus leaves for the author; issue triage gains a structured bug-confirmation format and explicit outcomes for mislabeled and not-a-bug reports, closing conservatively. Severity uses text labels to respect the no-emoji house style, and the adapted ignore-list keeps i18n and generated files flaggable.
2026-07-09 15:45:07 +02:00
MHSanaei d33b6865a9 ci(claude-bot): auto-open the PR after an owner @claude fix on an issue
claude-code-action only pushes a branch and posts a Create PR link by design; it never opens the PR itself. Add a post-step to the mention job that opens a PR from the action's branch_name output when the trigger was an issue (guarded against no-op branches and against an existing PR).

Simplify the mention prompt so the agent just makes edits with Edit/Write and lets the workflow commit and open the PR, instead of running git/gh pr create itself (which fought the action's built-in flow and left only a link).
2026-07-09 12:06:20 +02:00
MHSanaei de5b130095 ci(claude-bot): gate write capability to trusted actors
Make every automatic, untrusted trigger read-only and require an explicit trusted actor for any code change.

- handle-issue (issue opened): read-only triage; confirm bugs and tag the maintainer, never edit code or open a PR. Authenticates as GITHUB_TOKEN so replies post as github-actions[bot], not a personal account.

- handle-pr-fix (PR opened): applies fixes only for owner/member/collaborator authors; dropped allowed_non_write_users so the default write gate also applies.

- handle-pr-review (PR opened, external authors): read-only review comment only.

- mention (@claude comment): runs only for the repository owner; may open a PR from an issue or commit to a PR on explicit request.

No job authenticates as the static PAT anymore; the PAT is used only to route git pushes for the trusted PR-fix and owner-mention paths.
2026-07-09 02:18:43 +02:00
Sanaeiandclaude[bot] f3e99058f9 fix(sub): apply host Allow Insecure to Hysteria2 subscription links (#5866)
Host.AllowInsecure was only wired into the shared VLESS/VMess/Trojan/Shadowsocks
endpoint path (applyEndpointAllowInsecure). Hysteria/Hysteria2 builds its links
through its own applyExternalProxyHysteriaParams (raw hysteria2:// link) and
buildHysteriaProxy (Clash/Mihomo proxy), neither of which read the host's
allowInsecure flag, so a self-signed Hysteria2 host never got insecure=1 or
skip-cert-verify: true. Fixes #5865.

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-09 02:03:05 +02:00
MHSanaei 7780ab0e23 ci(claude-bot): auto-fix trusted PRs and easy issue bugs
Split the review-only handle-pr job into handle-pr-fix (owner/member/collaborator PRs: apply refactors and bug fixes directly, commit to the PR branch, no suggestion blocks) and handle-pr-review (external/fork PRs: one review-only comment, no suggestions, no code checkout).

Upgrade handle-issue to open a fix PR for easy bugs (pushed via CLAUDE_BOT_PAT so pull_request CI runs on it), confirm the root cause and tag the maintainer for big bugs, and never open a PR for feature or enhancement requests.
2026-07-09 00:31:00 +02:00
MHSanaei f4199353da chore(frontend): bump dependencies
Routine version bumps: i18next, msw, typescript-eslint, vitest and
@vitest/coverage-v8 to their latest patch/minor releases, with the
lockfile regenerated to match.
2026-07-08 22:11:37 +02:00
MHSanaei 5c5a509605 chore: add golangci-lint tasks and force LF on Go files
Add VS Code tasks to run golangci-lint (with and without --fix) and mark
*.go as text eol=lf so Go sources check out with LF, avoiding spurious
CRLF lint failures on Windows. Also drop the now-redundant explicit
DockerInit.sh/DockerEntrypoint.sh entries already covered by *.sh.
2026-07-08 22:11:28 +02:00
MHSanaei a067f817ae refactor: modernize Go with strings.SplitSeq and maps.Copy
Replace strings.Split loops with strings.SplitSeq iterators in the CSV
parsers (reality_scan and the scale-test helpers) and swap a manual map
copy for maps.Copy in the MTProto traffic collector. No behavior change;
these are the fixes the modernize analyzer reports.
2026-07-08 22:10:54 +02:00
MHSanaei 328d920e98 feat(mtproto): enforce per-client quota & expiry via mtg-multi limits
Map each mtproto client's totalGB and expiryTime onto mtg-multi's new
[secret-limits] (quota/expires): emit them into the generated config and
hot-apply through PUT /secrets so live connections survive. Quota is
written as an exact "<n>B" byte count that round-trips through both the
config and API parsers without the precision loss of a base-2 unit.

The sidecar's quota counter is not pruned when a secret is dropped, so a
panel-side traffic reset re-pushes the client's secret and then calls
POST /secrets/{name}/reset-quota (wired into every reset path) so a
renewed client is not immediately re-blocked.

Resolve the mtg-multi binary from the fork's latest release tag in
DockerInit.sh and release.yml instead of a hardcoded version pin, so the
panel no longer needs a manual bump per fork release.
2026-07-08 15:30:56 +02:00
Sanaei 61e12e4c29 Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859)
* chore(frontend): add husky + lint-staged pre-commit gate

Wire a local pre-commit gate that runs eslint --fix on staged
frontend TypeScript via lint-staged. Because the only package.json
lives in frontend/ while the git root is one level up, the prepare
script installs husky hooks at frontend/.husky from the repo root
(cd .. && husky frontend/.husky), and the pre-commit hook cd's into
frontend/ before invoking lint-staged so node_modules resolves.

* test(frontend): add MSW request mocking

Add Mock Service Worker so tests can exercise the real http-init.ts
request pipeline (CSRF acquisition, 403 refetch-and-retry, body
parsing) instead of only stubbing HttpUtil. A node setupServer is
started for the vitest unit project with onUnhandledRequest bypass so
the existing HttpUtil spies and 55 component tests are untouched; the
browser worker is copied to public/ for Storybook and dev use.

* chore(frontend): add Storybook + component stories

Set up Storybook 10 on the React-Vite builder (compatible with the
pinned Vite 8.1.3 and React 19). The preview decorator mirrors the
vitest component harness: an Ant Design ConfigProvider with a
light/dark toolbar toggle and an en-US i18next instance. main.ts
neutralizes the app vite config bits that do not belong in a component
workshop (the three-entry rollup input, renderBuiltUrl, and the shared
dist outDir) so build-storybook can never clobber internal/web/dist.
Seeds stories across the presentational library (viz, ui, clients,
feedback). build-storybook is a local tool and is not wired into the
CI gate.

* feat(frontend): add React Hook Form primitives

Introduce the shared RHF layer that AntD inputs bind through, ahead of
migrating the forms off Ant Design's Form store:
- FormField wraps a Controller in an Ant Design Form.Item shell,
  reconciling the value/onChange shapes of Input, Switch, InputNumber,
  Select and friends via normalizeAntdOnChange, with input/output
  transforms and Zod-issue-key error messages resolved through t().
- useZodForm wires zodResolver (Zod 4) with the AntD-matching modes
  (validate on submit, then live) and shouldUnregister false so hidden
  and unmounted-tab fields keep their values.
- rhfZodValidate covers the rare per-field rule sites.
Covered by a FormField test exercising normalization, transforms, and
resolver error surfacing.

* refactor(frontend): migrate Pattern-B leaf forms to React Hook Form

Move the controlled-useState leaf forms onto RHF via the FormField
primitive, keeping Ant Design components and each form's exact submit
behaviour (same safeParse, same toast on the first Zod issue, same
payload building):
- clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal
- xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal

Multi-control widgets that don't fit a single input (inbound dual
select, subId regen, expiry branches, the balancer tag warning) stay as
explicit Controller/setValue. Derived visibility now reads live values
through useWatch. FormField gains a required prop so migrated fields keep
their required-asterisk affordance.

Settings tabs are intentionally excluded: they are control-panel
components that live-patch a parent AllSetting via SettingListItem, not
Ant Design Form submit-forms.

* refactor(frontend): migrate LoginPage to React Hook Form

Replace the Ant Design Form store + antdRule per-field validation with
useForm + FormField. The AntD Form stays as the layout/submit wrapper,
now driving methods.handleSubmit(onSubmit) via onFinish. Username and
password validate through rhfZodValidate(LoginFormSchema.shape.*); the
two-factor field keeps its conditional required rule (only registered
when 2FA is enabled). Submit posts the same values to /login.

* refactor(frontend): migrate ClientFormModal to React Hook Form

Move the client add/edit form off controlled useState onto RHF while
preserving exact submit behaviour (same ClientFormSchema /
ClientCreateFormSchema safeParse, same toast, same payload + attach/
detach diff + external-links build). expiryDate is stored as an epoch
number (never a Dayjs) to survive RHF's value cloning, converted at the
DateTimePicker boundary. externalLinks uses useFieldArray with stable
ids. inboundIds and the derived show*/ss2022 visibility read live via
useWatch. Space.Compact button-group widgets stay manual Controllers so
the joined borders keep working.

* refactor(frontend): migrate Node and DNS modals to React Hook Form

Both are self-contained Pattern-A forms (no shared fragments). Replace
Form.useForm with useForm + FormProvider, Form.useWatch with useWatch,
setFieldValue with setValue, and partial validateFields([...]) with
methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules;
the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the
DNS domains/expectIPs/unexpectIPs string arrays are driven by
useWatch + setValue. Submit runs through handleSubmit on the modal OK
button, preserving each form's exact validation, payload build, and
save/onConfirm behaviour.

* refactor(frontend): migrate HostFormModal to React Hook Form

The host external-proxy editor's outer form moves to useForm +
FormProvider. Security/tab visibility reads via useWatch; the three
json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are
bound as value/onChange black boxes through a Controller (their own
internal forms are unchanged). remark/inboundId keep their validation
via rhfZodValidate; submit runs through handleSubmit and builds the
same payload (isDisabled = !enable) and save call.

* refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form

Move the outbound form cluster off Ant Design's Form store onto RHF.
The parent uses useForm + FormProvider with a watch() subscription for
the protocol reseed cascade and setValue-based network/security/xmux
cascades; the JSON<->Basic bridge and the formValuesToWirePayload
submit are preserved exactly. Every outbound transport/protocol/security
fragment now binds through FormField/useWatch via context.

The shared config editors stay untouched and are bound through small
value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField,
SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds
directly. The host json-form wrappers that reuse the outbound MuxForm/
SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move
to a local RHF provider to match. Outbound render/link tests pass
unchanged.

* refactor(frontend): migrate InboundFormModal + fragments to React Hook Form

Move the inbound add/edit form (the largest form in the panel) and its
transport/protocol/security fragments off Ant Design's Form store onto
RHF, mirroring the outbound migration. The parent uses useForm +
FormProvider with a watch() subscription for the protocol reseed
cascade (type==='change' guard so programmatic resets don't reseed) and
setValue-based network/security cascades; useSecurityActions drives the
TLS/Reality keypair + scan through setValue. Hidden pass-through
Form.Items are dropped (their values ride in the reset object and
survive via shouldUnregister:false), so getValues() still returns the
settings.clients subtree untouched. accounts / certificates / tun lists
use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind
through the value/onChange adapters.

Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation
toast + formValuesToWirePayload exactly. The golden link/full fixtures
pass byte-for-byte, confirming identical wire output. inbound-form-blocks
test harness rewritten from a Form.useForm harness to an RHF provider.

* refactor(frontend): retire antdRule; document the RHF form pattern

All forms now build on React Hook Form, so the AntD-Form Zod adapter
antdRule (src/utils/zodForm.ts) has no remaining callers — remove it.
Update frontend/CLAUDE.md: forms use useZodForm + FormField from
components/form/rhf with zodResolver/rhfZodValidate validation; AntD
<Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors
stay AntD islands wrapped as value/onChange adapters bound via a
Controller.

* chore(frontend): cover esbuild in the allowScripts allowlist

esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a
postinstall that npm's allow-scripts flags as uncovered on every
install. Its platform binary is delivered through the @esbuild/<platform>
optionalDependencies, so the postinstall isn't needed here; deny it like
the other entries to silence the warning.

* fix(frontend): restore label layout in Sniffing/FinalMask field adapters

The value/onChange adapters that wrap the shared SniffingFields and
FinalMaskForm editors put them in their own isolated AntD Form, but that
Form was missing the label layout the fields used to inherit from the
inbound/outbound parent form. Their labels rendered full-width instead
of the compact right-aligned column, so the Sniffing tab and the TCP
Masks / QUIC Params sections looked broken. Give both adapter forms the
same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout.

* ci: add least-privilege permissions to Docs CI workflow

The docs-ci workflow had no explicit permissions block, so it inherited
the repository default for GITHUB_TOKEN. The build job only checks out
and builds the docs, so restrict it to contents: read, resolving the
CodeQL actions/missing-workflow-permissions alert.
2026-07-08 13:28:37 +02:00
MHSanaei 8ee79cf447 refactor(frontend): replace recharts with uPlot for charts
Swap the Sparkline chart component (the only chart in the panel) from recharts to uPlot, keeping its public prop API identical so the three consumers (SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) are untouched. This drops recharts and 32 transitive deps (es-toolkit, victory-vendor, d3, redux, immer), shrinking the chart vendor chunk to ~51KB (22KB gzip).

The uPlot port reimplements every recharts feature on canvas: gradient area fill, spline curves, up to three series, dashed horizontal grid, formatted axes, hover tooltip and marker, reference lines, and min/max extrema dots. Because canvas cannot read CSS variables, axis and ring colors are resolved via getComputedStyle and repainted on theme changes through a MutationObserver on the body class and documentElement data-theme.

Also removes the es-toolkit/compat resolver shim from vite.config.js, which existed only for recharts, and swaps the manualChunks entry to vendor-uplot.

Note: repaint with redraw(false); a bare uPlot redraw() re-runs _setScale and nulls the index-based x-scale, which collapsed the series to a flat, partial line.
2026-07-08 02:20:30 +02:00
MHSanaei bc309ed9f8 refactor(frontend): replace axios with the native Fetch API
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.

- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
  request/response interceptors on fetch: base-path prefixing,
  X-Requested-With, same-origin credentials, the CSRF token on unsafe
  methods, a single 403 retry with token refresh, and the 401
  redirect-and-latch. A small encodeForm() reproduces qs's
  arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
  envelope, so the ~49 API call sites are untouched. HttpOptions is now
  hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
  with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
  base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
  HttpUtil's envelope unwrap / toast / error mapping; this logic was
  previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
  reword stale "axios" mentions in docs and route comments.
2026-07-08 01:09:18 +02:00
MHSanaei 9b91f0f42e docs: vendor the documentation site into the monorepo
Fold the standalone 3x-ui-docs project (Next.js 16 + Fumadocs, deployed to
docs.sanaei.dev) into docs/ so the panel and its documentation share a single
source of truth, the way sing-box keeps its docs in-tree. The old repo becomes
redundant and can be retired.

- Import the full site under docs/ (app, components, content, lib, public,
  scripts, config). The self-contained pnpm project sits alongside the existing
  engineering notes with no filename collisions.
- Re-point "Edit on GitHub" links from MHSanaei/3x-ui-docs to this repo's
  docs/content/docs path (docs/lib/shared.ts, docs/app/.../page.tsx).
- Add docs-ci.yml and docs-deploy.yml under .github/workflows/, scoped to
  docs/** and run with working-directory: docs, since GitHub only runs
  workflows from the repo-root .github/. deploy-static.yml's GitHub Pages
  publish (CNAME docs.sanaei.dev) carries over unchanged.

Follow-up (outside this commit): attach the docs.sanaei.dev custom domain to
this repository's Pages (or set the Vercel project's root directory to docs),
confirm the site is live from the monorepo, then delete MHSanaei/3x-ui-docs.
2026-07-07 23:07:14 +02:00
MHSanaei 2c49dbf54e fix(node): start a fresh quota window when a node auto-renews a client
When a node-hosted client auto-renews, the node extends the deadline
and zeroes its own counters, but the master treated the counter drop
like any reset dip (#5456): the delta clamped to zero, the renewed
expiry was adopted, and the old period's up/down stayed on the master
row. A "100 GB every 30 days" package never got a fresh quota on the
master for node inbounds.

Detect the renewal in setRemoteTrafficLocked - reset days configured,
an absolute deadline that moved forward, and the node counter falling
below the stored baseline - and on that path adopt the node's
post-renewal counters and enable state absolutely instead of adding
the clamped delta, plus clear the email's stale cross-panel
global-traffic rows, mirroring what the local autoRenewClients path
already does. A plain counter dip without a deadline move keeps the
existing clamp behavior, and a deadline extension with rising counters
keeps accumulating.

Closes #5843
2026-07-07 15:05:23 +02:00
MHSanaei cc3303dd8c fix(sub): carry a host's Final Mask into raw share links
A Host's Final Mask was merged into the JSON and Clash subscription
outputs via applyHostStreamOverrides, but the raw link builders compute
the fm param once from the inbound's own streamSettings.finalmask
before the per-host fan-out, and the endpoint override path never read
the host's mask. A Final Mask configured only on a host was silently
dropped from vless/trojan/ss/vmess share links while an inbound-level
mask worked everywhere.

Merge the host mask into the fm param per endpoint with the same
additive semantics as the JSON path (host tcp/udp masks appended to the
inbound's, quicParams only when the inbound has none), for both the
URL-param and the VMess object link forms.

Closes #5831
2026-07-07 15:05:23 +02:00
MHSanaei 52d4af71bc fix(ldap): attach auto-created clients to every configured inbound tag
The sync job built an independent client per configured tag and called
CreateOne once per tag. Each call generated a fresh random subId, and
the email-uniqueness check in ClientService.Create only re-admits a
taken email when the incoming subId matches the stored one - so the
first tag succeeded and every other tag failed with "email already in
use", leaving new LDAP users on a single inbound.

Build the client once per email and hand ClientService.Create the full
list of resolved inbound ids, the same path the panel's own client
create endpoint uses: one identity (email, subId) attached to all
configured tags, with per-protocol credentials filled per inbound.
Unknown tags are now skipped with a warning instead of building
clients against a nil inbound.

Closes #5846
2026-07-07 15:05:22 +02:00
MHSanaei 7cb2adf429 fix(client): clean node_client_traffics rows when deleting a client
Delete and DeleteByEmail removed client_traffics, global-traffic, and
inbound_client_ips rows but never the per-node baseline rows in
node_client_traffics, so every deleted client left orphaned baselines
behind for each registered node. The shared DelClientStat and
delClientStatsByEmails helpers already clean that table; mirror the
same cleanup in both row-cleanup paths so the record-only and
record-less delete flows stop leaking baselines.

Closes #5841
2026-07-07 15:05:22 +02:00
MHSanaei ad7a0f8164 refactor(mtproto): manage ad-tags per client only
The inbound-level ad-tag duplicated the per-client override for no
gain: the fork's global tag applied to every secret anyway, so one
value had two homes and they could drift. The inbound form field, the
settings key, and the global ad-tag in the generated config and in the
PUT /secrets body are gone; the tag is set on each client instead.
Existing inbound-level values are intentionally not migrated; a
leftover settings key is stripped on the next save.
2026-07-07 12:19:26 +02:00
MHSanaei 406ce54fb2 chore(mtproto): bump the mtg-multi binary pin to v1.14.0
Same asset layout and platform coverage as v1.13.3; picks up the
sidecar's Docker-style environment variable support.
2026-07-07 12:01:01 +02:00
MHSanaei 43500a5470 feat(mtproto): per-client ad-tags, management-API auth, and record secret sync
Catch the panel up to the mtg-multi README (v1.14.0):

- Each client can now carry its own 32-hex advertising tag overriding the
  inbound-level one. The tag lives on the client (settings JSON is the
  source of truth, clients.ad_tag is the UI projection), is rendered into
  the fork's [secret-ad-tags] section for active secrets only (mtg rejects
  a config whose override names an unknown secret), is pushed per entry
  through PUT /secrets, and is part of the reload fingerprint so a tag
  edit hot-applies without dropping connections.
- The loopback management API can replace the whole secret set, so every
  mtg process now gets a random per-process api-token; the manager sends
  it as a bearer token on PUT /secrets and GET /stats and reuses it across
  config rewrites, because mtg reads the token only at startup.
- Malformed tags are rejected at every save path and additionally dropped
  in InstanceFromInbound: one bad tag would otherwise fail the whole
  generated config and take every client of the inbound down with it.
- SyncInbound never copied a re-keyed mtproto secret into the canonical
  clients table, so the clients page and subscription links kept serving
  the old secret, which mtg then rejects. It is now guarded-copied like
  the other credentials.
2026-07-07 12:00:43 +02:00
MHSanaei 659f0f404c fix(ci): stop executing tag-checkout code in the release smoke test
CodeQL alert 99 (actions/cache-poisoning/poisonable-step): the workflow_run
job runs in the default branch's cache scope, so checking out
workflow_run.head_sha and executing a script from it is a cache-poisoning
surface. The if-guard (event == 'push') already kept fork PRs out, but the
checkout pin was never the load-bearing part of the release verification —
the version argument is, since install.sh downloads that exact release
binary. Run the smoke script from the default branch instead, which also
matches what real users execute.
2026-07-07 01:23:10 +02:00
Sanaei 6214ff4edc fix(mtproto): stop dropping connections on client/inbound edits; add live updates + ad-tag (#5838)
* fix(mtproto): split the mtg fingerprint into structural and secrets parts

A reordered clients array in the stored settings used to read as a config
change because the fingerprint concatenated secrets in array order, and one
opaque fingerprint could not tell a restart-worthy change (bind address,
fronting, throttle) from a secret-set change a reload-capable mtg can absorb
in place. Sort the secret pairs so order stops mattering, and split the value
so the upcoming hot-reload path can decide between keeping, reloading, and
restarting the process.

* fix(mtproto): stop restarting mtg on every inbound edit

Saving an mtproto inbound tore down and respawned its mtg sidecar even when
nothing material changed, dropping every live Telegram connection: the update
path pushed DelInbound+AddInbound, and Remove deletes the manager's map entry,
so Ensure's fingerprint no-op gate could never fire. Route mtproto updates
through a single Ensure call so an edit that leaves the generated TOML alone
keeps the process, and only real config changes restart it.

Capturing the pre-edit protocol also fixes a latent leak: changing an
inbound's protocol away from mtproto never stopped the sidecar, because the
snapshot handed to the runtime already carried the new protocol and the
removal took the xray branch, leaving an orphaned mtg holding the port.

An mtproto push failure no longer requests an xray restart - xray cannot fix
the sidecar, and the 10s reconcile job self-heals it.

The regression test fakes mtg by re-executing the test binary, counting
spawns through a pid file: an unchanged save and a remark-only edit must keep
the process, a re-keyed secret must restart it.

* fix(mtproto): exclude depleted clients from the reconcile job to match the sync push

The 10s reconcile job derived mtg secret sets from raw inbound settings while
the interactive push filtered clients through buildRuntimeInboundForAPI, which
drops client_traffics-disabled (depleted or expired) clients. The two paths
therefore disagreed on the fingerprint - each disagreement one needless mtg
restart dropping live connections - and worse, the job kept serving depleted
clients' secrets indefinitely, so running out of traffic never actually cut an
mtproto client's access.

DesiredMtprotoInstances now builds the job's desired state with the same
depletion overlay the push uses (one bulk client_traffics query), drops
inbounds whose every secret is filtered away so their sidecar stops, and
AddInbound pushes the filtered payload too so an imported inbound carrying
disabled stats does not seed a fingerprint the next reconcile disagrees with.

* feat(mtproto): hot-reload mtg secrets in place instead of restarting

A client add, removal, re-key, or enable-toggle changes only the [secrets]
section of the generated config, yet the panel could apply it only by killing
and respawning the mtg sidecar, dropping every Telegram connection on that
inbound. Split the ensure decision three ways: an identical config is a no-op,
a secrets-only change rewrites the TOML on the same api port and asks mtg to
hot-swap it via POST /reload, and a structural change (or a failed reload)
falls back to the full stop-and-start.

The reload endpoint is served by the mhsanaei/mtg-multi fork; against an older
binary the POST 404s and the manager restarts exactly as before, so panel and
binary upgrades stay order-independent.

* feat(mtproto): apply single-client edits to the sidecar immediately

Client CRUD on an mtproto inbound was a runtime no-op, so an add, delete,
re-key, or enable-toggle only reached mtg on the next 10s reconcile. With the
sidecar now able to hot-reload, push the change straight after the edit commits:
applyLocalMtproto rebuilds the inbound's filtered client set and re-applies it,
so a new client works within a moment (and, on a reload-capable binary, without
disturbing the others) and deleting the last client stops the process.

The three interactive single-client paths (add, update, delete) call it; bulk
operations still ride the reconcile job, which converges to the same state.

* chore(mtproto): pin mtg-multi to the mhsanaei fork v1.13.3

The reload endpoint the panel now uses lives in the mhsanaei/mtg-multi fork, so
point the source-build pin (DockerInit.sh + both release.yml matrices) at it and
bump to v1.13.3. The install still produces the same mtg-multi binary name, so
the mtg-<os>-<arch> rename and everything downstream are unchanged. Docs and the
package comment note the hot-reload path and its restart fallback.

* feat(mtproto): apply live secret updates via the management API and add ad-tag

Two capabilities the mhsanaei/mtg-multi v1.13.3 fork exposes are now surfaced by
the sidecar manager.

Live updates go through PUT /secrets on the fork's management API instead of
POST /reload: the panel already holds the whole desired set per inbound, so it
sends secrets and the advertising tag as one JSON call that mtg applies
atomically, keeping every unchanged connection and closing only removed or
re-keyed ones. The config file is still written first so a restart or crash
recovery reproduces the state, and any non-200 (an older binary, a refused
connection) still falls back to a full restart.

Per-inbound ad-tag adds an optional 32-hex Telegram advertising tag plus
public-ipv4/public-ipv6 overrides. The ad-tag rides the reloadable secrets
fingerprint, so changing it hot-applies without dropping connections; the public
IPs are proxy-construction parameters and sit in the structural fingerprint, so a
change there restarts the process. Empty public IPs are omitted so mtg
auto-detects the reachable address.

* feat(inbounds): expose the mtproto ad-tag and public IP in the inbound form

Adds an Ad-tag field (validated as 32 hex characters) plus optional Public IPv4
and Public IPv6 overrides to the MTProto inbound form, backed by the same-named
settings the sidecar writes into the mtg config. The public IPs are optional —
left blank, mtg auto-detects the reachable address the ad-tag middle proxy needs.
English strings are added to every locale; the non-English ones carry the
English text until translated and fall back to it meanwhile.

* ci(mtproto): install mtg-multi from prebuilt release binaries

The fork now publishes release archives for every platform we package, so
download and unpack the matching mtg-multi-<ver>-<os>-<arch> binary instead of
compiling it from source with go install. Faster builds and no toolchain step,
and the archive's platform labels line up with our matrix; the produced
mtg-<os>-<arch> filenames are unchanged.

* i18n(mtproto): localize the ad-tag and public IP strings

The six mtgAdTag*/mtgPublicIp* keys shipped with English text in every locale as
a placeholder. Translate them into the twelve non-English locales (Arabic,
Spanish, Persian, Indonesian, Japanese, Portuguese-BR, Russian, Turkish,
Ukrainian, Vietnamese, and Simplified/Traditional Chinese); en-US is unchanged.

* retired goreportcard.com
2026-07-07 01:13:24 +02:00
MHSanaei 84b6423020 fix(mtproto): stop persisting a vestigial inbound-level secret
MTProto is multi-client: mtg's [secrets] config and every share link read only the per-client secrets. The old HealMtprotoSecret regenerated an inbound-level secret on every save, and seedMtprotoSecretsToClients only dropped it for legacy single-secret inbounds, so multi-client inbounds kept a dead secret. That value once leaked into stale links imported into Telegram, which mtg then rejected as "incorrect client random".

Replace HealMtprotoSecret with StripMtprotoInboundSecret (removes the key), strip on save in normalizeMtprotoSecret, and add a one-time stripMtprotoInboundSecrets migration that runs after the seeder so a legacy secret is first preserved onto a client before the inbound-level copy is dropped.
2026-07-06 17:55:57 +02:00
MHSanaei 27fd19895a fix(mtproto): drop the remark fragment from tg proxy deep links
genMtprotoLink appended the panel remark as a URL fragment (tg://proxy?...&secret=...#remark). Because secret/server is the last query value, lenient Telegram parsers fold the "#remark" into it and the imported proxy breaks with "incorrect client random". Telegram proxy deep links have no name field, so emit a clean link on both the backend (internal/sub) and frontend (inbound-link.ts). The remark still shows as a separate tag in the inbound info modal, which reads it from genAllLinks, not the URL.

Guards: Go TestGenMtprotoLinkFields asserts no fragment; the frontend mtproto link test asserts no '#'.
2026-07-06 17:55:35 +02:00
MHSanaei a1ca43d869 chore(gen): refresh generated schemas after Client.Secret comment drop
Commit d8b9f535 dropped the trailing comment on model.Client.Secret but did not regenerate the openapigen output, leaving a stale "MTProto FakeTLS secret" description in schemas.ts and openapi.json. Rerun make gen to bring the generated files back in sync with the source.
2026-07-06 17:55:15 +02:00
MHSanaei 977fe4b4ea fix(ci): install mtg-multi without GOBIN for cross-compiled release builds
go install refuses to run with GOBIN set when GOOS/GOARCH differ from the host, which failed the linux release build for every non-amd64 platform (386, arm64, armv7, armv6). Let it install into GOPATH/bin instead, where cross-compiled binaries land in a GOOS_GOARCH subdirectory, and locate the binary there. DockerInit.sh keeps GOBIN because buildx runs it under emulation for the target platform, making the install native.
2026-07-06 16:16:44 +02:00
MHSanaei d8b9f535ff style(model): drop trailing comment on Client.Secret to satisfy gofumpt
The long example tag on Secret pulled the struct's trailing-comment block into a new alignment section, so gofumpt demanded every following comment be re-aligned to that tag's column. Removing the comment restores the previously accepted layout and follows the repo rule against line comments.
2026-07-06 16:16:31 +02:00
MHSanaei d97bd8643e feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client
Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound.

The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates.
2026-07-06 16:04:32 +02:00
MHSanaei 5e9606aa4d fix(script): stop logging an error when Enter accepts the default ACME port
Pressing Enter at the 'Please choose which port to use (default is 80)' prompt left WebPort empty, and bash arithmetic treats an empty string as 0, so the out-of-range branch fired and printed 'Your input is invalid' even though the default was correctly applied. Handle empty input as accepting the default silently, and validate real input with a digits-only regex so non-numeric entries like '8x' get the invalid-input message instead of a bash arithmetic error. Applied to the identical prompt in x-ui.sh, install.sh, and update.sh.

Fixes #5829
2026-07-06 12:44:37 +02:00
MHSanaei f36f481e02 feat(db): add pgclient command to install or upgrade PostgreSQL client tools
Restoring a panel backup made by a newer pg_dump fails when the host's
pg_restore is older, and the existing pg_ensure_client only installs the
distribution package when the tools are missing - it can never upgrade,
and distribution repositories often cap below the required major.

Add pg_upgrade_client to x-ui.sh, exposed as 'x-ui pgclient [major]' and
as a PostgreSQL menu entry: it checks the installed pg_restore major,
tries the distribution package for the exact requested major first, and
falls back to the official PostgreSQL repository (apt on Debian/Ubuntu,
yum/dnf on Enterprise Linux, with a /usr/pgsql PATH symlink fallback);
Arch, Alpine and openSUSE install their current package. The panel's
dump-version mismatch error now names the ready-to-copy command with
the exact major parsed from the dump header.
2026-07-06 09:24:18 +02:00
MHSanaei de70ecb026 fix(db): probe dump readability before PostgreSQL import
pg_restore cannot read archives newer than itself, so importing a dump
made by pg_dump from PostgreSQL 17+ into a panel with an older
postgresql-client failed with a raw 'unsupported version (1.16) in file
header' - and only after Xray had already been stopped for the restore.

Probe the uploaded file with pg_restore --list first, which reads only
the archive TOC without touching the database, so an unreadable dump is
rejected before Xray is interrupted. When the failure is a dump-format
version mismatch, translate it into a message naming the PostgreSQL
version that produced the dump and the client version to install.
2026-07-06 09:01:19 +02:00
MHSanaei ed66209e38 feat(outbound): add real-delay connection test mode
The HTTP probe reports the warm per-request round-trip, which reads
lower than the delay figure client apps show for the same server. Add a
third "real" test mode that reuses the temp-instance HTTP probe but
reports the cold request's full elapsed time - tunnel establishment
included - and skips the warm request. UDP-transport outbounds forced
out of the TCP lane still report "http"; in real mode they report
"real". The mode joins the TCP/HTTP toggle on the outbounds tab, with
the label translated in all 13 locales.
2026-07-06 08:35:48 +02:00
MHSanaei 5a7b3b7370 fix(client): stop duplicate client entries accumulating in inbound settings
Adding a user to multi-node inbounds could leave 3-6 identical entries
in one inbound's settings.clients array: addInboundClient appended
incoming clients unconditionally, and the duplicate-email precheck
exempts a matching subId (so one identity can span several inbounds),
so a retried or raced add of the same client re-appended it to an
inbound that already carried it - on the master and, since nodes run
the same code, on every node, whose snapshot adoption then copied the
duplicates back verbatim. The normalized clients/client_inbounds tables
stayed clean (unique constraints), which is why the phantom rows only
showed in settings-driven views like the Detach clients modal, where
duplicate React keys also broke the selection counter.

Three layers: addInboundClient now skips incoming clients whose email
is already on the target inbound (idempotent re-adds instead of
duplication), node snapshot adoption collapses duplicate emails before
writing the central row, and an idempotent startup repair rewrites any
inbound whose settings still carry duplicates from older builds.

Closes #5770
2026-07-05 21:17:25 +02:00
MHSanaei 9d1a21b484 fix(ui): keep an explicit zero happy-eyeballs delay across the round trip
Follow-up found in review: the wire normalizer still stripped
tryDelayMs when it equaled 0, but with the schema default now 250 a
reload rehydrates the missing field as 250 - a user who explicitly set
0 ("disabled", per the field's own placeholder) would see 250 and any
subsequent save would silently enable a delay they turned off. Keep
tryDelayMs on the wire unconditionally; it is the one happy-eyeballs
field whose presence changes xray's behavior.

Refs #5780
2026-07-05 21:17:12 +02:00
MHSanaei 0753f5ee83 fix(link): reject non-finite and clamp out-of-range quicParams from fm=
Follow-up hardening of the fm= sanitizer found in review. ParseFloat
accepts "inf"/"NaN", and a non-finite float64 makes json.Marshal fail
later - the subscription refresh discards that error and blanks the
stored outbound set, so one poisoned link could wipe a subscription's
outbounds. Values that coerce fine but sit outside xray-core's accepted
ranges (keepAlivePeriod 0 or 2-60, maxIdleTimeout 0 or 4-120,
maxIncomingStreams 0 or >= 8) still killed the config load, and huge
magnitudes serialize in exponent notation that xray's integer fields
reject. Coerced values are now stored as integers, clamped into the
accepted ranges, and dropped when negative, non-finite, or absurdly
large; the TS import parser mirrors the same rules.

Refs #5783
2026-07-05 21:16:56 +02:00
MHSanaei 837cf5f24e fix(db): clamp traffic counters below int64 max and repair overflowed rows
A counter pushed past int64 (multi-node setups hit this via historic
delta-compounding bugs) makes SQLite silently promote the INTEGER cell
to REAL. From then on the column no longer scans into the Go int64
field and every reader of client_traffics fails at once: the inbounds
page, xray restarts, and node traffic sync all return "converting
driver.Value type float64 to int64".

Two-part fix: every unbounded "up = up + ?" add (local traffic, node
delta merge, inbound counters, plus the Go-side outbound accumulation)
now saturates at TrafficMax, a cap safely below math.MaxInt64 so one
more delta cannot overflow; and a startup repair casts REAL-promoted
cells back to INTEGER and clamps all traffic counters into
[0, TrafficMax] across client_traffics, inbounds, outbound_traffics
and node_client_traffics, restoring access to already-corrupted panels
without manual sqlite surgery.

Closes #5762
2026-07-05 20:33:09 +02:00
MHSanaei b1fa76f9b6 fix(node): fully delete clients on nodes instead of only detaching them
Deleting a client on the master propagated to nodes via the detach
endpoint, which removes the client from that one inbound's settings but
deliberately keeps the client record. The node ended up with an
orphaned record that kept showing in its Clients view; the master and
node could never converge on a delete.

Full-delete and detach intent now travel separately: the Runtime
interface gains DeleteClient, which on Remote hits the node's
panel/api/clients/del endpoint (record, attachments, traffic; repeat
calls for a client on several inbounds of the same node are swallowed
as idempotent "not found"). Delete/DeleteByEmail/BulkDelete use it for
node inbounds, while Detach/BulkDetach keep the inbound-scoped detach
RPC so removing a client from one inbound never wipes it node-wide
(the #5543 guarantee is preserved and covered by tests). Bulk deletes
above the fold threshold still converge membership via reconcile; their
leftover node records can be cleaned with the node's delete-orphans
action.

Closes #5797
2026-07-05 20:28:26 +02:00
MHSanaei b6873c7a73 fix(outbound): measure HTTP test delay on a warm connection
Since the batched prober replaced the single tester, the reported delay
came from one cold request with keep-alives disabled, so it stacked the
SOCKS handshake, proxy dial, proxy TLS, target TCP and target TLS on top
of the round-trip. Users upgrading from v2.9.4 - whose tester warmed the
connection first and timed a second request - saw several times the real
connection time.

The cold request still proves reachability and supplies the HTTP status
plus the connect/TLS/TTFB breakdown; the delay is now re-measured on a
second request over the kept-alive connection, falling back to the cold
total when the warm request fails. Bodies are drained (bounded) so the
connection returns to the pool, and the batch test asserts both requests
of a probe share one connection.
2026-07-05 20:19:25 +02:00