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.
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.
Domain-based Routing rules could never match RouteThroughXray traffic: an
AmneziaWG peer resolves DNS itself, through the tunnel, before ever sending
a packet, so the decapsulated traffic TPROXY hands to the bridge is already
a bare destination IP with no domain name attached at the network layer.
Every other inbound recovers this via sniffing (confirmed working for the
stock wireguard inbound, which does have it configured); the bridge never
got a sniffing block at all, so only tag/IP/network-based rules could ever
match it -- any domain rule above it in the list was silently unreachable.
The dokodemo-door TPROXY bridge every AmneziaWG peer's traffic is routed
through has no per-user identity, so Xray's own access log never carries an
"email:" token for these lines -- the Access Logs modal showed a blank
Email column for every in-*-udp row, even though every other protocol's
rows show the client normally.
The peer's decapsulated tunnel IP does survive as the log's "from" address,
and that IP deterministically maps to exactly one configured peer. Builds a
"<inbound tag>|<ip>" -> email index from the same AmneziaWG inbounds already
parsed elsewhere (amneziawg.InstanceFromInbound), and fills in Email from it
whenever the raw log line didn't have one.
The Inbounds list only special-cased isWireguard/isHysteria for the "UDP"
network badge, so an AmneziaWG row showed just the bare protocol tag with
no transport badge next to it. Added the missing isAmneziawg flag (mirrors
isWireguard exactly) and wired it into the same branch.
Client-row protocol-color maps in ClientsPage/HostList had no amneziawg
entry, silently falling back to grey -- ClientInfoModal already had
amneziawg: 'yellow' from earlier work, these two just never got it.
TPROXY never rewrites a packet's own destination address, only the routing
decision. A default-deny firewall whose INPUT chain sanity-checks "is this
destination actually local" (UFW's ufw-not-local, via addrtype --dst-type
LOCAL, is a concrete example) silently drops the redirected packet before
Xray's socket ever sees it -- RouteThroughXray looked fully configured
(TPROXY rule present and counting, Xray listening with IP_TRANSPARENT set)
yet every peer's traffic vanished with no trace on either side.
Adds an idempotent, never-torn-down "iptables -I INPUT 1 -m mark --mark
<fwmark> -j ACCEPT" alongside the existing shared policy route, so this
works regardless of which firewall manager owns the rest of the INPUT chain.
Real incident: an AmneziaWG inbound with RouteThroughXray enabled lost
all internet on that connection after a migration. Root-caused on the
live box -- iptables TPROXY counters were incrementing (packets
correctly redirected to 127.0.0.1:63110), but nothing was actually
listening there (ss showed nothing on that port) until a full
`systemctl restart x-ui`, after which the bridge came up immediately.
Xray-core's gRPC AddInbound reports success for a new sockopt.tproxy
inbound (internal/amneziawg's own Xray egress bridge is the only kind
this fork ever generates) but doesn't reliably bind a working listener
for it outside of process startup -- the bridge silently never comes
up, and RouteThroughXray traffic goes nowhere until the next full
restart happens to occur for an unrelated reason.
diffInbounds already has this exact defensive pattern for REALITY
inbounds ("a gRPC remove+add does not reliably rebuild the REALITY
authenticator"), just never extended to TPROXY, and only in the
already-existing-then-changed branch -- the "brand new inbound" branch
had no such guard at all, which is exactly the path a freshly-enabled
RouteThroughXray bridge takes. Added inboundUsesTproxy and wired it
into both branches.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AmneziaWG is this fork's whole reason for existing, so requiring an
explicit flag/prompt answer to actually get it on every fresh install,
migration, or update was the wrong default -- confirmed today by a real
migration where it silently stayed uninstalled (interactive default was
"no", and non-interactive/unattended installs skipped it outright with
nothing to answer the prompt).
should_install_amneziawg now defaults to yes in both the interactive
prompt and the non-interactive/unattended path; XUI_INSTALL_AMNEZIAWG
still works as an explicit override in either direction (true/false),
so anyone who genuinely doesn't want the DKMS module + host-wide IP
forwarding can still opt out.
Also fixed the printed opt-out hint to show the actual working syntax
for setting the variable before a later re-run -- `VAR=val curl ... |
bash` only exports VAR into curl's environment, not bash's, since
they're separate processes joined by a pipe. Hit this exact gotcha
live: XUI_INSTALL_AMNEZIAWG=true prefixed onto the curl command was
silently ignored.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sidebar's version badge and the subscription-templates docs link
still hardcoded MHSanaei/3x-ui. install.sh and every README already
point at Kuzz007/3x-ui; these were the last two functional (non-credit)
repo links left pointing upstream. DONATE_URL/DOCS_URL intentionally
left alone -- those point at the original author's own donation page
and docs site, which is deliberate per this fork's existing convention
of crediting/linking upstream for general (non-fork-specific) info.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every reinstall/update wipes /usr/local/x-ui/ wholesale and re-extracts
the release tarball, which only ships known assets (xray/mtg binaries,
the bundled geoip*/geosite*.dat sets). A user-reported real incident:
a hand-placed custom geoip file referenced from a routing rule via
"ext:<file>:<code>" got silently deleted on update, and Xray refused
to start at all afterward ("failed to open <file>: no such file or
directory"), taking down every inbound until the file was manually
restored from the user's own backup.
install_x-ui now backs up the old bin/ before the wipe and restores,
after extraction, only the files the fresh release doesn't provide --
bundled assets still get the newer per-release copy, nothing custom
silently disappears. Verified in isolation: standard files (geoip.dat,
the xray binary) end up as the fresh release's copy; a custom file
absent from the release survives untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GitHub Pages was never enabled in this fork's repo settings, and the
workflow's own comment ties it to docs.sanaei.dev, upstream's custom
domain, not something this fork has. Failed both times it ever ran
(including once before today, unrelated to any of this session's
other changes) with "Ensure GitHub Pages has been enabled". Docs CI
(docs-ci.yml, lint/build-check only, no deploy) is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Never used by this fork's own distribution (install.sh/x-ui.sh is the
only supported install path), AmneziaWG structurally can't run in the
Alpine-based image anyway, and Docker Hub publishing was failing on
every release for lack of configured credentials. Removed Dockerfile,
docker-compose.yml, DockerEntrypoint.sh, DockerInit.sh, .dockerignore,
and the docker.yml CI workflow; dropped the now-dead "Docker" README
subsection (all 7 languages), the "Docker image"/"Docker Compose"
options from the issue/PR templates, and corrected claude-bot.yml's
now-stale references to the deleted files, the never-actually-ours
ghcr.io/mhsanaei/3x-ui image, and (caught in passing) an already-stale
claim that Windows is a supported platform.
Left untouched: generic container-runtime adaptations that apply
regardless of image source (x-ui.sh's running-in-docker detection,
the virtual-interface-name filters, the DNS-over-container-network
note) and deploy/test/smoke-noninteractive.sh, which uses Docker only
as its own test sandbox, not as something this repo ships.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The push/pull_request paths filter didn't include .github/workflows/**,
so the previous commit (fixing the Audit step) never actually ran
through CI -- editing the workflow itself silently skipped the trigger.
A broken workflow-syntax change could merge untested the same way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
npm audit --audit-level=high doesn't read code comments -- it was still
exiting 1 on the one remaining, already-investigated-as-unfixable advisory
(eslint-plugin-jsx-a11y's own pinned minimatch, GHSA-mh99-v99m-4gvg), so
every run on main has actually been red despite the prior commit's comment
calling it "known-accepted". That advisory lives entirely in
devDependencies (lint tooling, never shipped), so --omit=dev is the
correct signal for "doesn't apply to what we ship" -- verified locally,
0 vulnerabilities. Runtime dependencies are still audited at high severity.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The AmneziaWG share link (both the panel's per-client copy-link/QR and
the subscription endpoint) used an invented amneziawg://user@host:port
URI the real AmneziaVPN app can't parse -- it only recognizes its own
vpn:// scheme. Reverse-engineered the real app's import path (reading
amnezia-vpn/amnezia-client's own source) and confirmed it just needs
base64url(no padding) of a plain AmneziaWG .conf text -- no JSON schema
or qCompress framing to replicate, since qUncompress falls back to the
raw bytes for plain text and the parser reads a flat "Key = Value" bag
regardless of section. Both link generators now wrap the same .conf
text their own "download config" feature already produces correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Downloading the raw subscription URL as a .txt file duplicates the
adjacent copy button for the vast majority of actual usage, and the
row already has a real, useful download further down (the per-client
config, e.g. AmneziaWG's own .conf). Left the JSON/Clash rows' download
buttons alone -- only the plain SUB row's was removed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real, forward-compatible fixes for npm audit's high-severity
advisories (not the downgrades npm audit fix --force offers):
- react-router (GHSA-qwww-vcr4-c8h2, RSC CSRF bypass): react-router-dom
is frozen at 7.18.1, pinning the vulnerable react-router@7.18.1 -- no
newer react-router-dom release exists pointing at the fixed line.
react-router itself has shipped the real fix at 8.3.0. Migrated the 9
files importing from react-router-dom (all using plain
createBrowserRouter/RouterProvider/useLocation/useNavigate/Outlet, no
RSC anywhere) to import from react-router directly instead.
- brace-expansion/minimatch (GHSA-mh99-v99m-4gvg): fixed for eslint's
own dependency chain (minimatch@10.2.5, used by eslint itself,
storybook, typescript-eslint, swagger-client) via a scoped
"minimatch@^10" override forcing brace-expansion to the now-published
5.0.8 patch -- within the range minimatch@10.2.5 already declares
wanting (^5.0.5), so this isn't a version-pin workaround, just
unblocking a patch release npm's resolver hadn't picked up.
One advisory remains genuinely unfixable from our side:
eslint-plugin-jsx-a11y pins minimatch@^3.1.2 (old major, never
patched); forcing it to the 10.x line via override breaks npm's own
dependency-tree validation (a real incompatibility, not just an npm
quirk), so this needs an eslint-plugin-jsx-a11y release bumping its own
minimatch. Lint-time only, no untrusted input reaches it -- ci.yml's
Audit step comment updated to reflect the new, smaller remaining scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This reverts commit 638458bd. Reordering new rules to the top by
creation-time was a blunt heuristic: it fixed a rule silently shadowed
by an unrestricted catch-all above it, but breaks the opposite,
equally valid setup -- e.g. a pre-existing "youtube -> interfaceA" rule
(no inboundTag restriction, meant to apply everywhere) followed by a
new "amneziawg -> interfaceB" rule. Under the reverted logic the newer,
broader-matching amneziawg rule would jump above the youtube rule and
shadow it for amneziawg-sourced traffic -- the exact same class of bug
this was meant to fix, just aimed the other way.
Xray's rule order encodes real admin intent (which rule should win for
overlapping traffic) that creation time can't safely stand in for.
Back to plain append-at-the-end; admins reorder manually with the
existing moveUp/moveDown controls, same as upstream's own behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A new routing rule created through the Routing tab was always appended
to the end of the list. Xray matches rules top-to-bottom, first hit
wins, so any pre-existing broader rule (e.g. a block rule with no
inboundTag restriction) silently shadows a newly created, more specific
rule forever -- it looks saved and enabled but never actually fires.
Root-caused this from a report that an AmneziaWG inbound routed through
a real outbound lost all connectivity while routing it "direct" worked
fine: the AmneziaWG bridge's own TPROXY/interface code turned out to be
entirely fine (tunnel stayed up, client stayed online) -- the new rule
was just sitting below existing RU-IP/bittorrent/domain block rules
with no inbound restriction, so those matched first. Confirmed directly:
manually dragging the rule to sit right after the pinned api rule fixed
it. This isn't AmneziaWG-specific -- any protocol's newly added rule can
be shadowed the same way.
New rules now insert right after the pinned api rule (or at the very
top if it's absent) instead of at the end, so a newly created rule takes
effect by default; drag it lower afterward if a lower priority is
actually wanted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Speed column showed "--" for AmneziaWG (and MTProto, which has the
identical gap) even while cumulative traffic totals were correct.
XrayTrafficJob drives live speed by querying xray-core's own stats API
and broadcasting the delta over websocket -- but AmneziaWG/MTProto never
run inside xray-core's own runtime inbounds, so they're invisible to
that API. Their own jobs already compute the same per-poll delta shape
(that's what keeps cumulative totals correct) but never broadcast it.
Reusing the existing "traffics"/"clientTraffics" broadcast would have
two real bugs: the frontend's existing scope/replace logic would let
each side clobber the other's speed on its next unrelated tick, and the
websocket hub's per-message-type throttle is keyed only by message type,
not caller -- since both sidecar jobs run on identical "@every 10s"
grids registered milliseconds apart, one would silently lose almost
every broadcast if both protocols were ever configured together.
Fixed with a small unthrottled broadcast path (both sidecar jobs are
already self-rate-limited by their own cron cadence) and protocol-
namespaced wire keys, tracked in their own frontend state and merged
into the existing inboundSpeed/clientSpeed only at read time -- so every
existing consumer needs zero changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fork's README only ever documented AmneziaWG as "what's different
here" -- the new geosite/geoip routing-rule autocomplete had nowhere
appropriate to go. Added a dedicated, appendable section for smaller
fork-specific improvements beyond AmneziaWG, and described the new
autocomplete feature there in all 7 languages. Future fork-specific
features should get a short entry here too, in every README.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Writing a routing rule today means remembering exact geosite:/geoip:/
ext:file:code syntax by hand, with no way to discover what categories
actually exist in the .dat files sitting in the bin folder -- including
custom ones like geosite_roscom.dat added via the Geodata auto-update
feature. The Domain/IP fields in the rule editor now suggest categories
as you type (e.g. "you" -> "geosite:youtube"), built live from whatever
.dat files are actually on disk, while still accepting any free-typed
value exactly as before.
Backend: GET /panel/api/xray/getGeodataCategories scans the bin folder,
parses matched geosite*/geoip*.dat files via xray-core's own exported
protobuf types, and formats each category as the exact rule syntax
xray-core's parser accepts -- geosite:/geoip: for the default files,
ext:<file>:<code> for anything else (there's no shorthand for custom
files). Cached in memory keyed by each file's (name, size, modTime) so
a request-time scan is cheap until a file actually changes.
Frontend: the Domain/IP inputs become Select "tags" fields fed by a new
useGeodataCategories() query hook, with an explicit substring filter so
"you" matches "geosite:youtube" (not a prefix). The array<->CSV-string
adapter lives entirely at the FormField transform boundary, so the
underlying form schema and saved rule shape are unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The published v3.5.0-awg.1 release was built before 814369da, so it still
ships the version-comparison bug that commit fixed. Cutting a new tag from
the corrected tree so the panel's own self-check is actually accurate for
anyone who updates.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
internal/config/version was never bumped past 3.5.0 when v3.5.0-awg.1 was
tagged, so a freshly-updated panel kept reporting its own version as the
plain upstream base. On top of that, parseVersionParts (Go and its
TypeScript mirror) required exactly 3 dot-separated numeric parts, so it
rejected the -awg.N suffix entirely and fell back to a raw string
inequality that reports "update available" any time the strings merely
differ -- which they always do here, even when already on the latest tag.
Bump the embedded version to 3.5.0-awg.1 and extend both parsers to treat
"-awg.N" as an optional 4th, lower-priority component (defaulting to 0 for
a plain tag), so e.g. 3.5.0-awg.2 > 3.5.0-awg.1 > 3.5.0 and a matching tag
compares equal instead of always looking outdated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither is exploitable in this app (react-router CVE is RSC-only, we use
createBrowserRouter; jsx-a11y's brace-expansion chain only runs against our
own lint globs), and npm's suggested fixes are both downgrades with no real
forward patch published yet -- left as-is rather than trading a working
version for one that doesn't fix anything reachable here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v3.5.0-awg.1 is now tagged and promoted to Latest, so the plain
no-argument install.sh command resolves to something real for the first
time. Restructured Quick Start in all 7 READMEs to lead with it, followed
by the explicit-version and dev-channel variants (matching upstream's own
three-command Quick Start layout), replacing the now-outdated "this fork
only ever publishes dev-latest" note.
Investigated multi-node interaction with AmneziaWG: the master's own
reconcile (DesiredAmneziaWGInstances) and Xray config generation
(injectAmneziawgEgress, the GenXrayInboundConfig protocol skip) all
correctly filter on NodeID IS NULL, so a node-assigned AmneziaWG (or
MTProto) inbound would never be managed by the master. But nothing
stopped one from being created that way: NODE_ELIGIBLE_PROTOCOLS
(frontend/src/pages/inbounds/form/InboundFormModal.tsx) only hides the
node picker client-side -- a direct API call could set nodeId on an
AmneziaWG inbound, which every node then reconciles as an ordinary
local inbound (nodes run the identical binary, full cron suite
included), leaving it running unmanaged and untracked by the master's
own AmneziaWG bookkeeping.
Added isNodeEligibleProtocol (inbound_protocol.go), mirroring the
frontend's allowlist, and enforced it in both AddInbound (the actually
exploitable path -- nodeId comes straight from the request) and
UpdateInbound (defense in depth; NodeID is already restored from the
stored row there before this check, so it mainly guards against a
protocol change on an existing node-hosted inbound).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getInboundsBySubId's SQL protocol allowlist never had 'amneziawg' added,
so every AmneziaWG client was silently excluded from all three
subscription formats (plain/individual links, JSON, Clash) and from the
Telegram bot's QR/individual-link buttons, which fetch through the same
path. genAmneziaWGLink itself was already fully implemented and already
wired into GetLink's dispatch switch -- it just never got a chance to
run. Same bug shape as the earlier TRACKED_PROTOCOLS frontend gap: a
hardcoded protocol list one entry short.
Found while investigating whether the Telegram bot needed AmneziaWG-
specific client-management code -- it doesn't (the bot itself is fully
protocol-agnostic), but this is the actual root cause of "can't share
an AmneziaWG client's config via the bot."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Investigated: the image is Alpine-based, and AmneziaWG's own packaging
(DKMS module + amneziawg-tools) doesn't target Alpine/musl at all --
unlike the Debian/Ubuntu/Fedora/Arch paths install.sh already handles,
there's no package to apk add even with full host network/capabilities.
The panel already degrades gracefully (IsAwgInstalled() logs one warning
instead of retrying forever), so no code change is needed -- just made
the reason explicit at the point where a user would reach for cap_add/
network_mode to try to work around it.
- manager.go: serverAddress assumed subnetIp always ends in ".0"; a
base like "10.8.1.5" was used verbatim as the server's own address,
eventually colliding with peer allocation (which starts at .2
upward). Now derives the first host of the actual subnetIp/subnetCidr
network via netip, matching serverAddressV6's own approach. A /32
base (no host bits at all) is still used as-is. (Finding 12, partial
-- the /16 pool-widening half of this finding only exists on the
upstream-pr/amneziawg branch's merged client_wireguard.go, not here;
handled separately on that branch.)
- manager.go: ensureLocked carried the previous per-peer traffic
counters (`last`) forward even through a full restart, but
awg-quick down+up resets the kernel's own counters to zero -- the
next CollectTraffic computed a large negative delta (clamped to 0),
silently discarding real traffic. Extracted the decision into
nextTrafficBaseline: only a reload (syncconf) preserves the
baseline. (Finding 13)
- portfwd.go: exported ForwardedPortsInclude; inbound_amneziawg.go's
new checkForwardedPortsConflict uses it to reject, at save time, a
client's forwardedPorts that would DNAT the panel's own port or
another enabled inbound's port to the tunnel client --
portForwardLines has no destination restriction, so this collision
was previously silent. Wired into both the single-client update path
and the add-client path (client_inbound_apply.go), plus
normalizeAmneziaWGSettings for the whole-inbound save path. (Finding 14)
- inbound.go: InboundOption.AwgServer sent the whole ServerSettings
struct including PrivateKey to GetInboundOptions callers -- a
shared, admin-wide dropdown-filling endpoint the frontend's own
AwgServerOptionSchema never reads that field from. Redacted it
before assigning. (Finding 11)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses Finding 10 from the automated PR review: an always-on TPROXY
bridge makes every AmneziaWG tunnel hard-depend on Xray being up (all
traffic, including DNS, drops whenever Xray restarts), and forces a full
awg-quick down+up bounce on any client add/remove/re-IP, permanently
losing the syncconf fast path.
Adds ServerSettings.RouteThroughXray (off by default):
- defaultPostUpDown only emits the TPROXY/policy-route rules when it's
on; a plain AmneziaWG tunnel now has zero Xray dependency out of the
box.
- structuralFingerprint covers it (toggling it changes whether PostUp/
PostDown contain any TPROXY rules at all -- structural, not a
per-peer host-rule). hostRulesFingerprint's IPv4 tracking is now
itself conditional on RouteThroughXray (and IPv6 tracking on
IPv6Enabled), so an instance that never uses either keeps the
syncconf fast path for a plain peer re-IP.
- injectAmneziawgEgress only creates a bridge for inbounds that opted
in; checkAmneziawgEgressConflict (the Finding-7 fix) now parses each
candidate through InstanceFromInbound so a non-routed inbound's port
is correctly never treated as reserved.
- New inbound-level Switch in the AmneziaWG form; the actual outbound
decision is still made entirely through the panel's stock Routing
page, same as before -- only whether the bridge exists at all is now
a choice.
Translation keys added to all 13 locales in the same commit this time,
not backfilled later (see Finding 9's lesson).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each is independently reproducible; fixed together since one review pass
found all of them.
- manager.go: the shared "ip rule add fwmark" policy route had no
existence check, so it duplicated in "ip rule show" on every interface
bounce (which hostRulesFingerprint forces on any client add/remove/
re-IP). Now checked via "ip rule list | grep -q ..." first. (Finding 2)
- params.go: ExternalInterface, IPv6ExternalInterface, and subnetIp/
subnetCidr are interpolated unescaped into a shell-executed PostUp/
PostDown line, but only obfuscation and the IPv6 subnet were validated
before save. Added ValidateInterfaceName (a strict charset+length
pattern) and ValidateSubnetIPv4 (netip.ParsePrefix), wired into
normalizeAmneziaWGSettings. (Finding 3)
- amneziawg_job.go: IsAwgInstalled() existed but nothing ever called it,
so a host without awg/awg-quick (the Docker image, RHEL, Arch, a failed
install.sh PPA step) logged a reconcile failure every 10s forever. Now
checked once an inbound actually needs it, warning once instead of
spamming. (Finding 4)
- client_inbound_apply.go: the WireGuard/AmneziaWG credential
carry-forward (added so a metadata-only client edit doesn't rotate
keys) never covered ForwardedPorts, so a partial edit -- an API call or
Telegram-bot toggle that omits the field -- silently wiped a client's
port-forwarding spec. Carried forward and written back the same way the
key fields already are. (Finding 5)
- manager.go: hostRulesFingerprint keyed each peer on its IPv4 address
only, and structuralFingerprint omitted IPv6Enabled/IPv6ExternalInterface
entirely, so an IPv6-only change could pick the syncconf reload path
(which never re-runs PostUp, leaving a stale NDP-proxy entry) or be a
complete no-op. Both fingerprints now cover the IPv6 fields. (Finding 6)
- port_conflict.go: the AmneziaWG egress bridge (injectAmneziawgEgress)
binds 127.0.0.1:63100+id with no collision check anywhere, since it
isn't a database row the ordinary port-conflict query can see -- same
blind spot the reserved Xray API port already has its own check for.
Added the equivalent check for the AmneziaWG bridge port. (Finding 7)
- install.sh: install_amneziawg ran unconditionally for every install/
update, building a DKMS kernel module and enabling host-wide IPv4/IPv6
forwarding whether or not the feature is ever used. Gated behind a new
should_install_amneziawg (XUI_INSTALL_AMNEZIAWG=true/false, or an
interactive y/N prompt defaulting to no). Also replaced the deprecated
apt-key adv with a dedicated keyring + signed-by= on the Debian branch,
and guarded its sources.list appends against duplication on a retried
install. (Finding 8)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two gaps left an AmneziaWG interface stuck outside the manager's
control after a crash (kill -9/OOM/panic skips StopAll):
- ensureRestart's teardown was gated on the in-memory `exists` map,
which is always empty on a fresh process, so a survived interface
never got interfaceDown before interfaceUp tried `ip link add`
against a name the kernel already had — failing forever and never
populating m.ifaces, so traffic accounting silently stopped and the
inbound could never be removed. Gate on isInterfaceUp instead, which
checks real kernel state rather than this process's own bookkeeping.
- An inbound deleted from the database entirely while the panel was
down has no entry in `desired` ever again, so it never reaches the
per-id cleanup loop in Reconcile (which only walks m.ifaces). Add a
one-time sweepOrphansLocked scan of configDir, mirroring
mtproto.Manager.sweepOrphansLocked, that tears down and removes any
leftover interface/config not in the current desired set.
Found by the automated review on MHSanaei/3x-ui#6105 (Finding 1).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fa/Ar/Zh/Es/Tr READMEs still described upstream verbatim (generic 3X-UI,
no AmneziaWG section, upstream install command, Windows listed as
supported, Contributing/donation/stargazers sections). Rewrite each to
match the already fork-ified README.md/README.ru_RU.md: fork framing,
the "What's different: AmneziaWG" section, dev-latest install command,
Windows removed from supported platforms, leaner developer-notes/credit
sections. Also restore the 7-language switcher line on all files now
that they're consistent again.
Only en-US/ru-RU ever got these 9 keys as each AmneziaWG feature landed
(the regenerate-obfuscation button, then Phase 2a's IPv6 fields, then
Phase 2b's per-client ForwardedPorts) — the other 11 locale files were
never backfilled, so i18next has been silently falling back to English
for all of them since Phase 1. Cosmetic-only (never broke anything),
but now closed for every shipped locale.
Simplifies RouteViaXray after realizing the panel already has everything
needed: the Routing page already lets an admin pick a source inbound tag
and a target outbound (plus, if they want it, a specific source IP) for
any protocol. Bolting a parallel routeThroughXray/routeOutboundTag pair
onto both the client and inbound forms duplicated that mechanism instead
of using it.
Removed entirely: Client/ClientRecord/ServerSettings/Peer's
RouteThroughXray + RouteOutboundTag fields, the effective-routing OR/
fallback logic in InstanceFromInbound, and the Switch+Select UI on both
forms. Nothing configures "route via Xray" as a setting anymore.
In its place, every enabled AmneziaWG inbound now gets its own Xray
TPROXY bridge unconditionally, by default, no toggle:
- internal/amneziawg: every peer's traffic is always TPROXY'd into that
instance's own bridge (defaultPostUpDown, port derived from the
inbound's id via EgressPortForInbound so the kernel side and the
Xray-config side never need to negotiate a runtime value). Since the
TPROXY rule is now tied to a peer's mere presence rather than an
opt-in flag, hostRulesFingerprint now covers every peer unconditionally
(add/remove/re-IP forces a restart, the same way ForwardedPorts always
did) instead of skipping peers with nothing to opt into.
- internal/web/service/xray.go's injectAmneziawgEgress creates one
dokodemo-door bridge per qualifying inbound, tagged with that inbound's
own real tag — the same trick injectMtprotoEgress already uses (reusing
a real inbound's tag), which is why it's already selectable in the
panel's Routing page: InboundService.GetInboundTags() is a plain,
protocol-blind SELECT over every inbound row's tag, no dedicated UI
plumbing needed. The function never generates a routing rule itself
anymore — where (if anywhere) that traffic goes is entirely up to
whatever rules the admin adds through the existing Routing UI.
Frontend: no new UI at all. Tests rewritten to match — one bridge per
inbound with its own tag/port, no rule generation, no opt-in gating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RouteThroughXray/RouteOutboundTag were client-only, but the more common
case is "route this whole AmneziaWG server's traffic through Xray", not
configuring every peer individually. Add the same pair to ServerSettings
(inbound-level) while keeping the per-client fields as an override —
matching how ExternalInterface/IPv6Enabled already work at the server
level next to per-client settings like ForwardedPorts.
Effective per-peer decision (computed once, in InstanceFromInbound, not
duplicated at each consumer):
- routed = client.RouteThroughXray || server.RouteThroughXray
- outbound tag = client's own if set, else the server's default
This means a peer can be routed by the inbound-wide default with no
config of its own, opt in on its own even when the default is off, or
keep the default's on/off but pick a different outbound than everyone
else.
internal/web/service/xray.go's injectAmneziawgEgress now calls
amneziawg.InstanceFromInbound instead of re-parsing InboundSettings and
reading model.Client fields directly — the same effective-routing
computation the kernel-side TPROXY rules use, so the two independent
reconcile loops (Xray-config generation and the AWG manager) can never
quietly disagree about which peers are actually routed.
Frontend: Switch + conditional outbound Select added to the AWG inbound
form (mirroring the client-form version and mtproto's own UI), plus the
inbound-defaults.ts default-object fix that's bitten this project's CI
before (Phase 2a) whenever ServerSettings gains a new required-shaped
field.
Test fixtures in xray_config_inject_test.go needed a real Server block
and PublicKey once injectAmneziawgEgress started requiring a usable
InstanceFromInbound result — both were implicit fixture gaps, not
behavior the old tests were actually asserting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per-client toggle (RouteThroughXray + RouteOutboundTag) that TPROXYs a
peer's traffic into Xray instead of NAT'ing it straight out the host's
network interface, so it can egress through any configured Xray
outbound (or balancer) — a VLESS/proxy chain, WARP, etc.
Discovered mid-design that internal/mtproto already solved the "let a
native sidecar's traffic egress through Xray" problem once, via
routeThroughXray/routeXrayPort/outboundTag + injectMtprotoEgress: a
loopback bridge inbound plus a routing rule. AmneziaWG can't reuse it
directly — mtg is a userspace process that dials *out* through a local
SOCKS proxy, while AmneziaWG is a kernel tunnel interface with no
process of its own to redirect. The Xray-side shape carries over
almost exactly, the kernel-side plumbing is new:
- internal/amneziawg/route_egress.go: EgressPort/EgressTag/EgressFwmark/
EgressTable are one shared constant set, not one bridge per peer.
Every routed peer, across every AmneziaWG instance, TPROXYs into the
*same* loopback dokodemo-door bridge; the per-peer distinction happens
downstream, in Xray's own router, matched against each peer's
TPROXY-preserved source IP (Xray's field-rule `source` matcher — a
capability the router already had). This avoids two independent
reconcile loops (the AWG manager and the Xray-config generator) ever
having to agree on a dynamically-picked port for each peer.
- manager.go's defaultPostUpDown emits a per-peer mangle-table TPROXY
rule (matched by tunnel source IP) for each opted-in peer, plus the
fwmark->table->local-everywhere policy route TPROXY needs to deliver
those packets to the bridge. That policy route is system-wide, not
interface-specific, so — like the existing IPv6-forwarding sysctl —
it's added idempotently and never torn down in PostDown; a second
AmneziaWG instance with its own routed peers must find it already in
place, not race to remove what the first still needs.
- The existing portForwardFingerprint became hostRulesFingerprint,
covering both ForwardedPorts and RouteThroughXray/RouteOutboundTag:
both only ever take effect through PostUp/PostDown, which `awg
syncconf` never re-runs, so either one changing must force the same
full interface bounce.
- internal/web/service/xray.go's new injectAmneziawgEgress mirrors
injectMtprotoEgress/injectPanelEgress's safety rules, adapted for one
bridge serving many peers: an invalid or missing outbound target
skips only that one peer's rule (not the whole bridge, since other
peers may still need it), while the bridge itself is skipped
entirely when nothing needs it or its tag is already taken by a real
inbound.
Frontend: a Switch + conditional outbound Select on the client form
(showAmneziawg only), mirroring mtproto's own routeThroughXray UI and
reusing its useOutboundTags hook. install.sh now modprobes the
mainline TPROXY modules (xt_TPROXY, nf_tproxy_ipv4/ipv6) alongside the
existing AmneziaWG setup — ordinary upstream kernel modules, no
DKMS/PPA needed unlike the AmneziaWG module itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admins can now set a per-client ForwardedPorts string (e.g. "80, 443,
8000-8100") that gets DNAT'd + FORWARD'd to that peer's tunnel address
via iptables rules in PostUp/PostDown, ported and simplified from
coinman-dev/3ax-ui's shared/portfwd.
Two decisions worth flagging for future readers:
- The iptables --comment tag on each rule is awg-fwd-<fnv32a(email)>,
not the raw client email. Email is admin/API-supplied free text that
ends up embedded in a shell-executed PostUp/PostDown line; a hash
can never carry a shell metacharacter through where raw
interpolation could.
- The reconcile manager gained a third fingerprint (portFwdFP, next to
the existing structural/peers ones). `awg syncconf` only touches the
WireGuard peer table — it never re-applies PostUp/PostDown iptables
rules — so a port-forward-only change has to force a full
awg-quick down+up bounce, same as a structural change, rather than
the lighter sync a plain peer add/remove can use.
Also fixes a real pre-existing bug found while wiring up IPv6 client
allocation in the previous commit's spirit: allocateWireguardAddress
always suffixed "/32" regardless of address family, which produced
invalid host bits for IPv6 (needs "/128").
ForwardedPorts flows through model.Client -> model.ClientRecord
(gorm column wg_forwarded_ports, auto-migrated) -> ToRecord/ToClient/
MergeClientRecord, mirroring the awgServer field's earlier lesson
that new fields need checking against a second, hand-maintained
persistence-layer struct.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real gaps the CI caught (both new fields, both my miss):
- inbound-defaults.ts's createDefaultAmneziawgInboundSettings() built a
server object literal predating ipv6Enabled/ipv6Subnet/
ipv6ExternalInterface — AmneziawgServer's inferred type now requires
them (zod .default() fields are non-optional post-parse), so this
didn't typecheck at all.
- openapi.json's ipv6Enabled property was missing the description the
real generator attaches (the Go doc comment covering all three IPv6
fields is attached to the first one) — a one-line diff, but git
diff --exit-code doesn't care how small.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds native dual-stack IPv6 to AmneziaWG inbounds, ported from
coinman-dev/3ax-ui's approach:
- ServerSettings gets ipv6Enabled/ipv6Subnet/ipv6ExternalInterface;
Instance carries the server's own IPv6 address (first host of the
subnet) alongside its IPv4 one.
- defaultAmneziaWGClients allocates an IPv6 host address per client
(second AllowedIPs entry) when the server has IPv6 enabled, reusing
allocateWireguardAddress — which needed a real fix along the way: it
always suffixed "/32" regardless of address family, which is wrong
for an IPv6 host address (needs /128). Now family-aware.
- generateServerConfig's PostUp/PostDown gains IPv6 forward-accept
rules, proxy_ndp sysctl, and one `ip -6 neigh add/del proxy` entry per
enabled peer with an IPv6 address — the lightweight per-client
method, not the ndppd-daemon whole-subnet method (not worth the
config-file-management complexity at this scale; ndppd itself is
still installed by install.sh in case that changes later).
- ValidateIPv6Subnet rejects a malformed subnet before save.
- Frontend: ipv6Enabled/ipv6Subnet/ipv6ExternalInterface fields on the
AmneziaWG inbound form, EN+RU translations, openapi.json/generated/*
regenerated (the latter via `go run ./tools/openapigen`, pure Go).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removing build-windows (previous commit) means dev-artifacts/*.zip
never matches anything — gh release upload treats an unmatched glob as
fatal, so every dev-latest publish since has failed outright (verified:
this run's publish-dev job errored "no matches found for
`dev-artifacts/*.zip`" on all 5 retries). Only the *.tar.gz glob remains.
Also manually deleted the stale x-ui-windows-amd64.zip asset from the
current dev-latest release — it would never be refreshed again
otherwise, and silently going stale forever is worse than not being
there.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AmneziaWG (awg-quick, the DKMS kernel module) and this whole panel's
real deployment target here are Linux servers/routers — the Windows
job (MSYS2 + CGO cross-build, its own Xray/mtg-multi asset download
step) never served a purpose for this fork and just spent CI minutes
producing a binary nobody uses. Removed build-windows entirely and
dropped it from publish-dev's needs.
Also updated both READMEs' Supported Platforms line to match — this
fork's CI no longer produces a Windows binary, upstream's still does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was still MHSanaei/3x-ui's own upstream README verbatim — wrong install
URL (mhsanaei/master, no AmneziaWG mention), badges pointing at a repo
this fork doesn't publish releases/downloads on, stargazer chart that
would just show a near-flat personal-fork history.
Rewritten to lead with what's actually different here (native
AmneziaWG: no Docker, DKMS-installed by install.sh, full 2.0 obfuscation
params), point Quick Start at Kuzz007/3x-ui's dev-latest channel, note
where AmneziaWG needs Linux/Secure-Boot-off/native-not-Docker, and credit
the base project + the two AmneziaWG references this fork's
implementation was ported from (PR #6086, coinman-dev/3ax-ui).
The other 5 language READMEs (fa_IR/ar_EG/zh_CN/es_ES/tr_TR) still
describe upstream verbatim and are no longer linked from the language
switcher here — left as a follow-up rather than guessing translations.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>