mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-18 04:37:14 +07:00
ac193cd9d389458a1f6aa7e7f7a363309d070da6
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ac193cd9d3 |
refactor(ci): split the issue analyst out and brief the review job from a file
The issue analyst moves verbatim from claude-bot.yml into its own claude-issue-analyst.yml, so claude-bot.yml now holds only the pull-request side: review, @claude mentions and conflict resolution. The review job's briefing was a single 2,600-character quoted string inside claude_args, unreadable and unreviewable. It now lives in .github/claude/review-job.md, assembled at run time with a "This run" section that hands the reviewer the pinned head SHA, the pull request and the exact check-runs command, and reaches the CLI through --append-system-prompt-file. The agent-mode action sets no system-prompt append of its own, so the file flag cannot collide with one. Findings no longer carry the fix: REVIEW.md and the brief both forbid suggestion blocks, patches and replacement snippets, overriding the code-review skill's --comment step, which attaches a committable suggestion to any small fix. A finding states what is wrong, where, what triggers it and what breaks; the maintainer decides the change. |
||
|
|
f727d04f65 | v3.7.0 | ||
|
|
fcf60eb2e2 |
chore: bump dependencies and clear deprecated frontend APIs
Routine dependency refresh: telego 1.11.2, go-sqlite3 1.14.50, grpc 1.83.1,
miekg/dns 1.1.73, sing 0.8.14 and the usual indirect churn on the Go side;
react-query 5.102.2, i18next 26.4.0, react-hook-form 7.86.0, Storybook
10.5.10 and vite 8.2.2 on the frontend, which also lifts the private frontend
package to 1.0.0.
That left npm run lint:deprecated with five call sites. Zod 4 deprecates the
ZodTypeAny alias in favour of the bare z.ZodType constraint, and react-query
renamed queryClient.fetchQuery to queryClient.query ahead of removing the old
name in the next major — the two share an implementation, so the swap in the
settings test is behaviour-identical.
Also untracks internal/web/dist/.gitkeep.
|
||
|
|
103b0dfe8d |
fix(job): expire stored client IPs of offline clients
ipStaleAfterSeconds was only applied while a row was being rewritten, and rows are only rewritten for clients present in the current online scan. A client that stopped connecting therefore kept its last addresses forever in inbound_client_ips, and node_client_ips rows (including those of deleted clients) were never revisited at all. Sweep both tables every five minutes, dropping entries past the cutoff and deleting rows that end up empty. The sweep runs ahead of the fail2ban and api-mode gates so retention holds even on panels that collect nothing. Closes #6286 |
||
|
|
2d30ab3ada |
fix(panel): stop one poisoned DNS answer from blocking outbound tests
SanitizePublicHTTPURL rejected a hostname as soon as any single resolved
address was blocked, so a resolver returning a bogon AAAA for the test URL
host (e.g. 2001::1 for www.google.com, inside the Teredo range blocked
since
|
||
|
|
d175050f2e |
fix(job): force-disconnect over-limit Hysteria2 clients
disconnectClientTemporarily still gated on the protocol list from before XrayAPI.AddUser learned hysteria, so an over-limit Hysteria2 client kept its QUIC session until the fail2ban ban aged out, while a VLESS client in the same situation was dropped at once. buildUserAccount handles hysteria and model.Client already marshals the auth field the re-add needs, so admit the protocol. wireguard stays excluded: its keepAlive marshals as a JSON number, which the string-only user-field parsing rejects after the user was already removed. Closes #6256 |
||
|
|
7a595cb46d |
fix(sub): keep Hysteria2 mport on external-proxy links
genHysteriaLink only looked up the UDP hop range on the no-endpoint path, after the externalProxy fan-out had already returned. An inbound with Hosts therefore emitted per-host links without mport, so clients pinned themselves to the single listening port and silently lost port hopping. Set the param before the fan-out so every endpoint inherits it, matching the frontend link builder and the Clash emitter. Closes #6264 |
||
|
|
d9b599b9aa |
fix(sub): forward tlsSettings.cipherSuites into the JSON subscription
tlsData rebuilds the client-side tlsSettings from a whitelist of keys and never copied cipherSuites, so an inbound configured with e.g. "TLS_AES_256_GCM_SHA384" handed clients a config that negotiated any suite. Copy it through when non-empty; it is a real xray-core tlsSettings field, unlike the non-standard "cs" share-link param. |
||
|
|
cc245a908e |
style: format struct literals and whitespace
Clean up trailing braces, commas, and unnecessary blank lines in struct initializations across sub and network packages. |
||
|
|
c26ff59b47 |
chore(sub): drop the inlined externalLinkEnabled test helper
|
||
|
|
81fcacab11 |
chore(build): bump Go toolchain to 1.27.0
Go 1.27.0 shipped on 2026-08-19. Raise the go directive and the builder image so Docker and release builds pick it up; every CI job already reads the version from go.mod, and golangci-lint v2.13.1 release binaries are themselves built with go1.27.0, so the lint job needs no pin change. |
||
|
|
a3e617215c |
fix(ci): pin the head the review job checks out
The review job checked the pull request out through refs/pull/N/head, a ref the author can move after a maintainer types "@claude review". Code scanning flagged it twice on the issue_comment path: an untrusted checkout in a privileged context (alert 111) and the time-of-check / time-of-use race that ref creates (alert 110). Resolve the head once, up front, and refuse the run when the fork was pushed to after the request that vouched for it, mirroring the freshness gate resolve-conflicts already uses; the checkout then names that immutable SHA. pull_request_target runs take the head SHA straight from the payload, so they skip the comparison. The trailing "posted nothing" check no longer fires on top of a refusal, which would otherwise report a second, misleading failure. |
||
|
|
5321665d5b |
feat(ci): give the review bot a severity scale and a tally
REVIEW.md said what blocks and what does not, but never how to mark a finding, so every review invented its own shape and none carried a severity. It now names the three markers the hosted Code Review service uses - Important, Nit, Pre-existing - and keys them to what the pull request did rather than to how alarming a defect looks alone: a defect it introduces or worsens is Important, one it merely brought into view is Pre-existing and cannot be a reason to hold it. Pre-existing was missing entirely, and checking what this panel emits means reading far outside the diff, so those findings had nowhere to go except a wrong Important or silence. The volume cap said how many and never which. It now collapses a nit repeated across files into one finding, prefers a nit in code the pull request wrote over one in code it only moved, caps pre-existing findings at three, and states that Important findings are never capped - a section listing two caps otherwise reads as licence to trim what matters. The review opens with a tally so the author sees the shape before the detail. Two contradictions went with it. The file told the reviewer to skip what CI enforces and then to check that a new i18n key reaches all 13 locales, which i18n-dead-keys.test.ts pins in both directions - the rule moves to "Do not report" with the reason. "Anything CI already enforces: npm audit" overstated what runs; CI audits production dependencies at high and above, so a dev-dependency advisory is out of scope by design. The reviewer could not read its own CI. Only postgres-durable-first runs against PostgreSQL, and XRAY_E2E_BINARY and XUI_SCALE_TEST are set by no job, so a dialect or migration change can carry a wall of green while the paths it touches never executed. That belongs to the verification bar, next to the rule that a behaviour claim needs a file:line citation, and "CI passed" now needs a run actually read. Also names the two house choices no linter defends: neither golangci-lint nor oxlint rejects a testify or Tailwind import. Both kinds of claim rot on a rename, so a test pins them the way repo-context.md's claims are already pinned - the CI jobs REVIEW.md names must exist in ci.yml, the skip gates it calls unset must stay unset, and the locale count must match the directory. The review itself moves from high to max effort, and the prompt records why it names REVIEW.md at all: the code-review skill reads CLAUDE.md on its own but not REVIEW.md, so dropping that clause would silently stop the file applying. Drops a CLAUDE.md reference to tools/seedperf/, which no longer exists - the review reads that file as project context, so a stale path there misleads it. |
||
|
|
73a971c2d1 |
fix(ci): give the review bot the pull request's own code and CI verdict
Three consecutive review runs (#6105, #6265, #6272) posted accurate findings but ended with the same "nothing was verified" paragraph, and the transcripts show why: under pull_request_target the only checkout is the base branch, so every Read of a changed file returned the pre-merge version and the agent fell back to fetching blobs one at a time through the API — 452 Bash calls on #6105 alone. It tried `git fetch origin pull/N/head` in all three runs and was denied every time. Check the head out read-only beside the base tree and say so in the prompt, so the reviewer greps the code actually under review. Nothing builds or executes from pr-head/: this job carries a write-scoped token, which is exactly the pwn-request REVIEW.md classes as blocking. CI had already run the full gate on each head SHA, but no run ever looked — `check-runs` appears in none of the three transcripts. Point the reviewer at it so a red or missing required check becomes a finding instead of a disclaimer. Also pass an explicit review level: with none given the skill reuses the last one typed, which in CI does not exist (ReportFindings recorded level=null on #6272). And allow WebFetch/WebSearch — the PIA review was denied both while trying to confirm the bundled PIA public key, then had to file that same check as unverified. |
||
|
|
19a2c23c01 |
fix(ci): repair the review comment and the conflict-resolution guard
Two failures from the same afternoon, both in the bot workflow. The review of #6272 ran for 34 minutes across four subagents and posted "No issues found. Checked for bugs and CLAUDE.md compliance." — three lines for a 73-file diff. The agent had written a per-area coverage summary in its own last turn and then dropped it on the floor, because the code-review skill's comment template carries findings and nothing else. A comment that cannot distinguish a thorough clean review from a run that died early is not evidence, so REVIEW.md now states what the posted comment must show and the system prompt points the run at it. The same run logged 67 permission denials. Only the inline-comment MCP tool was named in --allowedTools, so `gh api`, writing the diff to a scratch file, and reading it back were all auto-denied: agents spent turns hunting for a writable directory, and the openapi.json copy check REVIEW.md calls blocking could not be run at all ("gh api was unavailable in this sandbox"). Name the tools the review actually uses. The conflict resolution on #6243 resolved both conflicted files correctly and was then rejected by its own guard: "Edits outside the conflicted set: CLAUDE.md". The agent never touched CLAUDE.md — it had Edit rights on exactly two paths and no shell. claude-code-action deletes and restores CLAUDE.md, .claude/, .mcp.json and friends from the base branch before it runs, because the PR head is untrusted, and that restore is what dirtied the tree. Name that set once, exclude it from the stray-edit check, and hand back rather than resolve when a conflict lands inside it — the restore would silently overwrite the resolution and stage the base copy. |
||
|
|
e4798a027c |
chore(lint): adapt to staticcheck v0.8.0 under golangci-lint v2.13.1
golangci-lint v2.13.0 pinned honnef.co/go/tools v0.8.0-rc.1, whose staticcheck never terminates on internal/web/service/tgbot: the run pins ~520% CPU with RSS climbing past 700MB rather than deadlocking, so it reads as a hang. controller/, job/ and service/... only appeared stuck because they pull tgbot into the analysis graph. v2.13.1 ships the final v0.8.0 and clears it — that package goes from unbounded to 0s, and a cold full run to 22s. CI needs no pin; it already tracks latest. The same bump reworded SA1019 from parser.ParseDir to go/parser.ParseDir, which silently voided the openapigen exclusion, so the pattern now matches either spelling. fasthttp Client.RetryIf is deprecated in favour of RetryIfErr. The old path left resetTimeout at its zero value, so returning false preserves the existing retry timing exactly. The rest are gofumpt redundant-paren removals from the stricter formatter — semantic no-ops. |
||
|
|
845abc380e |
fix(ci): make the review bot post its findings and acknowledge mentions
Three separate ways the bot went silent after the move to the official code-review skill: - The skill skips a PR it has already commented on without comparing the reviewed head to the current one, so #6272 got no review of the commits pushed after the first pass. A prior review now only justifies a skip when its "Reviewed head:" SHA matches the current head, and never when the run came from an explicit "@claude review". - The review agent launched its subagents in the background and ended its turn to wait for them. A headless run terminates on end_turn, so the findings were discarded and the job still reported success. The prompt now requires foreground subagents, and a new step fails the job when a run posts nothing for the current head, instead of passing green. - A custom prompt puts claude-code-action in agent mode, which never adds the eyes reaction, so a mention gave no sign it had been picked up. |
||
|
|
58669f6146 |
refactor(ci): replace the in-house review lanes with the official code-review skill
The four pull_request_target review jobs in claude-bot.yml (Senior Developer / QA / Tester / Arbiter and their shared rubric) are replaced by a single review job running the official code-review plugin - the same skill behind Anthropic's hosted Code Review and the review workflow /install-github-app generates. The hosted service needs a Team/Enterprise organisation, so the plugin runs in CI on the maintainer's subscription instead: inline findings on PR open and ready-for-review, plus manual (re-)review when the owner or a collaborator comments "@claude review". The official example triggers on pull_request, but GitHub withholds secrets from fork runs and essentially every 3x-ui pull request is from a fork, so the job keeps the lanes' pull_request_target posture: the workspace is the base revision and nothing from the pull request is checked out or executed. What the lanes uniquely knew is distilled into REVIEW.md, handed to the skill via --append-system-prompt and pinned by bot_context_test.go the way repo-context.md is: the runtime.Runtime dispatch rule, migration and upgrade safety, the four-step route contract chain including the unchecked docs copy, the i18n rule, the three link implementations, and the wire-format verification bar. The mention job now ignores "@claude review" comments on pull requests so the review trigger does not also wake the generic bot, and the lane-only rubric file goes with the lanes. The remaining prompts also lose their tone micro-rules (no emoji, no exclamation marks, no filler) and the workflow's comment banners are removed. |
||
|
|
19e71d9acc | refactor(ci): move the bot's repository briefing into versioned files a test pins | ||
|
|
f7db247b07 |
perf(clients): write client_inbounds deltas and check identity from the clients table
Client CRUD latency scaled with the number of client-inbound edges rather than with the size of the change. On a 5k-client / 8-inbound / ~56k-edge PostgreSQL panel, creating one client took 60-120s (#6252). Two independent causes, both confirmed by the reporter's pg_stat_statements and reproduced locally at their topology. SyncInbound deleted every client_inbounds row for an inbound and re-inserted the whole set, so a one-client edit rewrote thousands of unrelated rows. The dominant caller was not user CRUD: the node traffic poll re-syncs every node inbound from its snapshot every 5s, so the panel churned the entire membership table continuously in the background. SyncInbound now reads the current links and writes only the difference - insert missing, update a changed flow_override, delete departed. Callers are unchanged, so every reconciliation path benefits, and the four hot client CRUD paths additionally pass only the clients they touched via ApplyInboundClientDelta. The insert needs clause.OnConflict: the unconditional delete it replaces also serialized concurrent syncs of one inbound, and the node poll commits in its own transaction outside the serialized writer, where a duplicate key would abort the whole poll on PostgreSQL. Identity and membership questions expanded every inbound's settings.clients JSON - 5.75s per call under the reporter's load. They now read the indexed clients and client_inbounds tables, which every read path already trusts, over just the emails being checked. A LOWER(email) expression index keeps the case-insensitive matching indexed; a struct tag cannot declare one. Measured on PostgreSQL 17 at 8 inbounds x 6000 clients, rows written to client_inbounds per operation, before -> after: create across 8 inbounds 48008 ins / 48000 del -> 8 ins / 0 del update the client 48008 ins / 48008 del -> 0 ins / 0 del detach from 4 inbounds 24000 ins / 24004 del -> 0 ins / 4 del delete the client 24000 ins / 24004 del -> 0 ins / 4 del Two behavior changes worth naming. An email seen with two different subIds across two inbounds' JSON used to be locked so that no add could claim it, including the one with the correct subId; the clients row now adjudicates. And on an install whose settings JSON holds an email with no matching link, "is this email on another inbound" now answers no, so deleting it elsewhere purges its traffic rows; compactOrphans and the startup heal already converge such drift. Every added test was verified against a hand-written mutation of this change, so none of them pass regardless of the fix. One mutation survives on purpose: swapping OnConflict DoUpdates for DoNothing is only observable when two transactions race the same row, and a timing-dependent test would be flaky. Per-node batching of remote pushes and the metadata-only inbounds list from the same report are deliberately not in this change. Closes #6252 |
||
|
|
c8a3a2d723 |
fix(security): require a 2FA code to replace the stored TOTP secret
The confirmation gate in updateSetting only covered the true -> false transition, so a settings save that kept twoFactorEnable=true while carrying a non-blank twoFactorToken silently rebound the authenticator. preserveRedactedSecrets restores the stored secret only when the submitted one is blank, so a non-blank value went straight through without any branch asking for a code. Not reachable pre-auth or cross-site (CSRFMiddleware rejects unsafe methods without the session token), but it matters after a session hijack or with an admin API token, which sets api_authed and short-circuits the CSRF check: the attacker gains persistence and locks the legitimate operator out of their own authenticator. Now a code is required whenever 2FA is currently on and the submitted secret differs from the stored one. Enabling from off is untouched, as no code exists yet to verify, and a blank secret still means "unchanged", so the panel's normal save path is unaffected. Reported by @n0ctal (GHSA-xqqw-jqqv-99h6). |
||
|
|
b51f09768b |
fix(netsafe): classify IPv6 transition and CGNAT ranges as internal
IsBlockedIP leaned entirely on Go's net.IP predicates, which judge an address by its own range only. 6to4 (2002::/16), NAT64 (64:ff9b::/96 and 64:ff9b:1::/48) and Teredo (2001::/32) each tunnel an arbitrary IPv4 destination inside an IPv6 address, so all five predicates returned false for e.g. 64:ff9b::7f00:1 and the SSRF guard waved it through. CGNAT (100.64.0.0/10) and the deprecated site-local block were unclassified for the same reason. Reported as GHSA-cfpf-wmjp-gh6c. Reaching the embedded IPv4 needs a 6to4 tunnel, NAT64 gateway or Teredo client on the host, none of which exist by default, so this is hardening rather than a live path off a stock install. The guard backs outbound subscription fetches, node sync, reality scan, the tgbot API URL and the xray setting test URL, which is reason enough to close the gap. The deprecated and local-use prefixes are blocked outright since nothing public routes through them. The NAT64 well-known prefix is judged by the IPv4 it embeds instead: on a DNS64 network every public IPv4 host resolves into it, so blocking it wholesale would break legitimate fetches. |
||
|
|
3c087f6fd9 |
chore(docs): update dependencies and adapt to zbsearch 4
fumadocs-core 16.14.5 switched its search engine from Orama to zbsearch 4,
so the panel docs follow it up to the same major.
zbsearch 4 still rejects locale codes as tokenizer languages ("en" throws,
only "english" is accepted), so the custom search dialog that forces an
English index stays necessary — verified by loading the built static index
for all four locales and searching it through fumadocs' own client.
Around that:
- use `staticClient`, as `oramaStaticClient` is now a deprecated alias
- drop @orama/orama, which nothing depends on or imports any more
- correct the two comments that still described Orama and pointed at its
docs and tokenizer package, one of them suggesting a language zbsearch
does not have
- restore the corepack integrity hash on `packageManager`, which CI reads
through pnpm/action-setup
- prune minimumReleaseAgeExclude entries for versions no longer installed
The API reference MDX changes are serialization-only: fumadocs-openapi
11.2.4 emits plain scalars where it used folded ones. Parsed frontmatter
and page bodies are unchanged.
|
||
|
|
ce63bf3e66 |
fix(frontend): restore the two rolldown bindings npm dropped from the lockfile
The from-scratch lockfile regeneration in
|
||
|
|
b9eda09da9 |
chore(frontend): update dependencies and adapt to oxlint 1.79
npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7 and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11, and npm would not move either. Neither npm update, a targeted install, nor --package-lock-only broke the cycle, so node_modules and package-lock.json were regenerated from scratch (601 packages, 0 vulnerabilities). oxlint 1.79.0 then promoted five React Compiler rules into the correctness category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree, so nothing in our code changed - the rule set grew. They are fixed rather than suppressed: - refs (31): latest-value ref writes moved out of render into an effect. onlineClientsRef turned out to be write-only and is gone; expireDiffRef and trafficDiffRef were replaced by reading the values directly. - set-state-in-effect (55): reset-on-open modals now adjust state during render; where an effect mixed a synchronous reset with an async fetch, the reset moved to render and the effect kept only the request. useMediaQuery became useSyncExternalStore. - preserve-manual-memoization (11): optional-chained deps the compiler cannot match, hoisted to locals or dropped where the memo wrapped a string concat. - purity (3): Date.now() in render replaced by a state-backed clock, which also refreshes the expiry tag every 60s instead of freezing it until the next unrelated re-render. - immutability (1): applyClientStatsEvent merged websocket traffic into DBInbound rows in place; it now rebuilds only the rows it touches. Two things fell out of that. clientCount is derived with useMemo instead of an imperative rebuildClientCount() called from five sites, which also fixes a staleness bug where changing the expiry or traffic threshold left the counts alone until some later rebuild. statsVersion existed only to force a re-render after an in-place mutation, is meaningless now that rows are replaced, and nothing read it, so it is removed. Also adds a lint:fix script - oxlint --fix was previously only reachable through the lint-staged hook. |
||
|
|
92fb94d856 |
Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint
TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.
Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.
Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.
oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.
The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.
Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.
Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
cast, which hid the optional chain from ESLint and would throw on a
null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
accessible but oxlint cannot evaluate it, so it gets a scoped disable.
* chore(docs): replace Prettier with oxfmt
oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.
The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)
The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.
.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.
oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.
* style(frontend): adopt oxfmt and format src
frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.
Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.
Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.
Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.
* ci: enforce formatting in CI and make verify
Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.
Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.
Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.
No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.
* ci: trigger CI on Makefile changes
The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.
* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components
`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.
Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.
Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.
Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.
* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard
Addresses the review on #6262.
The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.
The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.
The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.
Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
hand-written lint logic in the repo is no longer the least covered
file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
CI gate in this PR while the hook only ran the linter, so a commit
could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
and make msw-worker-check byte-compare stay safe even if oxfmt is
invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
both accept JSONC, so relocating them was unnecessary.
Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
|
||
|
|
1872659d83 |
chore(gitignore): fully ignore internal/web/dist, build stub included
The !internal/web/dist/.gitkeep exception kept the dist build stub permanently visible as untracked noise and let it slip into commits four separate times (each reverted with a 'drop the accidentally committed dist build stub' commit). Nothing under dist/ is ever meant to be tracked — make dist-stub and CI recreate the stub on disk — so drop the whole exception block and let the plain dist/ rule cover it. |
||
|
|
6638ac4a1e |
i18n: translate importKeepHostSettings keys
Translate the `importKeepHostSettings` and `importKeepHostSettingsDesc` keys from English placeholders into 10 locale files (ar-EG, es-ES, fa-IR, id-ID, ja-JP, pt-BR, tr-TR, vi-VN, zh-CN, zh-TW). Also removes `main_test.go` which contained a test for a `commandHelp` function. |
||
|
|
8cec47a8a5 |
fix(ci): resync the bot prompts with the repo and close the gaps an audit found
The three prompts still enforced the comment ban CLAUDE.md replaced with
the 2-line cap on Aug 1 (
|
||
|
|
b53a5515d6 |
fix(frontend): make the jalali expiry clear button actually clear
persian-calendar-suite seeds today's date and emits it whenever it mounts without a value. Clearing the expiry remounts the picker with a null value, so the library immediately fired onChange(today) and the date came straight back — and it also painted that seeded date into its read-only input. Swallow the mount-time emit (re-armed on every clear-remount) and hide the seeded text while the value is empty, so a cleared expiry stays empty and a fresh client/inbound form no longer silently adopts today as its expiry. |
||
|
|
f22df49a71 |
fix(sub): restore the subscription info page for browser visits
Revert |
||
|
|
338822ab07 |
fix(sub): keep copy page within mobile viewport
Constrain the copy-only subscription page to the dynamic viewport, wrap long localized text, and infer text direction so mobile browsers cannot render a horizontally shifted desktop-width page. Add regression coverage for the responsive layout contract. |
||
|
|
acbf09e710 |
fix(frontend): restore responsive table height
Remove viewport-capped vertical scrolling so page size controls the rendered table height and page scrolling remains responsive. |
||
|
|
03950b1295 |
fix(frontend): disable table virtualization
Removes the `virtual` table option from Clients, Inbounds, and Nodes list pages. This aligns table behavior across key admin views and avoids virtualization-related rendering/interaction issues with the existing scroll and pagination setup. |
||
|
|
d291e1c5ee |
Bump Go toolchain and x dependencies
Refresh the Go toolchain from 1.26.5 to 1.26.6 and update the related x/* and protobuf dependency set in go.mod/go.sum. This keeps the project aligned with the current patch releases and ensures the module graph matches the expected transitive versions. |
||
|
|
5b80d4562d |
chore(docs): bump docs dependencies
Update fumadocs-core/mdx/ui to 16.14.3, lucide-react to 1.31.0, @types/node to 26.2.0, typescript-eslint to 8.67.0, esbuild to 0.28.2, shiki to 4.4.3, and various other transitive dependencies. |
||
|
|
f3f57e66f5 |
fix(frontend): wait out Collapse fade before a11y scan in ConfigBlock story
Collapse animates opacity in over motionDurationMid; the Collapsed story's play function only waited for visibility, so the addon-a11y color-contrast check could sample a mid-fade, lower-contrast frame and fail flakily in CI. Wait for the panel's opacity to settle to 1 first. |
||
|
|
8a8da88548 |
fix(frontend): isolate swagger deps from main vendor chunk
Keep swagger-ui-react and its transitive dependencies in the lazy swagger chunk so the initial panel bundle stays smaller. This avoids eager loading the OpenAPI UI on first paint while keeping the API docs route unchanged. |
||
|
|
1f846c3cb2 | fix(frontend): clean test validation output | ||
|
|
1c255fc00c |
chore(frontend): bump npm dependencies
Refresh frontend package versions and regenerate the lockfile. This updates core UI and tooling packages including Ant Design, React Hook Form, Storybook, Vite, eslint/typescript-eslint, @noble/hashes, persian-calendar-suite, and swagger-ui-react to pick up the latest fixes and minor improvements. |
||
|
|
ece1655939 | fix(docs): prevent theme switch hydration mismatch | ||
|
|
cb902314db |
fix(docs): restore theme switch without runtime warnings
Move html/body shell and global css to root app layout to avoid hydration/script warnings from nested document nodes. Disable provider theme injection and add a custom script-free theme switch in shared layout slots. Also migrate docs search static client initializer to ZBSearch (initDB), add zbsearch dependency, and align docs lint tooling with ESLint 9 compatibility so npm run lint passes. |
||
|
|
3883882726 |
chore: bump frontend and Go dependencies
Updates multiple frontend packages (antd, react-hook-form, storybook, vite, swagger-ui-react, playwright, typescript-eslint, etc.) and Go dependencies (gopsutil, gorm postgres driver, pion/transport, ugorji/codec, genproto, and others). Also replaces `__dirname` with `import.meta.dirname` in vite.config.js for ESM compatibility. |
||
|
|
216d18b3c4 |
chore(vscode): fix Linux paths in the task and launch configs
The "go: build" task hardcoded bin/3x-ui.exe, so building on Linux produced a binary carrying a Windows extension. It now emits bin/3x-ui and keeps the .exe name behind a windows override. The Postgres launch config prepended C:\Program Files\PostgreSQL\18\bin to PATH on every platform. Linux separates entries with ':', not ';', so that string fused into the first real PATH entry and clobbered it. Moved it into a windows block, which is where the pg_dump/pg_restore lookups in ServerService need it anyway. |
||
|
|
2a8c3bc0db |
fix(clients): stop a stale IP row from blocking a client edit
Saving a client walks every inbound it is attached to and calls UpdateInboundClient, which re-keys the client's email in inbound_client_ips to the spelling in the edited settings. The email match is EqualFold, so when an inbound's settings JSON drifted in case from the client record the panel issues a case-only rename of the tracking row. inbound_client_ips.client_email is unique and case-sensitive, and the IP-limit job keys its rows on whatever casing Xray reports, so both spellings can already be present. The rename then aborts the whole edit with "duplicate key value violates unique constraint uni_inbound_client_ips_client_email" — the client could not be saved at all, including when only adding an inbound to it. The caller only renames onto an identity no live client holds, so a row on the target email is stale IP tracking: delete it before renaming. The blob is rebuilt by the next scan anyway. |
||
|
|
e71b75e99e |
docs(claude): correct enforced-guard claims and add the runtime dispatch rule
Fact-checked every line of CLAUDE.md against the tree. Six claims were wrong, and two told an agent the opposite of the truth. The file said nothing checks endpoints.ts against the Go routes and nothing fails the build on a missing i18n key. Both guards exist and both run in make verify: TestRouteRegistryContract diffs the real router against the registry in both directions, and i18n-dead-keys.test.ts rejects a locale that misses an en-US key as well as an en-US key nothing references. An agent trusting the old text either skips a step it thinks is unenforced or is blindsided when a "silent" omission turns the suite red. The rest: the Go locale returns an empty string for an unknown key, not the raw key; mtg-multi is a prebuilt binary fetched at build time, not a Go dependency built from source; commits are type(area): summary, not <area>: summary, and perf is in active use; make verify is the fast gate, not a mirror of CI, which also runs race, vulncheck, a live-Postgres job where a SKIP is a failure, and a fuzz smoke. Add the five facts most likely to burn an agent, all reproduced before writing them down. A fresh clone has no internal/web/dist, so go build dies on the embed pattern while thirty-odd packages pass — it reads as a broken repo rather than a missing make dist-stub. Every state-changing inbound/client op must dispatch through runtime.Runtime; a direct xray/api.go call passes all local tests and silently breaks every multi-node install, which is exactly what a hard rule is for. Node 24 is required because make gen imports .ts directly. Postgres, xray e2e and scale tests skip themselves without their env vars. An endpoint change has a fourth step nothing checks: syncing docs/public/openapi.json. Definition of done loses its first step — verify's gen-check already runs gen and fails on a dirty generated diff. |
||
|
|
5bc81dfd1d |
fix(node): stop the node sync from deleting clients it never meant to
A client that hit its quota or expiry was disabled, then destroyed on both panels a few seconds later. Five defects fed the same hard delete. ReconcileNode pushed buildRuntimeInboundForAPI, which strips disabled clients. Every other call site targets an in-memory Xray config, where dropping a user is harmless; a node target is a peer panel's DATABASE, so the node deleted the row, stopped reporting it, and the master mirrored that deletion back. Split the builder in two: buildInboundForNodePush injects fallbacks only, buildInboundForLocalRuntime adds the strip on top. The names now say which targets they are safe for. setRemoteTrafficLocked trusted a config_dirty the caller sampled before the snapshot round-trip. A client added inside that window commits on the same serialized writer and marks the node dirty, but the merge still treated the older snapshot as authoritative and deleted it. Re-read the flag inside the writer. In "selected" sync mode, FilterNodeSnapshot strips a deselected tag, but the sweep loaded every inbound with node_id set, so deselecting a tag read as "the node deleted it" and wiped an inbound the node still serves. Skip tags outside the node's managed set. A failed SyncInbound was logged and swallowed; on SQLite the transaction still commits, and the sweep then deleted the innocent clients whose links that failure had left unbuilt. Skip the sweep for such an inbound, and close the trigger: SyncInbound now stores the trimmed email it looks up by, and email validation rejects every unicode space rather than only U+0020. ClientService.Delete tombstones up front and deliberately keeps the record when an inbound fails, so the next attempt can retry the leftovers. The tombstone did not lift with it, so the next merge dropped the client from the synced settings and finished the deletion this path had refused. Add withdrawClientTombstones on every failure path, in BulkDelete too. Finally, make the sweep itself recoverable. "Ended the merge unattached" is true for a real remote deletion and equally true for a bad merge, so it now stamps sync_orphaned_at instead of deleting; any later merge that sees the client attached clears the mark, and a reaper removes only what stayed orphaned past the grace period. The traffic row survives that window too, or a reclaimed client would come back with its usage, quota and expiry reset. The mark is written by this sweep alone, so orphans from any other cause keep their existing manual-cleanup semantics. |
||
|
|
f4b7b08e08 |
fix(ldap): stop auto-delete from wiping every client on an empty directory
FetchVlessFlags returns (empty map, nil) whenever the bind succeeds but the search yields nothing usable — a renamed OU, a service account that lost read on the user attribute, a filter that stopped matching. The only guard on the destructive half of the sync was `err != nil`, so that answer was read as "every user is gone" and the job detached every client from the configured inbounds, once a minute, for as long as the directory stayed broken. Gate auto-delete behind autoDeleteSafeForFetch: refuse an empty fetch, and refuse one that collapsed below half of the last successful sync, which is a misconfigured directory far more often than real churn. Also stop splitCsv from defaulting an empty string to DefaultTruthyValues. That default belongs to the truthy-value setting, but splitCsv is also what parses ldapInboundTags, so an unconfigured tag list silently resolved to ["true","1","yes","on"]. It only ever bounded the blast radius by accident. |
||
|
|
1ff90c5b66 |
docs(claude): bound comment length, fix size, and test value
Three agent-facing rules, each written after the same mistake showed up in review. Comments were banned outright, which the codebase itself contradicts on almost every file — the ban pushed real invariants out of the code entirely. Allow them, but cap a block at 2 lines and spend those lines on the *why* a name cannot carry. Add a scope rule: the fix must be the smallest change that removes the root cause. A small bug does not earn new columns, jobs, abstractions or config; if it genuinely needs architecture, agree on that first instead of shipping it alongside the fix. Add two testing rules: a test must go red when its fix is reverted, and it must cover something that can actually break. A test that passes either way certifies nothing and is then cited as proof the fix works. |
||
|
|
c377dca27c | v3.6.0 | ||
|
|
c56f6447a8 |
chore: refresh dependencies and modernize Go test idioms
Frontend deps: @hookform/resolvers 5.4.3 -> 5.5.7, Storybook 10.5.4 -> 10.5.5 across the four packages we declare, globals 17.7.0 -> 17.8.0, and jsdom 29.1.1 -> 30.0.1. The jsdom major replaces its CSS and selector stack -- @asamuzakjp/css-color 5 -> 6, @asamuzakjp/dom-selector 7 -> 8, undici 7 -> 8, nwsapi and generational-cache folded into their parents, whatwg-url 17 nested underneath. Nothing in the Vitest suites reaches those directly and the whole frontend gate (typecheck, lint, tests, build, Storybook compile) is green. Panel frontend version to 0.6.0. Backend deps: mattn/go-sqlite3 1.14.48 -> 1.14.49 and valyala/fasthttp 1.72.0 -> 1.73.0, plus the golang.org/x/exp and genproto/googleapis/rpc indirect bumps that came with them. Go tests: modernize -fix output, covering range-over-int, sync.WaitGroup.Go in place of manual Add/Done pairs, maps.Copy, and Go 1.26 new(expr) for pointer-to-value in the forwarded-trust table. The storedAs helper is deleted instead of being left behind a //go:fix inline directive -- keeping it that way fails govet on the one call site the rewrite did not reach, and every caller now takes new(...) directly. Behaviour is unchanged. DnsTab: the hosts-sync effect tested dns while declaring dnsEnabled in its dependency array. Both carry the same truth value, so this is exhaustive-deps hygiene rather than a behaviour change. |
||
|
|
f52c3c4837 |
perf(clients): make the clients page scale to large panels
The clients page was slow on panels with many clients for two independent reasons: the server rebuilt the whole picture on every request, and the browser rebuilt the whole table on every poll. Server side, ListPaged loaded every client row, every client_inbounds link and every client_traffics row into Go memory, then filtered, sorted and paginated in a loop -- on a request the page repeats every five seconds. Every predicate now runs in SQL and only the requested page's ids are hydrated, so the cost tracks the page size rather than the client count. Measured on SQLite with a realistic status mix: the default view at 100k clients goes from 1,072ms to 64ms. Behaviour is preserved deliberately in the subtle places -- the cross-panel global-traffic overlay is folded into the same used-bytes expression the predicates and sort use, LIKE wildcards are escaped so a search for "a_b" stays literal, and the two different tiebreak rules the in-memory comparator had are reproduced per sort key. The summary's per-bucket email lists are capped at 200 with exact counters beside them. They only back hover popovers, but shipping every match made the response grow with the panel: at 100k clients it carried ~42k emails, and the page revalidated all of them through a strict Zod parse every five seconds. The popover now shows a "+N" chip for the remainder. Browser side, the page fired three sequential list requests per load and threw the first two away: the query went out before the persisted sort was applied, and again before the configured page size was known -- 0 meaning "one long page" is indistinguishable from "not loaded yet". The page size is now derived rather than mirrored through an effect, and the previous visit's value is remembered so the single request goes out at mount instead of queueing behind /setting/defaultSettings. Then the per-poll work. Reading isFetching made it a tracked property, so the refetch interval notified twice per cycle and re-rendered the page even when structural sharing left the data identical. Xray reports a traffic row per client whether or not it moved bytes, so the speed map was mostly zeros and was replaced wholesale every push; zero rows are now dropped and an unchanged result returns the previous object, which lets React bail out instead of re-rendering. The five Tooltip-wrapped buttons and the inbound chips per row do not depend on traffic at all and are now memoised, keyed on the email because a push replaces the row object of every client whose counters moved. antd's hashed:false drops 3,311 :where(.css-<hash>) wrappers and 29% of the generated stylesheet, and a pinned cssVar key stops each of the eleven page-level ConfigProviders minting its own token scope. Two callers that only need the mutations, GroupsPage and ClientBulkAddModal, no longer start the list query -- the groups page had been polling the full paged list every five seconds for data it never renders. |
||
|
|
1e2d6f6081 |
fix(ci): close the TOCTOU race in the conflict-resolution bot
The resolve-conflicts job runs on issue_comment, a privileged trigger: it holds GITHUB_TOKEN, the Claude OAuth token and the push PAT, and it checks out fork code with `gh pr checkout`. The only gate was that the commenter is the repository owner, which says nothing about the code that ends up in the workspace. A contributor could force-push to the pull request head between the owner asking for the merge and the runner fetching it, so the owner reviews one tree and the job runs another. Verify before anything is checked out that the head repository was last pushed to before the triggering comment was written, and refuse the run otherwise. Pin the head SHA reported by that check and abort if the commit `gh pr checkout` lands on differs, which closes the remaining window between the check and the fetch. Require author_association to be OWNER alongside the existing login comparison. This also clears CodeQL actions/untrusted-checkout/high, which for issue_comment triggers demands both an actor/association check and a comment-vs-head-date check dominating the checkout. |
||
|
|
c3fa73d5a0 |
feat(ui): redesign the overview page as a trend-first command deck
Replace the ten-small-cards overview with an action bar, four vitals
tiles carrying 72-sample sparklines seeded from /server/history, a
two-series throughput chart, a TCP/UDP connections chart, and a
grouped system strip (uptime xray|os, panel ram|threads, ip
addresses). StatusCard and XrayStatusCard are deleted; every modal
stays reachable from the action bar, the Xray error message moves
into a tooltip on the state pill, and the panel version text keeps
opening the update modal (the dev-channel switch lives there) even
when no update is available. Live values sit beside the
upload/download and tcp/udp legends, a health sentence appears only
when a vital crosses the shared warn/crit thresholds now exported
from models/status, and load average is left to System History.
The sidebar becomes an auto-collapsed 72px icon rail that expands as
an overlay on hover: rail width, brand-row height and menu paddings
are pinned so nothing shifts during the transition, the collapsed-menu
tooltips are disabled, hover state survives the per-page sidebar
remounts (with a matches(':hover') resync), and the manual collapse
trigger is gone.
Sparkline gains rgb()/rgba() support in its fill gradient, a
showLegend prop so pages stop reaching into its internals, and loses
a dependency-less repaint effect that doubled canvas paints. Chart
tooltips show clock time via the new TimeFormatter.formatClock;
accents come from theme tokens instead of status.cpu.color. Verified
by screenshot at 390/800/1150/1280/1400/1600px in light and dark,
en and fa-IR, plus programmatic geometry checks on the sidebar.
Locale files gain 8 keys and lose 9 dead ones across all 13
languages.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
fd17255f1d |
Revert "fix(sub): keep the client identity on every subscription link (#6098)"
This reverts commit |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
c004c18d90 |
fix(sub): keep the client identity on every subscription link (#6098)
|
||
|
|
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 (
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
4e928a1ce0 | v3.5.0 | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |