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.
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.
- 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>
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>
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>
Wires the amneziawg protocol through the panel UI the same way every
other protocol is registered: a Zod settings schema (nested
{server, clients}, matching the Go JSON exactly), the protocol enum,
the inbound-form's per-protocol fields component and its
tab-visibility allowlist, the default-settings factory, the client
schema dispatcher, and the sniffing-capability exclusion (no Xray
inbound exists for amneziawg, same as mtproto).
Client key/allowedIPs fields are reused rather than duplicated: since
AmneziaWG clients are wire-identical to WireGuard clients (same
model.Client fields), ClientFormModal renders one shared field block
for both, switching only the visible label by which protocol is
active. The private-key input also gets a live public-key sync via a
new useEffect, because unlike WireGuard's Xray-native inbound (which
re-derives its public key at runtime and never stores one),
AmneziaWG's server.publicKey is a real persisted field the Go backend
reads directly — free-typing a new private key without this would
silently save a mismatched keypair.
Adds a downloadable per-client .conf (amneziawgConfig.ts, mirroring
wireguardConfig.ts) with the obfuscation lines, and an
InboundOption.AwgServer field on the Go side so the config builder
gets the full server block in one round trip.
Along the way, running tools/openapigen surfaced a real bug: it
doesn't flatten anonymously-embedded Go structs the way encoding/json
does, so ServerSettings embedding Obfuscation20 produced a Zod schema
with a nested `obfuscation20` key that never matches the real wire
JSON. Fixed by un-embedding (flat fields + an accessor method) and
registering internal/amneziawg in the generator's own package list,
which had been silently emitting a dangling schema reference.
English and Russian translations are complete; the other 10 locale
files still fall back to English for the new keys.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AmneziaWG (WireGuard plus DPI-resistant obfuscation) needs no Docker
here — it runs as a genuine kernel interface via awg-quick/awg, managed
the same way internal/mtproto manages mtg: one Inbound row is one
desired Instance, and a Manager reconciles running interfaces toward
the database every 10s (internal/web/job/amneziawg_job.go) plus
immediately after a client edit (applyLocalAmneziaWG).
Clients reuse model.Client verbatim (the same PrivateKey/PublicKey/
PreSharedKey/AllowedIPs fields WireGuard already uses), so bulk
operations, the QR/share-link modal and subscriptions come from the
shared inbound infrastructure instead of a parallel implementation.
internal/amneziawg owns the obfuscation param generator/validator
(ported from coinman-dev/3ax-ui, upgraded to AmneziaWG 2.0's S3/S4
padding and I1 signature packet) and the exec wrapper around
awg-quick/awg, with fingerprint-based reconcile (noop / reload-via-
syncconf / full restart) mirroring mtproto.Manager so a same-protocol
edit doesn't force an unnecessary interface bounce that would drop
every peer's connection.
Frontend and install.sh's DKMS/awg-tools setup are tracked separately;
this is backend-only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>