diff --git a/CLAUDE.md b/CLAUDE.md index 8e3f1bd5f..3b8a12352 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,13 @@ file locations when it can answer in one hop. - `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing. - `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/sub/` — subscription server (raw / JSON / Clash). - `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. - `service/` — business logic (InboundService, SettingService, XrayService, 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. - `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`, `runtime/` (master/sub-node over mTLS), `websocket/`. diff --git a/docker-compose.yml b/docker-compose.yml index a4c88c004..791040c3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,10 @@ services: # 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 # 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: - NET_ADMIN - NET_RAW diff --git a/docs/architecture.md b/docs/architecture.md index 52bf7aea1..57d3c252b 100644 --- a/docs/architecture.md +++ b/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 10s` | `check_client_ip_job` | Enforce per-client IP limits | | `@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 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 | diff --git a/docs/content/docs/en/config/amneziawg.mdx b/docs/content/docs/en/config/amneziawg.mdx new file mode 100644 index 000000000..507de6887 --- /dev/null +++ b/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. + + + 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. + + +## 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. ``. 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. | + + + 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. + + + + 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. + + +## Set it up in the panel + + + + +### Add an inbound + +Add a new inbound, choose protocol **AmneziaWG**, and set the port and tunnel +subnet. + + + +### 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. + + + +### 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. + + + +### 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. + + + +### 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. + + + + +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 = +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 = +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 = +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = your-server:443 +PersistentKeepalive = 25 +``` + +## Not yet covered + + + +- **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. + + diff --git a/docs/content/docs/en/config/inbounds.mdx b/docs/content/docs/en/config/inbounds.mdx index e982e41f6..c02992478 100644 --- a/docs/content/docs/en/config/inbounds.mdx +++ b/docs/content/docs/en/config/inbounds.mdx @@ -58,6 +58,7 @@ The inbound editor accepts these protocols: | **Trojan** | TLS-based; supports XTLS and fallbacks. | | **Shadowsocks** | Includes Shadowsocks-2022 (`2022-blake3-*`) ciphers. | | **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. | | **HTTP** | HTTP proxy. | | **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener. | diff --git a/docs/content/docs/en/config/meta.json b/docs/content/docs/en/config/meta.json index cf808e816..293a286ce 100644 --- a/docs/content/docs/en/config/meta.json +++ b/docs/content/docs/en/config/meta.json @@ -6,6 +6,7 @@ "ssl-certificates", "inbounds", "reality", + "amneziawg", "transports", "clients", "subscription", diff --git a/docs/content/docs/en/reference/api/server.mdx b/docs/content/docs/en/reference/api/server.mdx index fb6449b62..9365a1cb7 100644 --- a/docs/content/docs/en/reference/api/server.mdx +++ b/docs/content/docs/en/reference/api/server.mdx @@ -135,6 +135,10 @@ _openapi: - depth: 2 title: Return the last N lines of the Xray process log. url: '#return-the-last-n-lines-of-the-xray-process-log' + - depth: 2 + title: Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus + the panel’s own AmneziaWG event lines. + url: '#return-live-amneziawg-peer-activity-handshake-endpoint-transfer-plus-the-panels-own-amneziawg-event-lines' - depth: 2 title: Restore the panel DB from an uploaded backup (multipart form, field name "db"). SQLite panels accept a SQLite database (.db) or a SQLite @@ -276,6 +280,9 @@ _openapi: id: return-the-last-n-lines-of-the-panels-own-log - content: Return the last N lines of the Xray process log. id: return-the-last-n-lines-of-the-xray-process-log + - content: Return live AmneziaWG peer activity (handshake, endpoint, transfer) + plus the panel’s own AmneziaWG event lines. + id: return-live-amneziawg-peer-activity-handshake-endpoint-transfer-plus-the-panels-own-amneziawg-event-lines - content: Restore the panel DB from an uploaded backup (multipart form, field name "db"). SQLite panels accept a SQLite database (.db) or a SQLite migration dump (.dump); PostgreSQL panels accept a pg_dump archive @@ -322,7 +329,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/fa/reference/api/hosts.mdx b/docs/content/docs/fa/reference/api/hosts.mdx index df7a42bd4..2e360e750 100644 --- a/docs/content/docs/fa/reference/api/hosts.mdx +++ b/docs/content/docs/fa/reference/api/hosts.mdx @@ -102,7 +102,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/ru/reference/api/hosts.mdx b/docs/content/docs/ru/reference/api/hosts.mdx index ea5397b23..2b76f1100 100644 --- a/docs/content/docs/ru/reference/api/hosts.mdx +++ b/docs/content/docs/ru/reference/api/hosts.mdx @@ -103,7 +103,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/zh/reference/api/hosts.mdx b/docs/content/docs/zh/reference/api/hosts.mdx index 177cb42bc..092f71ec8 100644 --- a/docs/content/docs/zh/reference/api/hosts.mdx +++ b/docs/content/docs/zh/reference/api/hosts.mdx @@ -102,7 +102,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index ba242f8c6..e29982637 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -970,6 +970,36 @@ ], "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": { "properties": { "createdAt": { @@ -1064,6 +1094,16 @@ }, "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": { "description": "Auth password (Hysteria)", "type": "string" @@ -1094,6 +1134,10 @@ "description": "Flow control (XTLS)", "type": "string" }, + "forwardedPorts": { + "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"", + "type": "string" + }, "group": { "description": "Logical grouping label", "type": "string" @@ -1258,6 +1302,9 @@ "flow": { "type": "string" }, + "forwardedPorts": { + "type": "string" + }, "group": { "type": "string" }, @@ -1336,6 +1383,7 @@ "enable", "expiryTime", "flow", + "forwardedPorts", "group", "id", "keepAlive", @@ -2079,7 +2127,8 @@ "mixed", "tunnel", "tun", - "mtproto" + "mtproto", + "amneziawg" ], "example": "vless", "type": "string" @@ -2231,6 +2280,15 @@ }, "InboundOption": { "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": { "example": true, "type": "boolean" @@ -2913,6 +2971,68 @@ ], "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": "peer@example.com", + "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": { "properties": { "cpuPct": { @@ -3079,6 +3199,150 @@ ], "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": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { @@ -3608,6 +3872,7 @@ "success": true, "obj": [ { + "awgServer": null, "enable": true, "id": 1, "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": "peer@example.com", + "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": { "post": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index ba242f8c6..e29982637 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -970,6 +970,36 @@ ], "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": { "properties": { "createdAt": { @@ -1064,6 +1094,16 @@ }, "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": { "description": "Auth password (Hysteria)", "type": "string" @@ -1094,6 +1134,10 @@ "description": "Flow control (XTLS)", "type": "string" }, + "forwardedPorts": { + "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"", + "type": "string" + }, "group": { "description": "Logical grouping label", "type": "string" @@ -1258,6 +1302,9 @@ "flow": { "type": "string" }, + "forwardedPorts": { + "type": "string" + }, "group": { "type": "string" }, @@ -1336,6 +1383,7 @@ "enable", "expiryTime", "flow", + "forwardedPorts", "group", "id", "keepAlive", @@ -2079,7 +2127,8 @@ "mixed", "tunnel", "tun", - "mtproto" + "mtproto", + "amneziawg" ], "example": "vless", "type": "string" @@ -2231,6 +2280,15 @@ }, "InboundOption": { "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": { "example": true, "type": "boolean" @@ -2913,6 +2971,68 @@ ], "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": "peer@example.com", + "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": { "properties": { "cpuPct": { @@ -3079,6 +3199,150 @@ ], "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": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { @@ -3608,6 +3872,7 @@ "success": true, "obj": [ { + "awgServer": null, "enable": true, "id": 1, "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": "peer@example.com", + "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": { "post": { "tags": [ diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 1aa72e071..a66eeec7b 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -221,6 +221,26 @@ export const EXAMPLES: Record = { "webListen": "", "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": "peer@example.com", + "endpoint": "203.0.113.9:51820", + "handshake": 1735732800000, + "inboundId": 1, + "interface": "awg1", + "online": true, + "tag": "inbound-51820", + "up": 1048576 + } + ], + "running": true + }, "ApiToken": { "createdAt": 0, "enabled": false, @@ -244,6 +264,7 @@ export const EXAMPLES: Record = { "allowedIPs": [ "" ], + "allowedIPsByInbound": {}, "auth": "", "comment": "", "created_at": 0, @@ -251,6 +272,7 @@ export const EXAMPLES: Record = { "enable": false, "expiryTime": 0, "flow": "", + "forwardedPorts": "", "group": "", "id": "", "keepAlive": 0, @@ -288,6 +310,7 @@ export const EXAMPLES: Record = { "enable": false, "expiryTime": 0, "flow": "", + "forwardedPorts": "", "group": "", "id": 0, "keepAlive": 0, @@ -544,6 +567,7 @@ export const EXAMPLES: Record = { "xver": 0 }, "InboundOption": { + "awgServer": null, "enable": true, "id": 1, "listen": "", @@ -689,6 +713,18 @@ export const EXAMPLES: Record = { "runId": "1735689600123456789", "state": "success" }, + "PeerActivity": { + "allowedIPs": "10.8.1.2/32", + "down": 4194304, + "email": "peer@example.com", + "endpoint": "203.0.113.9:51820", + "handshake": 1735732800000, + "inboundId": 1, + "interface": "awg1", + "online": true, + "tag": "inbound-51820", + "up": 1048576 + }, "ProbeResultUI": { "cpuPct": 12.5, "error": "", @@ -725,6 +761,45 @@ export const EXAMPLES: Record = { "tlsVersion": "1.3", "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": { "id": 0, "key": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 7527c517a..84c9820a5 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -944,6 +944,36 @@ export const SCHEMAS: Record = { ], "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": { "properties": { "createdAt": { @@ -1038,6 +1068,16 @@ export const SCHEMAS: Record = { }, "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": { "description": "Auth password (Hysteria)", "type": "string" @@ -1068,6 +1108,10 @@ export const SCHEMAS: Record = { "description": "Flow control (XTLS)", "type": "string" }, + "forwardedPorts": { + "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"", + "type": "string" + }, "group": { "description": "Logical grouping label", "type": "string" @@ -1232,6 +1276,9 @@ export const SCHEMAS: Record = { "flow": { "type": "string" }, + "forwardedPorts": { + "type": "string" + }, "group": { "type": "string" }, @@ -1310,6 +1357,7 @@ export const SCHEMAS: Record = { "enable", "expiryTime", "flow", + "forwardedPorts", "group", "id", "keepAlive", @@ -2053,7 +2101,8 @@ export const SCHEMAS: Record = { "mixed", "tunnel", "tun", - "mtproto" + "mtproto", + "amneziawg" ], "example": "vless", "type": "string" @@ -2205,6 +2254,15 @@ export const SCHEMAS: Record = { }, "InboundOption": { "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": { "example": true, "type": "boolean" @@ -2887,6 +2945,68 @@ export const SCHEMAS: Record = { ], "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": "peer@example.com", + "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": { "properties": { "cpuPct": { @@ -3053,6 +3173,150 @@ export const SCHEMAS: Record = { ], "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": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index b1f1c0a37..215772233 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -231,6 +231,12 @@ export interface AllSettingView { webPort: number; } +export interface AmneziaWGLogs { + events: string[]; + peers: PeerActivity[]; + running: boolean; +} + export interface ApiToken { createdAt: number; enabled: boolean; @@ -254,6 +260,7 @@ export interface ApiTokenView { export interface Client { adTag?: string; allowedIPs?: string[]; + allowedIPsByInbound?: Record; auth?: string; comment: string; created_at?: number; @@ -261,6 +268,7 @@ export interface Client { enable: boolean; expiryTime: number; flow?: string; + forwardedPorts?: string; group?: string; id?: string; keepAlive?: number; @@ -300,6 +308,7 @@ export interface ClientRecord { enable: boolean; expiryTime: number; flow: string; + forwardedPorts: string; group: string; id: number; keepAlive: number; @@ -512,6 +521,7 @@ export interface InboundFallback { } export interface InboundOption { + awgServer?: ServerSettings | null; enable: boolean; id: number; listen?: string; @@ -658,6 +668,19 @@ export interface PanelUpdateStatus { 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 { cpuPct: number; error: string; @@ -694,6 +717,46 @@ export interface RealityScanResult { 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 { id: number; key: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index cd0dbbc75..e95ea30b2 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -249,6 +249,13 @@ export const AllSettingViewSchema = z.object({ }); export type AllSettingView = z.infer; +export const AmneziaWGLogsSchema = z.object({ + events: z.array(z.string()), + peers: z.array(z.lazy(() => PeerActivitySchema)), + running: z.boolean(), +}); +export type AmneziaWGLogs = z.infer; + export const ApiTokenSchema = z.object({ createdAt: z.number().int(), enabled: z.boolean(), @@ -274,6 +281,7 @@ export type ApiTokenView = z.infer; export const ClientSchema = z.object({ adTag: z.string().optional(), allowedIPs: z.array(z.string()).optional(), + allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(), auth: z.string().optional(), comment: z.string(), created_at: z.number().int().optional(), @@ -281,6 +289,7 @@ export const ClientSchema = z.object({ enable: z.boolean(), expiryTime: z.number().int(), flow: z.string().optional(), + forwardedPorts: z.string().optional(), group: z.string().optional(), id: z.string().optional(), keepAlive: z.number().int().optional(), @@ -322,6 +331,7 @@ export const ClientRecordSchema = z.object({ enable: z.boolean(), expiryTime: z.number().int(), flow: z.string(), + forwardedPorts: z.string(), group: z.string(), id: z.number().int(), keepAlive: z.number().int(), @@ -513,7 +523,7 @@ export const InboundSchema = z.object({ nodeId: z.number().int().nullable().optional(), originNodeGuid: z.string().optional(), 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(), settings: z.unknown(), shareAddr: z.string(), @@ -550,6 +560,7 @@ export const InboundFallbackSchema = z.object({ export type InboundFallback = z.infer; export const InboundOptionSchema = z.object({ + awgServer: z.lazy(() => ServerSettingsSchema).nullable().optional(), enable: z.boolean(), id: z.number().int(), listen: z.string().optional(), @@ -703,6 +714,20 @@ export const PanelUpdateStatusSchema = z.object({ }); export type PanelUpdateStatus = z.infer; +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; + export const ProbeResultUISchema = z.object({ cpuPct: z.number(), error: z.string(), @@ -741,6 +766,47 @@ export const RealityScanResultSchema = z.object({ }); export type RealityScanResult = z.infer; +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; + export const SettingSchema = z.object({ id: z.number().int(), key: z.string(), diff --git a/frontend/src/lib/xray/amneziawg-obfuscation.ts b/frontend/src/lib/xray/amneziawg-obfuscation.ts new file mode 100644 index 000000000..59dd5782a --- /dev/null +++ b/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: ``, + 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, + }; +} diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index ab51f710c..21bef0966 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -1,5 +1,7 @@ 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 { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria'; import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed'; @@ -263,12 +265,20 @@ export interface WireguardInboundSeed { mtu?: number; secretKey?: string; noKernelTun?: boolean; + subnetIp?: string; + subnetCidr?: number; } // 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 // through the client modal, which generates each one's keypair and a unique // 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( seed: WireguardInboundSeed = {}, ): WireguardInboundSettings { @@ -278,6 +288,36 @@ export function createDefaultWireguardInboundSettings( peers: [], clients: [], 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 | TunnelInboundSettings | WireguardInboundSettings - | MtprotoInboundSettings; + | MtprotoInboundSettings + | AmneziawgInboundSettings; export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null { switch (protocol) { @@ -323,6 +364,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin return createDefaultWireguardInboundSettings(); case 'mtproto': return createDefaultMtprotoInboundSettings(); + case 'amneziawg': + return createDefaultAmneziawgInboundSettings(); default: return null; } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 27d39700a..c6b5ad52f 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -5,6 +5,7 @@ import type { } from '@/schemas/forms/inbound-form'; import type { InboundSettings } from '@/schemas/protocols/inbound'; import { + AmneziawgClientSchema, HysteriaClientSchema, MtprotoClientSchema, ShadowsocksClientSchema, @@ -268,6 +269,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null { return WireguardClientSchema; case 'mtproto': return MtprotoClientSchema; + case 'amneziawg': + return AmneziawgClientSchema; default: return null; } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 1a4c42ae7..80ebb59fb 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -1,6 +1,7 @@ import { Base64, Wireguard } from '@/utils'; 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 { VmessSecurity } from '@/schemas/protocols/shared/vmess'; import type { @@ -911,6 +912,168 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string { 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://, 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 { let url: URL; try { @@ -971,6 +1134,34 @@ export function wireguardConfigFromLink(link: string, fallbackRemark = ''): stri 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 }; 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 // pastes into a client. Iterates per-client for protocols with clients, // 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). export function genInboundLinks(input: GenInboundLinksInput): string { const { inbound, remark = '', hostOverride = '', fallbackHostname } = input; @@ -1308,6 +1499,9 @@ export function genInboundLinks(input: GenInboundLinksInput): string { if (inbound.protocol === 'wireguard') { return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname }); } + if (inbound.protocol === 'amneziawg') { + return genAmneziaWGConfigs({ inbound, remark, hostOverride, fallbackHostname }); + } return ''; } diff --git a/frontend/src/lib/xray/inbound-tag.ts b/frontend/src/lib/xray/inbound-tag.ts index f595bab72..8c0006075 100644 --- a/frontend/src/lib/xray/inbound-tag.ts +++ b/frontend/src/lib/xray/inbound-tag.ts @@ -14,7 +14,7 @@ function inboundTransports( streamSettings: Record | undefined, settings: Record | undefined, ): TransportBits { - if (protocol === 'hysteria' || protocol === 'wireguard') return UDP; + if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP; let bits: TransportBits = 0; const network = asString(streamSettings?.network); diff --git a/frontend/src/lib/xray/link-label.tsx b/frontend/src/lib/xray/link-label.tsx index 561fce9bb..7690f6d30 100644 --- a/frontend/src/lib/xray/link-label.tsx +++ b/frontend/src/lib/xray/link-label.tsx @@ -26,6 +26,7 @@ const PROTOCOL_LABELS: Record = { wireguard: 'WireGuard', wg: 'WireGuard', tg: 'MTProto', + vpn: 'AmneziaWG', }; const PROTOCOL_COLORS: Record = { @@ -37,6 +38,7 @@ const PROTOCOL_COLORS: Record = { Hysteria2: 'magenta', WireGuard: 'cyan', MTProto: 'blue', + AmneziaWG: 'yellow', }; const SECURITY_COLORS: Record = { @@ -50,6 +52,18 @@ const TRANSPORT_COLOR = 'gold'; 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 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 @@ -83,6 +97,20 @@ export function parseLinkParts(link: string): LinkParts | null { } catch { /* 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 { try { const url = new URL(trimmed); diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index 9b0a41b17..cff17639a 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -75,10 +75,11 @@ export function canEnableStream(values: { protocol: string }): boolean { 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 { - return values.protocol !== 'mtproto'; + return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg'; } // Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index 5e74b2d2c..fd18970b9 100644 --- a/frontend/src/models/dbinbound.ts +++ b/frontend/src/models/dbinbound.ts @@ -169,6 +169,10 @@ export class DBInbound { return this.protocol === Protocols.WIREGUARD; } + get isAmneziawg() { + return this.protocol === Protocols.AMNEZIAWG; + } + get isHysteria() { return this.protocol === Protocols.HYSTERIA; } diff --git a/frontend/src/models/status.ts b/frontend/src/models/status.ts index d8bfad792..6909717db 100644 --- a/frontend/src/models/status.ts +++ b/frontend/src/models/status.ts @@ -61,6 +61,11 @@ export interface XrayInfo { color: string; } +export interface AmneziaWGInfo { + configured: boolean; + running: boolean; +} + interface StatusInput { cpu?: number; cpuCores?: number; @@ -79,6 +84,7 @@ interface StatusInput { appUptime?: number; appStats?: AppStats; xray?: Partial; + amneziawg?: Partial; } export class Status { @@ -99,6 +105,7 @@ export class Status { appUptime = 0; appStats: AppStats = { threads: 0, mem: 0, uptime: 0 }; xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' }; + amneziawg: AmneziaWGInfo = { configured: false, running: false }; constructor(data?: StatusInput | null) { if (data == null) return; @@ -121,5 +128,6 @@ export class Status { this.appStats = data.appStats ?? this.appStats; this.xray = { ...this.xray, ...(data.xray || {}) }; this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray'; + this.amneziawg = { ...this.amneziawg, ...(data.amneziawg || {}) }; } } diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 839825e99..beff3de9e 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -608,6 +608,28 @@ export const sections: readonly Section[] = [ 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}', }, + { + 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', path: '/panel/api/server/importDB', diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx index 6b071ea32..cbe3cad3f 100644 --- a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx @@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([ 'shadowsocks', 'wireguard', 'mtproto', + 'amneziawg', ]); interface BulkAttachInboundsModalProps { diff --git a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx index 2bf888203..8bd3d1571 100644 --- a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx @@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([ 'shadowsocks', 'wireguard', 'mtproto', + 'amneziawg', ]); interface BulkDetachInboundsModalProps { diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index d0d87c33c..95ab17c7c 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -36,6 +36,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([ 'trojan', 'hysteria', 'wireguard', + 'amneziawg', ]); const EMPTY: ClientBulkAddFormValues = { diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index e9b2e3203..f7b28f42c 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -60,6 +60,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([ 'hysteria', 'wireguard', 'mtproto', + 'amneziawg', ]); const CLIENT_FORM_MODAL_Z_INDEX = 1000; @@ -110,6 +111,7 @@ interface ClientFormModalProps { inbounds: InboundOption[]; attachedExternalLinks?: ExternalLink[]; attachedIds?: number[]; + tunnelAllowedIPs?: Record; tgBotEnable?: boolean; groups?: string[]; save: ( @@ -128,6 +130,8 @@ type Values = ClientFormValues & { wgPublicKey: string; wgPreSharedKey: string; wgAllowedIPs: string; + awgAllowedIPs: string; + awgForwardedPorts: string; secret: string; adTag: string; }; @@ -162,6 +166,8 @@ const EMPTY: Values = { wgPublicKey: '', wgPreSharedKey: '', wgAllowedIPs: '', + awgAllowedIPs: '', + awgForwardedPorts: '', secret: '', adTag: '', }; @@ -189,6 +195,34 @@ export function gbToBytes(gb: number): number { 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, + amneziawgInboundIds: Set, + wgAllowedIPs: string[], + awgAllowedIPs: string[], +): Record { + const wgId = attachedInboundIds.find((id) => wireguardInboundIds.has(id)); + const awgId = attachedInboundIds.find((id) => amneziawgInboundIds.has(id)); + const result: Record = {}; + if (wgId != null) result[wgId] = wgAllowedIPs; + if (awgId != null) result[awgId] = awgAllowedIPs; + return result; +} + export function resolveTotalBytes( originalBytes: number | null | undefined, displayedGB: number, @@ -206,6 +240,7 @@ export default function ClientFormModal({ inbounds, attachedExternalLinks = [], attachedIds = [], + tunnelAllowedIPs = {}, tgBotEnable = false, groups = [], save, @@ -262,6 +297,27 @@ export default function ClientFormModal({ const limitIpDisabled = !fail2ban.usable; 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(); + for (const row of inbounds || []) { + if (row && row.protocol === 'wireguard') ids.add(row.id); + } + return ids; + }, [inbounds]); + + const amneziawgIds = useMemo(() => { + const ids = new Set(); + for (const row of inbounds || []) { + if (row && row.protocol === 'amneziawg') ids.add(row.id); + } + return ids; + }, [inbounds]); + function addExternalLinkRow(kind: 'link' | 'subscription') { appendExternalLink({ kind, @@ -282,6 +338,13 @@ export default function ClientFormModal({ if (isEdit && client) { 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 = { ...EMPTY, email: client.email || '', @@ -312,7 +375,9 @@ export default function ClientFormModal({ wgPrivateKey: client.privateKey || '', wgPublicKey: client.publicKey || '', wgPreSharedKey: client.preSharedKey || '', - wgAllowedIPs: client.allowedIPs || '', + wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '', + awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '', + awgForwardedPorts: client.forwardedPorts || '', secret: client.secret || '', adTag: client.adTag || '', }; @@ -369,14 +434,6 @@ export default function ClientFormModal({ return ids; }, [inbounds]); - const wireguardIds = useMemo(() => { - const ids = new Set(); - for (const row of inbounds || []) { - if (row && row.protocol === 'wireguard') ids.add(row.id); - } - return ids; - }, [inbounds]); - const mtprotoIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { @@ -431,6 +488,11 @@ export default function ClientFormModal({ [inboundIds, wireguardIds], ); + const showAmneziawg = useMemo( + () => (inboundIds || []).some((id) => amneziawgIds.has(id)), + [inboundIds, amneziawgIds], + ); + const showMtproto = useMemo( () => (inboundIds || []).some((id) => mtprotoIds.has(id)), [inboundIds, mtprotoIds], @@ -625,18 +687,40 @@ export default function ClientFormModal({ 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.publicKey = values.wgPublicKey; if (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({ /> )} - {showWireguard && ( + {(showWireguard || showAmneziawg) && ( <> - + - - - + {showWireguard && showAmneziawg ? ( + <> + + + + + + + + ) : ( + + + + )} + {showAmneziawg && ( + + + + )} )} {showMtproto && ( diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index af1986114..52a31ea98 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -25,6 +25,11 @@ import { findWireguardInbound, isWireguardClient, } from './wireguardConfig'; +import { + buildAmneziaWGClientConfig, + findAmneziaWGInbound, + isAmneziaWGClient, +} from './amneziawgConfig'; import './ClientInfoModal.css'; const INBOUND_PROTOCOL_COLORS: Record = { @@ -35,6 +40,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { hysteria: 'cyan', hysteria2: 'green', wireguard: 'gold', + amneziawg: 'yellow', http: 'purple', mixed: 'lime', tunnel: 'orange', @@ -56,6 +62,7 @@ interface ClientInfoModalProps { open: boolean; client: ClientRecord | null; inboundsById: Record; + tunnelAllowedIPs?: Record; isOnline: boolean; subSettings?: SubSettings; onOpenChange: (open: boolean) => void; @@ -86,6 +93,7 @@ export default function ClientInfoModal({ open, client, inboundsById, + tunnelAllowedIPs, isOnline, subSettings = DEFAULT_SUB, onOpenChange, @@ -186,6 +194,22 @@ export default function ClientInfoModal({ ); }, [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) { if (!text) return; const ok = await ClipboardManager.copyText(String(text)); @@ -766,6 +790,18 @@ export default function ClientInfoModal({ /> )} + + {awgConfigText && client && ( + <> + {t('pages.clients.amneziaWgConfig')} + + + )} )} diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index d7c74d456..069167106 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -11,6 +11,11 @@ import { findWireguardInbound, isWireguardClient, } from './wireguardConfig'; +import { + buildAmneziaWGClientConfig, + findAmneziaWGInbound, + isAmneziaWGClient, +} from './amneziawgConfig'; interface SubSettings { enable: boolean; @@ -24,6 +29,7 @@ interface ClientQrModalProps { open: boolean; client: ClientRecord | null; inboundsById: Record; + tunnelAllowedIPs?: Record; subSettings?: SubSettings; onOpenChange: (open: boolean) => void; } @@ -45,6 +51,7 @@ export default function ClientQrModal({ open, client, inboundsById, + tunnelAllowedIPs, subSettings = DEFAULT_SUB, onOpenChange, }: ClientQrModalProps) { @@ -74,7 +81,24 @@ export default function ClientQrModal({ ); }, [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. const openSubId = open ? (client?.subId ?? '') : ''; @@ -165,8 +189,25 @@ export default function ClientQrModal({ ), }); } + if (awgConfigText) { + out.push({ + key: 'awg-config', + label: ( + + {t('pages.clients.amneziaWgConfig')} + + ), + children: ( + + ), + }); + } 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. const firstKey = open && items.length > 0 ? items[0].key : null; diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 049610daf..8293c6101 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -171,6 +171,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { hysteria: 'cyan', hysteria2: 'green', wireguard: 'gold', + amneziawg: 'yellow', http: 'purple', mixed: 'lime', tunnel: 'orange', @@ -349,10 +350,16 @@ export default function ClientsPage() { const [editingClient, setEditingClient] = useState(null); const [editingAttachedIds, setEditingAttachedIds] = useState([]); const [editingExternalLinks, setEditingExternalLinks] = useState([]); + const [editingTunnelAllowedIPs, setEditingTunnelAllowedIPs] = useState>( + {}, + ); const [infoOpen, setInfoOpen] = useState(false); const [infoClient, setInfoClient] = useState(null); const [qrOpen, setQrOpen] = useState(false); const [qrClient, setQrClient] = useState(null); + const [viewingTunnelAllowedIPs, setViewingTunnelAllowedIPs] = useState>( + {}, + ); const [bulkAddOpen, setBulkAddOpen] = useState(false); const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false); const [subLinksOpen, setSubLinksOpen] = useState(false); @@ -619,6 +626,7 @@ export default function ClientsPage() { setEditingClient(null); setEditingAttachedIds([]); setEditingExternalLinks([]); + setEditingTunnelAllowedIPs({}); setFormOpen(true); } @@ -635,6 +643,7 @@ export default function ClientsPage() { const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []); setEditingAttachedIds([...ids]); setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []); + setEditingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {}); setFormOpen(true); }, [hydrate], @@ -686,6 +695,7 @@ export default function ClientsPage() { if (!row) return; const full = await hydrate(row.email); setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); + setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {}); setInfoOpen(true); }, [hydrate], @@ -697,6 +707,7 @@ export default function ClientsPage() { if (!row) return; const full = await hydrate(row.email); setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); + setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {}); setQrOpen(true); }, [hydrate], @@ -1838,6 +1849,7 @@ export default function ClientsPage() { client={editingClient} attachedIds={editingAttachedIds} attachedExternalLinks={editingExternalLinks} + tunnelAllowedIPs={editingTunnelAllowedIPs} inbounds={inbounds} tgBotEnable={tgBotEnable} groups={allGroups} @@ -1851,6 +1863,7 @@ export default function ClientsPage() { open={infoOpen} client={infoClient} inboundsById={inboundsById} + tunnelAllowedIPs={viewingTunnelAllowedIPs} isOnline={infoClient ? isOnline(infoClient.email) : false} subSettings={subSettings} onOpenChange={setInfoOpen} @@ -1861,6 +1874,7 @@ export default function ClientsPage() { open={qrOpen} client={qrClient} inboundsById={inboundsById} + tunnelAllowedIPs={viewingTunnelAllowedIPs} subSettings={subSettings} onOpenChange={setQrOpen} /> diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts new file mode 100644 index 000000000..d92dc242f --- /dev/null +++ b/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, +): 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'); +} diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index 260b6064f..f2a6627aa 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -39,6 +39,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { hysteria: 'cyan', hysteria2: 'green', wireguard: 'gold', + amneziawg: 'yellow', http: 'purple', mixed: 'lime', tunnel: 'orange', diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index e98b791bf..8f12aba93 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -25,8 +25,14 @@ import { import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils'; import { buildClonePayload } from '@/lib/xray/inbound-clone'; 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 { Protocols } from '@/schemas/primitives'; import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; @@ -335,7 +341,16 @@ export default function InboundsPage() { 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({ title: t('pages.inbounds.exportLinksTitle'), content, diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 0a7edf4de..65664e3f2 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/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 { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter'; 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 { canEnableReality, @@ -56,6 +57,7 @@ import './InboundFormModal.css'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; import { formatInboundIssue, formatInboundValidation } from './formatValidationError'; import { + AmneziawgFields, HttpFields, HysteriaFields, MixedFields, @@ -347,6 +349,41 @@ export default function InboundFormModal({ 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 = ( block: { id?: string; label?: string } | undefined | null, authId: string, @@ -740,6 +777,14 @@ export default function InboundFormModal({ )} + {protocol === Protocols.AMNEZIAWG && ( + + )} + {protocol === Protocols.TUN && } {protocol === Protocols.TUNNEL && } @@ -1077,6 +1122,7 @@ export default function InboundFormModal({ Protocols.TUN, Protocols.WIREGUARD, Protocols.MTPROTO, + Protocols.AMNEZIAWG, ] as string[] ).includes(protocol) || isFallbackHost ? [ diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx new file mode 100644 index 000000000..5a6ac758c --- /dev/null +++ b/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 ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/inbounds/form/protocols/index.ts b/frontend/src/pages/inbounds/form/protocols/index.ts index 455f66c09..3d9341c96 100644 --- a/frontend/src/pages/inbounds/form/protocols/index.ts +++ b/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 MtprotoFields } from './mtproto'; export { default as VlessFields } from './vless'; +export { default as AmneziawgFields } from './amneziawg'; diff --git a/frontend/src/pages/inbounds/form/protocols/wireguard.tsx b/frontend/src/pages/inbounds/form/protocols/wireguard.tsx index a8142bacb..0a495337f 100644 --- a/frontend/src/pages/inbounds/form/protocols/wireguard.tsx +++ b/frontend/src/pages/inbounds/form/protocols/wireguard.tsx @@ -24,6 +24,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF + + + + + + diff --git a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx index 0e9305042..5f17fc1d7 100644 --- a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx +++ b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx @@ -10,6 +10,8 @@ import { InfinityIcon } from '@/components/ui'; import { useDatepicker } from '@/hooks/useDatepicker'; import { genAllLinks, + genAmneziaWGConfigs, + genAmneziaWGLinks, genWireguardConfigs, genWireguardLinks, preferPublicHost, @@ -49,6 +51,8 @@ export default function InboundInfoModal({ const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]); const [wireguardConfigs, setWireguardConfigs] = useState([]); const [wireguardLinks, setWireguardLinks] = useState([]); + const [amneziawgConfigs, setAmneziawgConfigs] = useState([]); + const [amneziawgLinks, setAmneziawgLinks] = useState([]); const [subLink, setSubLink] = useState(''); const [subJsonLink, setSubJsonLink] = useState(''); const [refreshing, setRefreshing] = useState(false); @@ -153,6 +157,28 @@ export default function InboundInfoModal({ fallbackHostname, }).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([]); } else { setLinks( @@ -166,6 +192,8 @@ export default function InboundInfoModal({ ); setWireguardConfigs([]); setWireguardLinks([]); + setAmneziawgConfigs([]); + setAmneziawgLinks([]); } if (clientSet?.subId) { @@ -1198,6 +1226,58 @@ export default function InboundInfoModal({ )} + {inbound?.protocol === Protocols.AMNEZIAWG && amneziawgConfigs.length > 0 && ( + <> + {t('pages.inbounds.copyLink')} + {amneziawgConfigs.map((cfg, idx) => ( + + {cfg && ( +
+
+ + {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })} + + +
+ {cfg} +
+ )} + {amneziawgLinks[idx] && ( +
+
+ Peer {idx + 1} link + +
+ {amneziawgLinks[idx]} +
+ )} +
+ ))} + + )} + {dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && ( <> {t('pages.inbounds.copyLink')} diff --git a/frontend/src/pages/inbounds/list/helpers.ts b/frontend/src/pages/inbounds/list/helpers.ts index 0d244851f..dd3595966 100644 --- a/frontend/src/pages/inbounds/list/helpers.ts +++ b/frontend/src/pages/inbounds/list/helpers.ts @@ -89,6 +89,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown case 'hysteria': case 'mtproto': case 'wireguard': + case 'amneziawg': return true; case 'shadowsocks': return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) }); diff --git a/frontend/src/pages/inbounds/list/types.ts b/frontend/src/pages/inbounds/list/types.ts index b623e8b22..1ca44c370 100644 --- a/frontend/src/pages/inbounds/list/types.ts +++ b/frontend/src/pages/inbounds/list/types.ts @@ -15,6 +15,7 @@ export type ProtocolFlags = { isMixed?: boolean; isHTTP?: boolean; isWireguard?: boolean; + isAmneziawg?: boolean; isTunnel?: boolean; }; diff --git a/frontend/src/pages/inbounds/list/useInboundColumns.tsx b/frontend/src/pages/inbounds/list/useInboundColumns.tsx index 8fa55f23f..bfefec7ad 100644 --- a/frontend/src/pages/inbounds/list/useInboundColumns.tsx +++ b/frontend/src/pages/inbounds/list/useInboundColumns.tsx @@ -199,7 +199,7 @@ export function useInboundColumns({ {record.protocol} , ]; - if (record.isWireguard || record.isHysteria) { + if (record.isWireguard || record.isAmneziawg || record.isHysteria) { tags.push( UDP diff --git a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx b/frontend/src/pages/inbounds/qr/QrCodeModal.tsx index 2834eeb46..384a98857 100644 --- a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx +++ b/frontend/src/pages/inbounds/qr/QrCodeModal.tsx @@ -6,6 +6,8 @@ import type { CollapseProps } from 'antd'; import { Protocols } from '@/schemas/primitives'; import { genAllLinks, + genAmneziaWGConfigs, + genAmneziaWGLinks, genWireguardConfigs, genWireguardLinks, isPostQuantumLink, @@ -50,6 +52,8 @@ export default function QrCodeModal({ const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]); const [wireguardConfigs, setWireguardConfigs] = useState([]); const [wireguardLinks, setWireguardLinks] = useState([]); + const [amneziawgConfigs, setAmneziawgConfigs] = useState([]); + const [amneziawgLinks, setAmneziawgLinks] = useState([]); const [subLink, setSubLink] = useState(''); const [subJsonLink, setSubJsonLink] = useState(''); const [activeKey, setActiveKey] = useState([]); @@ -97,6 +101,31 @@ export default function QrCodeModal({ fallbackHostname, }).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([]); } else { setLinks( @@ -110,6 +139,8 @@ export default function QrCodeModal({ ); setWireguardConfigs([]); setWireguardLinks([]); + setAmneziawgConfigs([]); + setAmneziawgLinks([]); } 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; - }, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]); + }, [ + subLink, + subJsonLink, + links, + wireguardConfigs, + wireguardLinks, + amneziawgConfigs, + amneziawgLinks, + t, + ]); const collapseItems: CollapseProps['items'] = useMemo( () => diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index 6ecbd835c..5a3d2abf7 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -66,6 +66,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [ Protocols.HYSTERIA, Protocols.WIREGUARD, Protocols.MTPROTO, + Protocols.AMNEZIAWG, ]; async function fetchSlimInbounds(): Promise { diff --git a/frontend/src/pages/index/AmneziaWGLogModal.css b/frontend/src/pages/index/AmneziaWGLogModal.css new file mode 100644 index 000000000..5215913f5 --- /dev/null +++ b/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; +} diff --git a/frontend/src/pages/index/AmneziaWGLogModal.tsx b/frontend/src/pages/index/AmneziaWGLogModal.tsx new file mode 100644 index 000000000..83ee78070 --- /dev/null +++ b/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>({}); + + const peers = useMemo(() => logs.peers ?? [], [logs.peers]); + const events = useMemo(() => logs.events ?? [], [logs.events]); + + const runRefresh = useCallback(async () => { + try { + const msg = await HttpUtil.post(`/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(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 ( + + {t('pages.index.amneziawgLogs')} + + + } + > +
+ + setFilter(e.target.value)} + onKeyUp={(e) => { + if (e.key === 'Enter') refresh(); + }} + /> + + + setAutoUpdate(e.target.checked)}> + {t('pages.index.autoUpdate')} + + + +