claude-bot.yml ran three jobs: the pull-request review, an @claude mention
responder, and a conflict resolver that committed and pushed to contributor
branches. Only the review is wanted, so the other two are gone and the file
is renamed to say what is left.
Consequences worth knowing:
- secrets.CLAUDE_BOT_PAT is no longer referenced by any workflow. It was the
only push credential handed to an agent in this repository and can now be
deleted from the repository settings.
- @claude goes unanswered everywhere. claude-issue-analyst.yml deliberately
excludes mentions (!contains(body, '@claude')) so the two jobs would not
both reply; with the mention job gone, only `@claude review` on a pull
request still reaches anything. Dropping that clause from the analyst would
restore mention answering on issues.
- The workflow display name changes, so a branch protection rule keyed on
"Claude Bot / review" has to become "Claude PR Review / review". The job
name, which is what statusCheckRollup reports, is unchanged.
The review job itself is byte-identical. The workflow-level permission drops
to issues: read, which is all the remaining job needs - it already declares
its own.
`grep -ci amneziawg` returned 0 in both .github/claude/repo-context.md and
REVIEW.md while CLAUDE.md has carried the protocol for releases. The issue
analyst and the review bot could not name internal/amneziawg/,
internal/amneziawgnet/ or internal/pia/, and the mention job's inline map
enumerated ten protocols with amneziawg missing from the list.
The 3.1 obfuscation parameters are generated twice - GenerateObfuscation31 in
internal/amneziawg/params.go and generateAwgObfuscation in
frontend/src/lib/xray/amneziawg-obfuscation.ts - so REVIEW.md now names that
pair as a divergence surface next to the three link implementations. Commit
bd1c27b0 was already a bug in exactly that pair.
Also corrects the CLAUDE.md CLI list, which omitted encrypt-tokens.
Creating or attaching a client across N inbounds called AddInboundClient
once per inbound, strictly one after another. When those inbounds live on
different nodes each call is a full node round-trip bounded by the 10s
remote timeout, so the request cost the SUM of every node's latency: two
nodes felt instant, three took ~13s and timed out bot callers, which is
how it surfaced as "two out of four account creations fail".
Split the per-inbound preparation from the apply. Preparation stays
ordered and single-threaded because fillProtocolDefaults mints the shared
credentials on the first inbound and every later one reuses them; the
applies then run concurrently, capped at inboundFanoutConcurrency. A
4-node create measured 1.205s -> 0.307s with peak overlap 1 -> 4.
Consequences of no longer aborting at the first failing inbound:
- Every apply error is tagged with its inbound and the failures are
joined, so all of them reach the caller instead of just the first.
- The fanout goroutines recover their own panics. Off the request
goroutine gin's Recovery no longer covers them, and an unrecovered
panic would kill the panel rather than fail one inbound.
- A partly-applied call commits clients on the inbounds that succeeded,
so the controller and the LDAP job now read needRestart before the
error check; otherwise Xray was never flagged for the work that landed.
- limitHwid is applied only when every inbound succeeded. Applying it
after a failure rewrites limit_hwid and trims the registered devices of
an email that already existed, which is silent data loss on an
operation the panel reported as failed.
Update the API docs for the new partial-application contract and the
inbound-tagged error strings.
The race job failed with "panic: test timed out after 10m0s" in
internal/web/service (FAIL at 600.106s) while every other package passed
and the non-race go-test job ran the same package in 57s.
Nothing hung. The race detector costs this repo ~8.5-10x (internal/database
8.4s -> 73s, internal/sub 17.8s -> 149s), and internal/web/service has 671
tests, ~40 of which each pay a full InitDB + AutoMigrate. That puts it right
on go test's 10-minute default per-package timeout: the last four race jobs
finished in 10m10s-10m28s before this one crossed the line.
Pass -timeout 25m in ci.yml and `make race` so the largest package has real
headroom while a genuine deadlock is still bounded. Verified locally:
ok internal/web/service 265.425s, 658 tests, no data races.
Update frontend dev tooling to Vitest 5 by bumping `vitest`, `@vitest/browser-playwright`, and `@vitest/coverage-v8`, plus `@types/react-dom`. Add an override for `@storybook/addon-vitest` to pin Vitest-related packages to compatible versions and avoid dependency mismatch issues. Also bump the Go toolchain patch version from `1.27.0` to `1.27.1` in `go.mod`.
The sync adopts each node's reported clients through SyncInbound, which resolves
a client record by email alone — and clients.email is globally unique. A node
reporting a colliding email therefore overwrote that client's UUID even when the
client is attached only to a master inbound, and the master then rebuilt its own
Xray config with the node-supplied credential: the real user locked out.
Skip a reported client whose record is attached only to inbounds of other nodes.
A record attached nowhere stays adoptable, so the soft-orphan reattach path a
flapping node depends on is unaffected.
subJsons and subClashs served the raw body and returned before enforceHwid ran,
so appending ?view=raw to a JSON or Clash subscription URL handed out a complete,
client-consumable config however many devices were already registered. The branch
exists to stop a browser's Accept: text/html from being answered with the info
page, not to skip the gate.
Gate the raw branch and leave the other gate where it was, below
maybeServeSubPage, so the HTML info page stays ungated as before.
resolveXrayLogPaths looked the log object up by the exact keys "access" and
"error", but xray-core decodes that object with encoding/json, which falls back
to a case-insensitive field match. "Access": "/tmp/pwn.log" therefore reached
AccessLog untouched and Xray — root, in a standard install — created the file
there, reopening the arbitrary write that GHSA-jm48-m3rr-9hgg closed.
Fold every case variant onto the canonical key before confining it. When both a
canonical key and a variant are present the canonical value wins, so a
"none" cannot be overridden by a smuggled "Access" path.
Ten review runs fired in under two hours on 3 September and every one after
11:25 came back rejected: the five-hour usage window was at 100 percent
(overageStatus rejected, org_level_disabled) while the seven-day window sat at
29. Two of them reviewed the same head SHA and one pull request was reviewed
four times, because a draft/ready toggle re-fires pull_request_target and the
skip decision is only reachable after a full checkout and a model boot.
Settle it in the workflow instead: a bot comment carrying "Reviewed head:" and
the pinned SHA means this head is done, so the pr-head checkout, the brief and
the action are all skipped. An explicit "@claude review" is exempt, so a
maintainer can still force one.
A refused run also failed the job twice over - the action's exit 1 plus "the
review posted nothing" - with nothing on the pull request to say why, which
reads as a broken bot rather than an exhausted budget. The job now classifies
its own transcript: a rejected rate_limit_event, or a 529 that survived every
retry, posts one line on the pull request and stays green. Anything else still
fails loudly.
Also tightens that check, which counted ANY bot comment quoting the head SHA as
a legitimate skip; the conflict-resolution job quotes SHAs too, so a dead run
could go green on one.
The `suggestion` blocks stopped once the briefing moved into its own file, but
the carve-out that survived — "one clause naming where the fix belongs" — was
being stretched from a location into an instruction. #6397 dictated what to
write in a comment and which existing test to copy; #6394 named the fix
outright. The clause now permits a file, a function, a symbol or a layer and
nothing about what happens there, and closes the stretch three ways: prose is
a patch the moment a verb describes the change, so is holding up an existing
symbol as the model to copy, and a clause the maintainer could apply as
written is the fix however it is punctuated.
Three rules the rubric was missing, none of which existed anywhere. A 🔴 or 🟡
says in one clause what the change did to the code it is about, the way a 🟣
already says it predates it — otherwise nothing in the comment shows the
marker was earned. A claim about a caller or a callee needs that file read:
the dispatch-rule violation this repo cares most about sits a frame outside
the diff, and the skill is told to avoid reading past the changes. And nothing
pads the comment.
The briefing's one named override aimed at a step that does not exist. The
plugin the job loads defines no `--comment` flag and mentions suggestions
nowhere, so `max --comment <target>` is inert trailing text. Replaced with the
six overrides that are real: the skill calls pre-existing issues and unmodified
lines false positives, drops every finding its confidence pass scores under 80
and then posts nothing at all (a nitpick scores 50, so that filter empties all
five nit slots), says to avoid emojis against a severity system that is three
of them, mandates a "Found N issues" format, and forbids reading build signal.
Refresh the Go module set in go.mod and go.sum to newer patch/minor releases, including xray-related dependencies, gRPC, WireGuard, and supporting indirect libraries. This keeps the project aligned with upstream fixes and compatibility updates without changing application code.
Update dependency versions across `docs` and `frontend`, including Next/Fumadocs packages in docs and Ant Design, React Query, Storybook, and related tooling in frontend. Also updates lint/format tool versions (`oxlint`, `oxfmt`), bumps docs `pnpm` package manager version, and refreshes workspace release-age exclusions for the newly upgraded docs packages.
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.
Routine dependency refresh: telego 1.11.2, go-sqlite3 1.14.50, grpc 1.83.1,
miekg/dns 1.1.73, sing 0.8.14 and the usual indirect churn on the Go side;
react-query 5.102.2, i18next 26.4.0, react-hook-form 7.86.0, Storybook
10.5.10 and vite 8.2.2 on the frontend, which also lifts the private frontend
package to 1.0.0.
That left npm run lint:deprecated with five call sites. Zod 4 deprecates the
ZodTypeAny alias in favour of the bare z.ZodType constraint, and react-query
renamed queryClient.fetchQuery to queryClient.query ahead of removing the old
name in the next major — the two share an implementation, so the swap in the
settings test is behaviour-identical.
Also untracks internal/web/dist/.gitkeep. 1872659d dropped its gitignore
exception on the grounds that nothing under dist/ is ever meant to be
tracked, but the file was already in the index, so the rule never applied and
every frontend build that empties dist/ resurfaced it as a spurious deletion.
make dist-stub and every CI job recreate it on disk.
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
SanitizePublicHTTPURL rejected a hostname as soon as any single resolved
address was blocked, so a resolver returning a bogon AAAA for the test URL
host (e.g. 2001::1 for www.google.com, inside the Teredo range blocked
since b51f0976) failed the outbound Check button outright — including TCP
mode, which never uses the test URL. Mirror SSRFGuardedDialContext instead:
one usable address is enough, because the guarded dialer skips blocked
answers at connect time; a hostname with nothing usable is still refused.
Closes#6290
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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).
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.
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.
The from-scratch lockfile regeneration in b9eda09d bumped rolldown 1.2.4 ->
1.2.5 but wrote back only 14 of its 16 optional platform bindings: npm
removed the old @rolldown/binding-darwin-x64 and
@rolldown/binding-linux-arm64-gnu entries and never added the 1.2.5 ones.
Both are still listed in rolldown's optionalDependencies, so the packages
section no longer matches the dependency graph.
npm ci validates the whole ideal tree, not just the packages installable on
the current platform, so it aborted with EUSAGE everywhere and took down all
four workflows that install the frontend - CI, Release, CodeQL and Docs
Deploy - each at its first npm ci step. The Go jobs were unaffected.
Regenerated with a clean npm install --package-lock-only, which resolves from
registry metadata alone and keeps every optional binding regardless of the
host platform. The diff is purely additive - the two missing blocks, no
version changes.
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.
* 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.
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.
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.
The three prompts still enforced the comment ban CLAUDE.md replaced with
the 2-line cap on Aug 1 (1ff90c5b), so the review bot would flag every
legitimate short comment; frontend/CLAUDE.md and CONTRIBUTING.md carried
the same stale rule. The PR reviewer's recipe for reading a post-change
file (headRefOid + pr diff) was unfulfillable with its allowlist - it now
fetches refs/pull/N/head and reads blobs via git show, object-only, no
checkout. Conventions the reviewer checks now include the unchecked docs
openapi.json copy step, the docs/lib/xray third link implementation, the
both-ways route contract, and the i18n dead-key half of the rule.
Also: drop the SUBPROCESS_ENV_SCRUB=0 override on the two untrusted-input
jobs (the mention job proves gh works scrubbed); teach the triage prompt
the issue forms (pre-applied labels, required fields, no re-asking); add
a security-report exception plus SECURITY.md so vulnerabilities are not
confirmed publicly; add a clarification follow-up job so a reporter's
reply to "clarification needed" is actually processed; review PRs again
on ready_for_review and skip drafts; stamp the reviewed head SHA so
force-pushes visibly date a review; scope gh issue/pr edit to label and
title flags; per-job concurrency; comment guards now match the actual
bot login after the run started; artifact names survive re-runs; the
mention prompt's repo map and env-var facts corrected (XUI_PORT,
XUI_TUNNEL_HEALTH_*, distro env files, memory.high, encrypt-tokens).
The bug and feature forms also referenced a "needs triage" label that
does not exist in the repo and was silently never applied - dropped.
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.
Revert 43bc9153 and its follow-up 338822ab. The copy-only notice replaced the
themed sub page for every browser request, so mobile users got a bare "This is
a subscription link" screen instead of their traffic, expiry and links — and it
left serveSubPage plus the custom-theme renderer as dead code.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
This reverts commit c004c18d90.
Showing {{EMAIL}}/{{USERNAME}} on the first subscription-body link only is
intentional, not an oversight in 876d55f2. Restoring the behaviour and the
tests that pin it.
Making the identity tokens configurable is the sanctioned route for the
operators asking for them on every link (#5935), rather than flipping the
default for everyone.
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.
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.
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.
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.
876d55f2 put EMAIL/USERNAME in the same first-link-only bucket as the usage
tokens, so a client attached to several inbounds got its email on whichever
inbound sorted first and bare inbound names on all the rest. With the shipped
default template ({{INBOUND}}-{{EMAIL}}|...) that makes every profile after
the first indistinguishable between clients — the point of the token.
The two are not alike: the usage block repeats identical numbers on every
link, while the identity is what tells one imported profile from another.
Restore identity on all body links and leave usage first-link-only.
Reported again in #6029 and #5659, which asked for the same revert.
client_global_traffics rows are keyed by (master_guid, email) and are only
ever overwritten by a push from that same master. A master that stops
pushing — decommissioned, reinstalled under a fresh GUID, or detached from
the node — therefore leaves its last snapshot behind permanently.
depletedClientsCond's cross-panel EXISTS branch matched any such row, so a
node kept comparing a client's quota against counters frozen weeks earlier.
Once they exceeded the quota the node disabled the client on every traffic
poll, and the node -> master enable merge latched that off on the master too,
where nothing sets it back. The reported symptom is exactly this: a client at
11 GB of a 24 GB quota, enabled on two nodes, disabled on the third, which
still held a 27-day-old row from a previous master reporting 30 GB.
Bound both the enforcement predicate and the display overlay to rows a master
refreshed within globalTrafficFreshWindow. Masters push every 30s, so a live
master is never affected; a master that is merely unreachable for a while
keeps enforcing for a full day before its numbers are set aside.
The one-way enable merge that makes such a disable permanent on the master is
deliberate (12d84c2a, #4917) and is left alone.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The subId collision check in Update ran on every save, unlike the email
check above it. Because Update defaults an omitted subId to the stored
one, any client already sharing a subId was rejected on every later edit
-- even a pure totalGB or expiry change that never mentions subId.
Gate the check on an actual change. Pre-existing duplicates are reachable
because SyncInbound has no such check, and 88a36773 meant to leave them
untouched. Editing a client onto another subscriber's subId is still
rejected, so the typo guard is intact.
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.
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.
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.
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.
* fix(xray): block private-range egress in default freedom finalRules (#6037)
With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.
Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.
* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)
Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.
Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.
* fix(xray): resolve geodata assets before building outbound configs (#5928)
Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.
Call ensureXrayAssetLocation before both build paths.
* fix(api): use a real i18n key in the client get handler (#5911)
The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.
* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)
The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.
Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.
* fix(metrics): accept Unicode outbound tags in the observatory (#5972)
The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.
Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.
* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)
With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.
Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.
* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)
validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.
Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.
* fix(xray): stop deleting hand-written direct routing rules on save (#6056)
The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.
Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.
* fix(clients): resolve email lookups through client_inbounds after a move (#6059)
GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.
When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.
* fix(nodes): replicate inbound fallbacks to nodes (#5963)
Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.
Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.
* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)
A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.
Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.
* fix(xray): force a full restart when REALITY stream settings change (#6010)
A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.
Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.
* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)
An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.
Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.
* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)
The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.
Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).
* fix(frontend): default sockopt fields when editing a stored inbound (#5956)
Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.
Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
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.
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.
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.
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.