errcheck: explicitly discard io.Copy's error in the two fire-and-forget
relay goroutines -- a copy error there just means the connection closed,
which is the expected/normal way this loop ends, not something to handle
further.
noctx: net.DialTimeout must not be called per this repo's lint config; use
(*net.Dialer).DialContext with Timeout set instead, same as the rest of the
codebase already does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Manager.ensureLocked now attaches AttachTCPForwarder/AttachUDPHandler to
every Device it builds, relaying into that instance's own loopback SOCKS5
inbound automatically -- no caller needs to know relay.go exists at all.
Peer identity is re-looked-up via Manager.Lookup on every connection rather
than captured once at attach time, so a reconfigure-in-place (peers added/
removed without a full rebuild) doesn't leave the forwarder working off a
stale peer index.
Added TestManagerEnsureAutomaticallyWiresRelay: drives this through the
real Manager.Ensure entry point (not manual wiring like the existing
relay_e2e_test.go) against a real xray-core process, confirming the
automatic attachment and the port/password Manager derives internally
actually agree with what a real SOCKS5 inbound expects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hard cutover, part 1: injectAmneziawgnetSocks replaces injectAmneziawgEgress
as the AmneziaWG-side Xray config injector. Every enabled AmneziaWG inbound
now gets an always-on loopback SOCKS5 inbound (built by
amneziawgnet.SocksInboundSettings) instead of an opt-in dokodemo-door TPROXY
bridge -- there's no RouteThroughXray gate anymore since the embedded path
has no alternative datapath once traffic is decapsulated in gVisor. Reuses
the real inbound's own tag, same as before, so per-inbound stats totals
keep matching.
internal/amneziawgnet gains SOCKSPortForInbound (deterministic port
derivation, its own range distinct from the kernel-module bridge's) and
SocksPassword (a process-wide, lazily-generated, not-persisted password --
this traffic never leaves loopback).
port_conflict.go's port-reservation check is updated to match: the new
SOCKS5 relay port is reserved unconditionally for every qualifying
AmneziaWG inbound, not gated on RouteThroughXray.
Not yet done (tracked in the migration plan): swapping the actual manager
call sites (cron job, immediate-apply CRUD, shutdown) from the kernel-module
Manager to amneziawgnet's, and deleting the now-dead TPROXY/awg-quick code.
This commit could not be locally verified beyond internal/amneziawgnet
itself (this machine has no C compiler, so internal/database and anything
that imports it -- including internal/web/service -- can't be built or
vetted here); pushing for real CI feedback before continuing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
relay.go relays a recovered tunnel connection into Xray's own stock SOCKS5
inbound, authenticating as the peer's email -- the mechanism that gives
embedded AmneziaWG traffic real Xray stats/routing/sniffing with no
Xray-core fork. TCP goes through golang.org/x/net/proxy; UDP needed a
hand-rolled SOCKS5 UDP ASSOCIATE client since neither that package nor
xray-core's own internal socks client expose one.
Verified end-to-end against a real xray-core process (gated behind
XRAY_E2E_BINARY, matching internal/xray's own e2e test convention): a real
TCP and UDP round trip through the whole chain, plus real per-peer stats
counters in Xray's own log.
Xray-config auto-injection (a real SOCKS5 inbound wired into the generated
panel config) is deliberately not part of this commit -- it would require
deciding which AmneziaWG inbounds run on the kernel-module path vs. this
one, and that's an explicit later decision, not something to back into here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New internal/amneziawgnet package: builds a real amneziawg-go Device over a
gVisor netstack from an existing amneziawg.Instance, with a TCP/UDP
forwarder that recovers each tunnel connection's real destination and a
peer-identity index keyed by AllowedIPs. This is the foundation for
migrating AmneziaWG off the kernel-module+TPROXY path (see the AmneziaWG-go
vs kernel-module decision) -- nothing wires into live traffic yet, that's
Phase 2 (relay into Xray's own SOCKS5 inbound).
Covered by three real end-to-end tests: a genuine handshake + TCP forwarder
+ identity resolution, the same for UDP (including a reply routed back
through the tunnel), and the manager's reconfigure-in-place vs. rebuild
lifecycle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The shared link-tag/label helper (used by the client info modal, QR
modal, and subscription page) had no entry for the vpn:// scheme
AmneziaWG links use, so it fell through to the generic fallback: a
plain "Vpn" tag with no color, and an empty remark/port that made the
row's title fall back to "Link N" instead of the inbound's actual
name:port — unlike every other protocol, which shows its real tag and
label.
vpn:// links are base64url of a plain .conf text (matching the real
AmneziaVPN app's own share-link format), not a structured URL, so
there's no query string or #hash to read a remark/port from. Decode
the payload and pull the remark/endpoint back out of the .conf text
directly instead.
Fork-only file, invisible to upstream's own rename of
buildRuntimeInboundForAPI into buildInboundForNodePush /
buildInboundForLocalRuntime (part of the node-sync client-deletion fix).
Every other call site was migrated by that commit; this was the one
straggler, caught by CI after the 3.6.0 sync landed on main.
npm install to reconcile the lockfile with the merged package.json
(version 0.4.3 -> 0.6.0, plus the various dependency range bumps that
came in cleanly from upstream's own routine dependency refreshes).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Fixes several small issues found during code review:
- fix(xray): return explicit nil instead of stale err in getLogPath
- fix(xray): remove duplicate doc comment on GetErrorLogPath
- refactor: remove unreachable return after log.Fatalf (×4)
- fix(cli): add missing newline to listen IP success message
- fix(cli): typo "form" → "from" in migrate help text
- refactor: simplify var+assign to short declaration for server/subServer
- fix(controller): return error from getTwoFactorEnable instead of swallowing it
- Replace the mktemp+cp snapshot with a same-filesystem mv of bin/ aside:
an unchecked mktemp failure previously made the very next line copy
bin/'s contents into "/" (empty custom_bin_backup + trailing slash),
and a silently-ignored cp failure (stderr redirected, exit code never
checked) could leave a truncated custom geo file that gets "restored"
as if it were intact. A rename is atomic and needs no extra disk space,
removing both failure modes at once; if it fails, back off cleanly and
say so instead of proceeding as if a backup exists.
- Add a trap so an interrupted update (Ctrl-C, signal) between the
backup and the restore doesn't leave the snapshot (which contains
bin/config.json and every mtproto client's FakeTLS secret) sitting
around indefinitely; the two exit-path cleanups this replaces are gone
since the trap now covers those exits too.
- Move the restore below the arm arch-rename/chmod block instead of
before it, so xray-linux-arm32/mtg-linux-arm already exist under their
final names and don't get needlessly restored-then-overwritten and
misreported as "custom".
- Exclude bin/config.json and bin/mtproto/*.toml from the restore: those
are the panel's own generated runtime state (internal/xray/process.go,
internal/mtproto/manager.go), not admin-placed files, and restoring a
stale one only resurrects dead state or recreates bin/mtproto/ with the
wrong (more permissive) directory mode.
- Match symlinks in the restore's find, not just plain files -- cp -a
already preserves them in the snapshot, but the restore loop was
silently dropping them, which is exactly the failure mode (a geo file
symlinked in from elsewhere) this PR set out to fix.
- Quote the two new xui_folder expansions.
- Extend the non-interactive smoke test to reinstall over an existing
install with a sentinel file in bin/, asserting it survives and that
the bundled geoip.dat is still the release's own copy -- the update
path this PR touches had no CI coverage at all before this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 upstream PR branch's copy of this helper already wraps
QueryClientProvider (needed by any test rendering a component that uses
react-query), but this fork's own main never picked that up -- until the
just-ported rule-form-geodata-tags.test.tsx became the first test here to
render a component (RuleFormModal) whose hooks call useQuery, failing
immediately with "No QueryClient set". Backward compatible: all 12
existing callers pass unchanged since QueryClientProvider is a no-op for
components that don't touch react-query.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Replace parseGeodataFile's full proto.Unmarshal with a protowire-based
scan that reads only each entry's Code, skipping every Domain/CIDR
payload without allocating it -- the actual bulk of a real
geoip.dat/geosite.dat. Also caps the file read at 256 MiB.
- Hold geodataMu across the full scan-and-maybe-parse in
GetGeodataCategories instead of releasing it around the parse, so
concurrent cache misses (e.g. several browser tabs) can't all
independently re-parse every file; clone the cached slices before
returning them so a caller mutating its result can't corrupt the cache.
- Gate useGeodataCategories on the rule editor's own `open` state instead
of firing on every visit to the Routing tab.
- formatGeodataSuggestion now compares filenames with strings.EqualFold,
matching scanGeodataFiles' own case-insensitive match -- a file that IS
the default one on a case-insensitive filesystem (e.g. Windows) no
longer gets the long ext: form.
- Fix a real bug the review's hypothesis led to: Select mode="tags" only
commits the search text on Enter/comma, so clicking Save right after
typing (a blur, not an Enter) silently dropped the value entirely, with
no domain/ip key at all in the saved rule. Wrap it in a small
TagsAutocomplete that also commits on blur. Same autocomplete now
applies to sourceIP, which accepts geoip:/ext: too.
- Guard useGeodataCategories' fetch per-field with Array.isArray instead
of a single top-level `?? EMPTY_CATEGORIES`, since parseMsg returns the
original unvalidated obj (not null) on a schema mismatch.
- Test fixes: exact slices.Equal instead of slices.Contains-only
assertions, t.Run subtests, a cache-hit-skips-reparse test (via a
test-only parse counter), a returns-independent-slices test, a
file-size-cap test, and four new frontend tests covering the tags
round-trip including the blur-commit regression above.
- GeodataCategories now goes through the same generated-example path as
every other response type (StructAllow + example: tags + responseSchema
in endpoints.ts) instead of a hand-written response string. The
existing hand-written GeodataCategoriesSchema in schemas/routing.ts is
unrelated to this and is left alone -- CLAUDE.md is explicit that Zod
schemas under src/schemas/ are the source of truth and only the
generated example/openapi path comes from Go example: tags.
- Drop the two PR-illustration screenshots from media/ -- nothing in the
repo referenced them; they only ever needed to exist in the PR
description itself.
Not changed: leaving geodataFileKind's leak into generated/{types,zod}.ts
as-is. internal/web/service's openapigen request has no AliasAllow at
all, so every non-struct type in the package already leaks this way
(e.g. staticEgressResolver, transportBits predate this PR) -- scoping an
AliasAllow for the whole package is a real cleanup but a separate, wider
change than this PR's own footprint, and needs checking nothing already
depends on those existing generated aliases first.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The vpn:// share-link fix and the live-Speed-for-sidecar-protocols fix
(both shipped a few days ago) never got their changelog bullet despite
the fork's own standing rule to always document fork-specific changes
here. Also documents the bin/-preservation fix on install.sh, shipped
today and proposed upstream as MHSanaei/3x-ui#6152.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same fix as the upstream PR (#6105) review round: grepping
/etc/sysctl.conf for the setting name is unreliable -- many distros
split sysctl config across /etc/sysctl.d/*.conf, and /etc/sysctl.conf
can be a symlink into that directory, so the check can miss an
already-active setting or match a disabled/commented line, leaving
forwarding silently off either way. Query the live value via
`sysctl -n` instead. Applied to both the IPv6 and IPv4 checks.
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.
* fix(database): snapshot SQLite backups online
Use SQLite's online backup API for downloadable backups and SQLite migration exports instead of checkpointing then reading the live database file. The regression test validates a backup made while writes continue.
* style(database): group SQLite driver imports
* fix(database): bound online backup retries
Use a single backup step and a bounded connection-acquisition/retry context. Tighten temporary-file cleanup and regression assertions while removing the unused checkpoint helper.
* test(database): cover existing backup destinations
* fix(database): harden SQLite snapshot lifecycle
Sweep interrupted snapshot directories at SQLite startup, keep rollback-journal backups incremental, and make caller-owned cleanup explicit. Reuse one scheduled Telegram snapshot across administrators and make the direct SQLite driver dependency explicit.
---------
Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
* fix(sub): honor trustedProxyCIDRs before forwarded URLs
* fix(sub): avoid unused trust-setting lookups
Skip the trustedProxyCIDRs lookup when no forwarded header can affect a subscription URL. Keep the shipped proxy default in one exported setting constant and document the subscription-link behavior for custom proxy boundaries.
* fix(frontend): meet config text contrast requirements
Keep compact configuration text readable in the light theme and satisfy the Storybook accessibility check.
---------
Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
* fix(xray): synchronize lifecycle snapshots
Protect process replacement and result caching with a lifecycle state object, so read paths keep one process snapshot while restarts swap state safely. Bound version probing to prevent a stalled binary from holding the restart lock.
* test(xray): cover concurrent lifecycle reads
Exercise status, result, and traffic reads while the managed process is replaced, so the race detector guards the lifecycle snapshot boundary.
* fix(xray): guard process config snapshots
Synchronize hot-applied config snapshots, keep Telegram reads on one lifecycle snapshot, and strengthen lifecycle timeout and concurrency regression coverage.
---------
Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
* fix(sub): coalesce external subscription refreshes
Limit concurrent cache misses to one upstream request per URL and evict the oldest entries once the cache reaches its bounded capacity.
* fix(sub): preserve shared stale refresh results
Release every in-flight waiter on panic or error, carry the leader outcome to waiters, and strengthen cache capacity and stale fallback coverage.
---------
Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
* fix(mtproto): synchronize child-process state
Use lifecycle snapshots around the mtg command, completion signal, and exit error so Wait cannot race status and shutdown reads.
* test(mtproto): cover concurrent process exit
* test(mtproto): cover lifecycle field synchronization
---------
Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
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.
* feat(ui): tag settings that sit at their shipped default value
A field showing 2096 reads identically whether the install never set
it or the operator saved 2096 — newcomers cannot tell which knobs
they have touched, and after the cleared-port fix (#6121) a port can
never visually return to an unset state. Add a small grey tag next to
numeric settings whose current value equals the shipped default.
The tag deliberately compares values, not provenance: a stored 2096
and a fallback 2096 behave identically, so they read identically, and
the tag reacts live as the user types.
The backing endpoint filters defaultValueMap through the AllSetting
field set, so per-install material (secret, panelGuid, node mTLS
keys) and redacted credential fields never leave the server; a test
pins that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): keep the default tag out of the accessible name, pin the defaults contract
From review, in order of severity:
The badge was rendered inside the element whose id feeds the
control's aria-labelledby, so a visible tag changed every field's
accessible name ('Panel Port Default'). The title text now carries
the id on its own span and the badge sits beside it.
The same default values live in three places: the Go defaultValueMap,
the frontend AllSetting class, and the tag's verdict. A new contract
test parses the Go map's string literals and asserts every shared key
matches the AllSetting class default through the tag's own
comparison — and on first run it caught two real drifts
(tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but
'login.attempt,cpu.high' on the server), now aligned.
matchesFactoryDefault no longer coerces blank or unparsable defaults
(Number('') is 0; a junk string is not false). The Go tests are
table-driven t.Run subtests and gained the structural invariant:
every returned key is an AllSetting json tag outside the credential
deny-list. The service doc comment now describes the projection
mechanism instead of overclaiming; the i18n key is re-indented and
placed at the head of pages.settings in all 13 locales; the fetch
falls back to {} when validation fails; and smtpPort gets the tag so
plain numeric settings-list fields are covered uniformly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages
Follow-up promised in #6127's review thread: the settings and xray
pages write numeric changes straight into state, so a regressed
handler silently ships the cleared-port bug again. A scoped
no-restricted-syntax rule now rejects Number(...) || N inside an
onChange attribute in those directories, pointing at onNumber().
The one remaining match, the Telegram notify interval, moves onto the
helper with its floor intact: clearing now keeps the stored count
instead of writing 1, and Math.max still clamps typed values. Form
modals that stage values behind Zod keep their deliberate
clear-means-zero semantics; the rule deliberately does not apply
there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(lint): widen the numeric-clamp guard to the shapes that actually drift
From review: the rule matched only the Number-or-literal shape, while
two semantically identical ternary sites already lived inside its own
directories, so 'zero suppressions' reflected the selector's
narrowness rather than a clean subtree. The rule now catches the
ternary typeof form and the nullish-coalescing form too, is anchored
to InputNumber elements so its message can never point a ChangeEvent
handler at a number-typed helper, and documents the extracted-handler
shape it cannot see.
The xray form modals stage values behind Zod like the clients modals
do, so a follow-up config object exempts them explicitly instead of
the comment claiming they were never in scope.
BasicsTab's Happy Eyeballs try-delay — the one genuine direct-write
ternary — moves onto onNumber: clearing keeps the stored delay
instead of writing 0, and 0 stays reachable by typing it. The
Telegram interval gains precision={0} so a typed decimal cannot
compose an @every value its own parser rejects on reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(build): stop shipping production sourcemaps inside the binary
Everything under internal/web/dist is embedded into the release
binary via embed.FS, and sourcemap: true put 112 .map files — 18MB,
72% of dist — inside every build users download. Nothing consumes
them there: the panel never references them and npm run dev serves
its own maps regardless of this flag. dist drops from 25MB to 6.7MB;
flip the flag locally when a production bundle needs debugging.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(build): gate production sourcemaps behind XUI_SOURCEMAP
From review: hard-coding false made the documented debugging path an
edit to a tracked file, and the XUI_DEBUG serve-from-disk flow lost
maps with no zero-diff way back. XUI_SOURCEMAP=true at build time
restores them; the default stays off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(web): pin the endpoints.ts registry to the actual Gin routes
endpoints.ts is a hand-maintained registry and nothing checked it
against the router: an omitted API route silently vanishes from the
generated OpenAPI docs, and an entry for a removed route documents an
endpoint that 404s. Two new tests construct the real router against a
throwaway DB and diff the /panel/api surface both ways.
The check found one gap on arrival: GET /panel/api/openapi.json — the
endpoint that serves the docs — was itself undocumented. Registered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api)+test: authenticate openapi.json, fold the two route-contract tests into one
Three things from the review, in severity order.
The bot found that GET /panel/api/openapi.json was registered on the
base-path group one line before the /panel/api group installs
checkAPIAuth, so Gin's snapshot of the parent chain meant the whole
admin API surface plus build version was fetchable without a session
— while this very PR was about to document it as auth-required. Move
the registration inside the authed api group. Verified: unauthenticated
it now 404s exactly like server/status (was 200), and a logged-in
session still serves it 200, so the docs page is unaffected.
The existing api_docs_test.go already checked the forward direction by
regex-scanning controller source against a hand-maintained per-file
path switch — which is why it missed this web.go-registered route, and
whose fall-through default silently mis-paths any unlisted controller
file. The new router-based test is a strict superset, so fold in the
extra surface it guarded (/login, /logout, /csrf-token,
/getTwoFactorEnable, /ws) and delete the old test rather than run two.
Harden the endpoints.ts parser: pair each method with the next path
sequentially instead of a brace-crossing regex, and fail loudly when
the parsed count doesn't match the declared method fields. Construct
the server once across both subtests, cancel it, and restore the
previous global on cleanup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(i18n): delete 230 dead translation keys and guard against new ones
The 13 locale files carried 230 keys (11% of the set) that nothing in
the frontend or Go sources references — leftovers of renamed features
(the email notifier reuses tgbot.messages.* for subjects, the old
email.subject*/title* set was orphaned; likewise menu.*, the clients
bulk-copy strings, and the secAlert* family). Nothing detected this:
a missing key falls back to en-US and an unused key fails nothing.
A new test now fails the build when an en-US key has no reference in
frontend/src or internal Go sources (dynamic keys are covered by
harvesting concatenation and template-literal prefixes), and pins
that all 13 locales carry exactly the en-US key set, so parity drift
surfaces at test time instead of as a silent fallback.
Each locale shrinks by the same 230 keys; net -2,900 lines across
the translation set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): restore the 29 live remarkVars keys, match whole tokens, unmask 9 more
From review: the template-literal harvester required the prefix to end
on a dot, so pages.hosts.remarkVars.desc${token} harvested nothing
and all 29 desc* tooltip keys were wrongly deleted — and the guard
shared the flawed logic, so CI stayed green while the Hosts page
would have shown raw key names in 13 languages. Restored from the
parent commit; the harvester now requires at least one dot but not a
trailing one.
Also from review: references are matched as whole dotted tokens
instead of substrings (a dead key can no longer hide behind a longer
sibling — that unmasked 9 more genuinely dead keys, each verified by
hand before deletion), and the test excludes itself from the scan so
its own prose cannot whitelist a subtree.
Net: -210 keys per locale instead of the previous -230.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Same 8 findings fixed on upstream-pr/amneziawg, ported here since this
fork's internal/amneziawg + related web/service files predate that PR
branch's own fix-up commits:
1. hostRulesFingerprint now folds in a peer's IPv4 whenever
ForwardedPorts is set, not only when RouteThroughXray is on, so a
re-IP forces the bounce needed to move the DNAT rule too.
2. ValidateConfigValue (new, params.go) rejects control characters in
server/client keys, email and I1 at save time; sanitizeConfigValue
strips them defensively at .conf-render time.
3. checkForwardedPortsConflict now scopes to node_id IS NULL and takes
a pre-loaded portConflictContext (loadPortConflictContext), so a
port used only on another node isn't a false collision and an
inbound with N clients costs one query instead of N.
4. PostDown commands are now best-effort (appendOrTrue) so an external
firewall flush can't abort the rest of the teardown chain.
5. The "ip rule list | grep -q" existence check now uses
grep -c >/dev/null, avoiding a pipefail/SIGPIPE false negative that
could re-add a duplicate rule.
6. route_egress.go's stale "always present, no opt-in" comment
corrected to describe the real RouteThroughXray-gated behavior.
(This fork's genAmneziaWGLink already emits vpn://, and there's no
upstream-facing docs page here, so neither needed the PR branch's
Finding 6 docs/link-format changes.)
7. install.sh: Arch's ndppd install uses pacman -Sy, not -Syu, matching
every other pacman call in the script; should_install_amneziawg
short-circuits to yes when awg is already installed, so `x-ui
update` doesn't re-prompt -- this fork's own opt-out-by-default
philosophy for should_install_amneziawg is unchanged, only the
redundant-reprompt behavior is fixed.
8. CollectTraffic checks pointer identity before writing back a
traffic-counter baseline, so a concurrent restart's freshly-reset
(empty) baseline can't be clobbered by stale pre-restart counters.
sweepOrphansLocked no longer permanently disables itself on a
transient os.ReadDir failure.
go build/vet/test and frontend typecheck/lint/build/vitest all pass.
* fix (install.sh): use realpath instead of script name
###Description:
During arch() sctipt tries to delete itself in case no compatible arch found. This may lead to unexpected file deletion if executed outside root dir; also cur_dir is declared but doesn't seem to be used anywhere
###Way to reproduce:
```bash
cd "/some/other_dir_with_install_sh"
/3x-ui/project/dir/install.sh
```
* fix(install): quote the script path before the self-delete
realpath was handed an unquoted $0, so a script living under a path that
contains spaces was split into several arguments: realpath printed a
partial path plus an error, and rm -f then targeted a name matching
nothing at all. The unsupported-arch branch silently kept the script it
means to remove — the very case the surrounding fix exists for.
* feat(ui): validate the REALITY client version range at save time
The impossible range from PR #6125 — a max below the effective minimum
— could still be saved; the tooltip only helps a user who hovers it.
Add save-time validation mirroring xray-core's parser (up to three
dot-separated parts, each 0-255) on both fields, plus a cross-field
check that a non-empty max is not below a non-empty min. Errors are
field-level i18n keys following the REALITY target precedent, so the
modal stays open and points at the offending field instead of storing
a config that rejects every client.
A malformed min is reported by its own field and skipped by the max
comparison, so the user sees one precise error per field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): reject untrimmed client versions and revalidate max on min edits
From review: the validators trimmed but the save path ships the value
verbatim, and xray-core's part parser accepts no surrounding
whitespace — so a green form could still save a config the core
refuses to load. Reject any value that differs from its trimmed form.
Also revalidate the max field after a min edit when max already
shows an error, so correcting the min clears the stale cross-field
message without waiting for the next submit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor(ui): share one onNumber handler for numeric setting inputs
The Number(v) || 0 idiom in InputNumber onChange handlers is the root
pattern behind the cleared-port bug (#6121): AntD reports a cleared
field as null, and || 0 turns that into a stored zero or a min-clamp.
The port fields got an inline null-guard; the other sixteen numeric
settings kept the idiom, so every new field is a chance to
reintroduce the bug.
Extract the guard into onNumber(apply): null, empty and NaN change
events are ignored so a cleared field snaps back to its stored value
on blur, and numeric events pass through unchanged. Convert all
sixteen sites in the settings and xray pages. Two sites keep their
deliberate different semantics: smtpPort falls back to 587 on clear,
and the Telegram notify interval clamps through Math.max.
For the non-port fields this changes clearing from storing 0 to
keeping the stored value; zero remains reachable by typing it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ui): fold the remaining hand-rolled numeric guards into onNumber
From review: ObservatorySettingsTab's sampling field hand-rolled the
same ignore-null semantic and smtpPort kept a fallback-to-587 on
clear that nothing documents as intentional and that silently
overwrites a configured non-standard port — both now go through the
shared helper, leaving the Telegram interval clamp as the one
deliberate exception.
Also from review: narrow the helper to numbers only (no stringMode
input exists in the repo, and the string branch codified a guarantee
the number-typed callback cannot honour), soften the docblock to
describe behavior rather than promise prevention, add a GeneralTab
component test covering the clear-vs-typed-zero semantics, and assert
the blur snap-back in both settings tests so a display/state desync
cannot ship unnoticed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sub): omit hyphen for empty remark variables
The default INBOUND-EMAIL template left a leading hyphen when an inbound had no remark after display remarks became template-driven in b0c1156dd. Treat a hyphen between adjacent variables as their separator and drop it when it would lead the output or when the value after it is empty, so an empty variable in the middle of a template still leaves a single separator between its neighbours. Literal leading hyphens written into the template are preserved.
* fix(sub): elide the remark separator after leading decoration
The separator between two adjacent tokens was kept as soon as any text had
reached the segment, so a template opening with decoration still rendered
"🌐-john" for an inbound with no remark. Track whether a token has produced
a value rather than testing the accumulated output, so the hyphen is elided
for any prefix that carries no token value of its own, and the builder is no
longer rescanned once per token.