From 56ac8d0fc7c5df9c3bc84cc933967a1a493521bc Mon Sep 17 00:00:00 2001 From: YoungReckless4 <132759713+YoungReckless4@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:24:50 +0300 Subject: [PATCH] feat(amneziawg): add opt-in Linux kernel datapath (#6877) * feat(amneziawg): add opt-in Linux kernel datapath Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply. Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed. * fix(amneziawg): align readiness and endpoint reporting Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support. Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass. * style(amneziawg): format readiness test * test: reproduce kernel AWG client validation gaps * test(service): cover kernel AWG shared client fanout * fix(amneziawg): validate kernel client fanout and config values * test(amneziawgkern): cover owned interface lifecycle recovery * fix(amneziawgkern): recover owned interfaces on restart * test(amneziawgkern): retain visibility after recovered cleanup failure * fix(amneziawgkern): report recovered interfaces while cleanup retries * style(amneziawgkern): satisfy static analysis * test(amneziawgkern): cover long rendered peer comments * fix(amneziawgkern): accept long generated config comments * test(amneziawgkern): refuse foreign occupied interfaces Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries. * test(amneziawgkern): cover stale interface collisions Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown. * fix(amneziawgkern): verify interface ownership before mutation Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs. Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check. * build: update Go toolchain to 1.27.3 * test(amneziawg): reproduce unsafe key rotation recovery Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure. * fix(amneziawg): recover interrupted key rotations Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership. * fix(scanner): align Go image with module version * fix(docs): pin pnpm release with bootstrap fix pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely. * fix(xray): build core from pinned module The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets. * fix(tests): stabilize local network fixtures Keep the TUIC stream fragmentation check on a working UDP loopback path, allocate the AmneziaWG fixture ports dynamically, and wait for a real Xray stats response before starting API mutation assertions. Recreate failed readiness channels so an early connection cannot stall the probe. Verified host and offline parallel Go suites, repeated occupied-port and UDP tests, race/shuffle runs, and golangci-lint. Production behavior and dependency versions are unchanged. * fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK main gained the sk-SK locale after this branch added its kernel-mode keys to the other 13, so i18n-dead-keys.test.ts failed on the merged tree. * refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick awg-quick evaluates PostUp/PostDown through a root shell, so every value the panel rendered into its config was one validation slip away from root command execution. The interface is now built with direct ip/awg/nft execs from a setconf-only file: link add, awg setconf, address, mtu, an atomically replaced nftables table per inbound, and the IPv4 forwarding sysctl written directly. - Panel-owned names and paths: xui-awg interfaces, xui_awg NAT tables and configs under the panel's data dir, so nothing collides with an operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg. - FORWARD accepts (comment 3x-ui:xui-awg) when iptables is present: Docker and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel. - A full panel stop records the final counters and tears the interfaces down, like the embedded datapath; a stopped panel enforces no quota or expiry. Crash survivors are still adopted without a rebuild. - The job reads counters before reconciling, and from every managed interface, so a client disabled for quota, or a removed inbound, is charged for its last interval instead of losing it with the dropped peer. - An unchanged reconcile tick forks no process (it probed the live key with `awg show` every 10s); readiness no longer loads the module from a GET. Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM teardown leaving no link, table, FORWARD rule or config behind. * fix(amneziawg): gate kernel host readiness only when a save starts the datapath Every save of a kernel-mode inbound re-ran the host preflight, so a host that lost its module (a kernel upgrade without a DKMS rebuild) refused even disabling the inbound or switching it back to embedded mode. Host blockers now apply only when the save starts the kernel datapath: a new inbound, a switch from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded ports, pinned listen) still apply to every save. * feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it A node built before kernel mode drops the unknown kernelMode field and serves the inbound on the embedded datapath while the master's form shows kernel mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses to push a kernel-mode inbound to a node that has not advertised it, learning the caps with an inbound list first when this Remote has not heard from it. * fix(amneziawg): pin the kernel module installer to verified commits The installer ran as root and built whatever upstream tag sorted newest at install time. It now clones fixed tags (module v3.1.20260906, tools v3.1.20260812) and refuses unless each resolves to its recorded commit. The udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat upstream, so the patch never matched. * fix(i18n): translate the kernel-mode AmneziaWG strings All thirteen non-English locales carried the English text verbatim. * fix(amneziawg): skip the kernel traffic flush when no kernel interface runs Shutdown listed the desired AmneziaWG inbounds from the database even on a panel with no kernel interface, an unneeded query on every stop. A server stopped before its database was opened panicked on it, which turned TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first. --------- Co-authored-by: MHSanaei --- docs/content/docs/en/config/amneziawg.mdx | 56 +- .../docs/en/reference/api/inbounds.mdx | 15 +- docs/public/openapi.json | 97 +++ frontend/public/openapi.json | 97 +++ .../queries/useAmneziawgKernelReadiness.ts | 25 + frontend/src/api/queryKeys.ts | 1 + frontend/src/generated/examples.ts | 15 + frontend/src/generated/schemas.ts | 52 ++ frontend/src/generated/types.ts | 13 + frontend/src/generated/zod.ts | 17 + frontend/src/lib/xray/inbound-defaults.ts | 1 + frontend/src/pages/api-docs/endpoints.ts | 7 + .../inbounds/form/protocols/amneziawg.tsx | 54 +- .../schemas/protocols/inbound/amneziawg.ts | 3 + .../test/amneziawg-kernel-readiness.test.tsx | 79 +++ internal/amneziawg/instance.go | 11 +- internal/amneziawg/params.go | 10 +- internal/amneziawg/types.go | 20 +- internal/amneziawgkern/config.go | 343 ++++++++++ internal/amneziawgkern/config_test.go | 227 +++++++ internal/amneziawgkern/manager.go | 623 +++++++++++++++++ internal/amneziawgkern/manager_test.go | 639 ++++++++++++++++++ internal/amneziawgkern/ownership.go | 144 ++++ internal/amneziawgkern/preflight.go | 211 ++++++ internal/amneziawgkern/preflight_test.go | 117 ++++ internal/amneziawgkern/stats.go | 414 ++++++++++++ internal/amneziawgkern/stats_test.go | 312 +++++++++ internal/util/wirecodec/wirecodec.go | 5 + internal/web/controller/inbound.go | 8 + internal/web/job/amneziawg_job.go | 179 ++++- .../job/amneziawg_job_traffic_restart_test.go | 234 +++++++ internal/web/job/check_client_ip_job.go | 21 +- internal/web/middleware/config_envelope.go | 2 +- internal/web/runtime/local.go | 66 +- .../runtime/local_amneziawg_datapath_test.go | 70 ++ internal/web/runtime/remote.go | 51 +- internal/web/runtime/remote_envelope_test.go | 41 ++ internal/web/service/client_crud.go | 6 + internal/web/service/client_inbound_apply.go | 53 +- internal/web/service/inbound.go | 9 +- internal/web/service/inbound_amneziawg.go | 39 +- .../web/service/inbound_amneziawg_test.go | 116 +++- internal/web/service/inbound_node.go | 23 +- .../web/service/kernel_client_guard_test.go | 199 ++++++ internal/web/service/port_conflict.go | 50 +- internal/web/service/port_conflict_test.go | 35 + internal/web/service/server.go | 3 +- internal/web/service/xray.go | 5 + internal/web/translation/ar-EG.json | 14 +- internal/web/translation/en-US.json | 14 +- internal/web/translation/es-ES.json | 14 +- internal/web/translation/fa-IR.json | 14 +- internal/web/translation/id-ID.json | 14 +- internal/web/translation/ja-JP.json | 14 +- internal/web/translation/pt-BR.json | 14 +- internal/web/translation/ru-RU.json | 14 +- internal/web/translation/sk-SK.json | 14 +- internal/web/translation/tr-TR.json | 14 +- internal/web/translation/uk-UA.json | 14 +- internal/web/translation/vi-VN.json | 14 +- internal/web/translation/zh-CN.json | 14 +- internal/web/translation/zh-TW.json | 14 +- internal/web/web.go | 3 + main.go | 51 ++ scripts/install-amneziawg-kernel.sh | 77 +++ tools/openapigen/main.go | 5 + 66 files changed, 5006 insertions(+), 144 deletions(-) create mode 100644 frontend/src/api/queries/useAmneziawgKernelReadiness.ts create mode 100644 frontend/src/test/amneziawg-kernel-readiness.test.tsx create mode 100644 internal/amneziawgkern/config.go create mode 100644 internal/amneziawgkern/config_test.go create mode 100644 internal/amneziawgkern/manager.go create mode 100644 internal/amneziawgkern/manager_test.go create mode 100644 internal/amneziawgkern/ownership.go create mode 100644 internal/amneziawgkern/preflight.go create mode 100644 internal/amneziawgkern/preflight_test.go create mode 100644 internal/amneziawgkern/stats.go create mode 100644 internal/amneziawgkern/stats_test.go create mode 100644 internal/web/job/amneziawg_job_traffic_restart_test.go create mode 100644 internal/web/runtime/local_amneziawg_datapath_test.go create mode 100644 internal/web/service/kernel_client_guard_test.go create mode 100644 scripts/install-amneziawg-kernel.sh diff --git a/docs/content/docs/en/config/amneziawg.mdx b/docs/content/docs/en/config/amneziawg.mdx index ab4e79cfb..08ee6abfc 100644 --- a/docs/content/docs/en/config/amneziawg.mdx +++ b/docs/content/docs/en/config/amneziawg.mdx @@ -1,6 +1,6 @@ --- 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. +description: Configure embedded or optional Linux kernel AmneziaWG inbounds in 3x-ui. icon: Lock --- @@ -116,13 +116,53 @@ 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. +In the **embedded** backend, each peer relays into its own loopback SOCKS5 +inbound, tagged with the AmneziaWG inbound's tag. Its traffic therefore uses +Xray routing and appears on the [Routing](/docs/operations/outbounds-routing) +page, like other Xray inbounds. + +## Optional Linux kernel backend + +The embedded backend remains the default. On a supported Linux host, an +administrator can opt an individual inbound into the kernel backend with +**Kernel mode**. The panel then creates an `xui-awg` interface on the +AmneziaWG kernel module, configures it with `awg setconf`, and masquerades +peer traffic out of the host NIC with an nftables table of its own +(`xui_awg`). Peer traffic bypasses Xray. Nothing runs through a shell: +every step is a direct `ip`, `awg` or `nft` command, and the interface's +config lives in a panel-private directory (`/etc/x-ui/amneziawg` on a standard +install), never next to your own `awg-quick` configs. + +Kernel mode requires a Linux host running the panel as root, with the +`amneziawg` module installed and `awg`, `ip` and `nft` available. The +readiness panel reports missing host requirements and provides the install +command. On a standard install, run +`sudo /usr/local/x-ui/x-ui install-awg-kernel`. The bundled command builds a +pinned, commit-verified release of the module and tools on Debian and Ubuntu; +kernel headers and, when Secure Boot is enabled, module signing must be +available. Review the script and host changes before running it. + +The panel enables IPv4 forwarding and, when `iptables` is installed, adds two +`FORWARD` accept rules per interface (commented `3x-ui:xui-awg`), so a host +whose `FORWARD` policy is `DROP` — as Docker and ufw set it — still forwards +the tunnel. A firewall that drops forwarded traffic in its own nftables table, +such as firewalld, still needs a rule from you. + +Kernel mode is not supported in Docker. Embedded mode continues to work in +containers. The kernel backend does not support Xray routing or sniffing, +per-client IPv6, client port forwarding, or a pinned listen address; the panel +rejects saves that request those features. On a multi-node setup the master +checks that the target node supports kernel mode before pushing such an +inbound, and the node checks its own host when it applies it. + +Stopping the panel records the interfaces' final traffic and then tears them +down, exactly as the embedded backend stops: a stopped panel enforces no quota +or expiry, so nothing keeps forwarding. A panel-only restart (`SIGHUP`) leaves +them running. If the panel process dies instead, the next start adopts the +surviving interfaces; traffic between the crash and the first counter read +after it is not charged. Interfaces and configs the panel cannot prove are its +own — a same-named link with a different key, or an unmarked, non-regular or +symlinked config file — are never changed or removed. ## Keepalive ranges diff --git a/docs/content/docs/en/reference/api/inbounds.mdx b/docs/content/docs/en/reference/api/inbounds.mdx index 3eaac929a..5eae788b1 100644 --- a/docs/content/docs/en/reference/api/inbounds.mdx +++ b/docs/content/docs/en/reference/api/inbounds.mdx @@ -33,6 +33,13 @@ _openapi: clientStats so the payload stays small even on panels with thousands of clients. url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients' + - depth: 2 + title: Whether this host can run an AmneziaWG inbound on the kernel module. + Returns ready, host blocker codes (root/tools/module/nft), and the + command that fixes them. Saving an inbound also checks its features and + rejects IPv6, port forwarding, routeThroughXray, or a pinned listen + address that the kernel backend cannot honor. + url: '#whether-this-host-can-run-an-amneziawg-inbound-on-the-kernel-module-returns-ready-host-blocker-codes-roottoolsmodulenft-and-the-command-that-fixes-them-saving-an-inbound-also-checks-its-features-and-rejects-ipv6-port-forwarding-routethroughxray-or-a-pinned-listen-address-that-the-kernel-backend-cannot-honor' - depth: 2 title: Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. @@ -131,6 +138,12 @@ _openapi: clientStats so the payload stays small even on panels with thousands of clients. id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients + - content: Whether this host can run an AmneziaWG inbound on the kernel module. + Returns ready, host blocker codes (root/tools/module/nft), and the + command that fixes them. Saving an inbound also checks its features + and rejects IPv6, port forwarding, routeThroughXray, or a pinned + listen address that the kernel backend cannot honor. + id: whether-this-host-can-run-an-amneziawg-inbound-on-the-kernel-module-returns-ready-host-blocker-codes-roottoolsmodulenft-and-the-command-that-fixes-them-saving-an-inbound-also-checks-its-features-and-rejects-ipv6-port-forwarding-routethroughxray-or-a-pinned-listen-address-that-the-kernel-backend-cannot-honor - content: Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured @@ -201,7 +214,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 f470c1811..86a85aca4 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1703,6 +1703,23 @@ ], "type": "object" }, + "Blocker": { + "description": "Blocker is one unmet requirement.", + "properties": { + "code": { + "example": "tools", + "type": "string" + }, + "detail": { + "example": "awg", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, "Client": { "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", "properties": { @@ -4331,6 +4348,37 @@ ], "type": "object" }, + "Readiness": { + "description": "Readiness is the host's ability to run any kernel-mode inbound at all.", + "properties": { + "blockers": { + "example": [ + { + "code": "tools", + "detail": "awg" + } + ], + "items": { + "$ref": "#/components/schemas/Blocker" + }, + "type": "array" + }, + "installCommand": { + "example": "sudo /usr/local/x-ui/x-ui install-awg-kernel", + "type": "string" + }, + "ready": { + "example": false, + "type": "boolean" + } + }, + "required": [ + "blockers", + "installCommand", + "ready" + ], + "type": "object" + }, "RealityScanResult": { "properties": { "alpn": { @@ -4513,6 +4561,10 @@ "keepaliveTimeout": { "type": "string" }, + "kernelMode": { + "description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.", + "type": "boolean" + }, "maxHandshakeAttempts": { "type": "string" }, @@ -5464,6 +5516,51 @@ } } }, + "/panel/api/inbounds/amneziawgKernelReadiness": { + "get": { + "tags": [ + "Inbounds" + ], + "summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.", + "operationId": "get_panel_api_inbounds_amneziawgKernelReadiness", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/Readiness" + } + } + }, + "example": { + "success": true, + "obj": { + "blockers": [ + { + "code": "tools", + "detail": "awg" + } + ], + "installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel", + "ready": false + } + } + } + } + } + } + } + }, "/panel/api/inbounds/allLinks": { "get": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index f470c1811..86a85aca4 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1703,6 +1703,23 @@ ], "type": "object" }, + "Blocker": { + "description": "Blocker is one unmet requirement.", + "properties": { + "code": { + "example": "tools", + "type": "string" + }, + "detail": { + "example": "awg", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, "Client": { "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", "properties": { @@ -4331,6 +4348,37 @@ ], "type": "object" }, + "Readiness": { + "description": "Readiness is the host's ability to run any kernel-mode inbound at all.", + "properties": { + "blockers": { + "example": [ + { + "code": "tools", + "detail": "awg" + } + ], + "items": { + "$ref": "#/components/schemas/Blocker" + }, + "type": "array" + }, + "installCommand": { + "example": "sudo /usr/local/x-ui/x-ui install-awg-kernel", + "type": "string" + }, + "ready": { + "example": false, + "type": "boolean" + } + }, + "required": [ + "blockers", + "installCommand", + "ready" + ], + "type": "object" + }, "RealityScanResult": { "properties": { "alpn": { @@ -4513,6 +4561,10 @@ "keepaliveTimeout": { "type": "string" }, + "kernelMode": { + "description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.", + "type": "boolean" + }, "maxHandshakeAttempts": { "type": "string" }, @@ -5464,6 +5516,51 @@ } } }, + "/panel/api/inbounds/amneziawgKernelReadiness": { + "get": { + "tags": [ + "Inbounds" + ], + "summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.", + "operationId": "get_panel_api_inbounds_amneziawgKernelReadiness", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/Readiness" + } + } + }, + "example": { + "success": true, + "obj": { + "blockers": [ + { + "code": "tools", + "detail": "awg" + } + ], + "installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel", + "ready": false + } + } + } + } + } + } + } + }, "/panel/api/inbounds/allLinks": { "get": { "tags": [ diff --git a/frontend/src/api/queries/useAmneziawgKernelReadiness.ts b/frontend/src/api/queries/useAmneziawgKernelReadiness.ts new file mode 100644 index 000000000..32de5647e --- /dev/null +++ b/frontend/src/api/queries/useAmneziawgKernelReadiness.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { ReadinessSchema, type Readiness } from '@/generated/zod'; +import { HttpUtil } from '@/utils'; +import { keys } from '@/api/queryKeys'; + +export type KernelReadiness = Readiness; + +async function fetchKernelReadiness(): Promise { + const msg = await HttpUtil.get('/panel/api/inbounds/amneziawgKernelReadiness', undefined, { + silent: true, + }); + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch kernel readiness'); + return ReadinessSchema.parse(msg.obj); +} + +// Probing the module is cheap but not free, and the answer only changes when an +// admin installs something, so a short stale window is plenty. +export function useAmneziawgKernelReadiness(enabled: boolean) { + return useQuery({ + queryKey: keys.inbounds.amneziawgKernelReadiness(), + queryFn: fetchKernelReadiness, + enabled, + staleTime: 30_000, + }); +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 4c6db1446..ee634497b 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -29,6 +29,7 @@ export const keys = { root: () => ['inbounds'] as const, slim: () => ['inbounds', 'slim'] as const, options: () => ['inbounds', 'options'] as const, + amneziawgKernelReadiness: () => ['inbounds', 'amneziawgKernelReadiness'] as const, }, clients: { root: () => ['clients'] as const, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index ca8a9f83c..6c8c5aca5 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -410,6 +410,10 @@ export const EXAMPLES: Record = { "scope": "admin", "token": "new-token-string" }, + "Blocker": { + "code": "tools", + "detail": "awg" + }, "Client": { "adTag": "0123456789abcdef0123456789abcdef", "allowedIPs": [ @@ -1069,6 +1073,16 @@ export const EXAMPLES: Record = { "xrayState": "", "xrayVersion": "25.10.31" }, + "Readiness": { + "blockers": [ + { + "code": "tools", + "detail": "awg" + } + ], + "installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel", + "ready": false + }, "RealityScanResult": { "alpn": "h2", "certChainBytes": 3427, @@ -1115,6 +1129,7 @@ export const EXAMPLES: Record = { "jmax": 0, "jmin": 0, "keepaliveTimeout": "", + "kernelMode": false, "maxHandshakeAttempts": "", "mtu": 0, "primaryDns": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4f795f5f5..8165b101b 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1677,6 +1677,23 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "Blocker": { + "description": "Blocker is one unmet requirement.", + "properties": { + "code": { + "example": "tools", + "type": "string" + }, + "detail": { + "example": "awg", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, "Client": { "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", "properties": { @@ -4305,6 +4322,37 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "Readiness": { + "description": "Readiness is the host's ability to run any kernel-mode inbound at all.", + "properties": { + "blockers": { + "example": [ + { + "code": "tools", + "detail": "awg" + } + ], + "items": { + "$ref": "#/components/schemas/Blocker" + }, + "type": "array" + }, + "installCommand": { + "example": "sudo /usr/local/x-ui/x-ui install-awg-kernel", + "type": "string" + }, + "ready": { + "example": false, + "type": "boolean" + } + }, + "required": [ + "blockers", + "installCommand", + "ready" + ], + "type": "object" + }, "RealityScanResult": { "properties": { "alpn": { @@ -4487,6 +4535,10 @@ export const SCHEMAS: Record = { "keepaliveTimeout": { "type": "string" }, + "kernelMode": { + "description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.", + "type": "boolean" + }, "maxHandshakeAttempts": { "type": "string" }, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index bbfe37d8e..a7e158e36 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -1,4 +1,5 @@ // Code generated by tools/openapigen. DO NOT EDIT. +export type BlockerCode = string; export type GeoKind = string; export type OnlineAPISupport = number; export type ProcessState = string; @@ -408,6 +409,11 @@ export interface ApiTokenView { token?: string; } +export interface Blocker { + code: BlockerCode; + detail?: string; +} + export interface Client { adTag?: string; allowedIPs?: string[]; @@ -973,6 +979,12 @@ export interface ProbeResultUI { xrayVersion: string; } +export interface Readiness { + blockers: Blocker[]; + installCommand: string; + ready: boolean; +} + export interface RealityScanResult { alpn: string; certChainBytes: number; @@ -1018,6 +1030,7 @@ export interface ServerSettings { jmax: number; jmin: number; keepaliveTimeout?: string; + kernelMode?: boolean; maxHandshakeAttempts?: string; mtu?: number; primaryDns: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index cc4a6b0e6..692a2f677 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -1,5 +1,8 @@ // Code generated by tools/openapigen. DO NOT EDIT. import { z } from 'zod'; +export const BlockerCodeSchema = z.string(); +export type BlockerCode = z.infer; + export const GeoKindSchema = z.string(); export type GeoKind = z.infer; @@ -429,6 +432,12 @@ export const ApiTokenViewSchema = z.object({ }); export type ApiTokenView = z.infer; +export const BlockerSchema = z.object({ + code: z.lazy(() => BlockerCodeSchema), + detail: z.string().optional(), +}); +export type Blocker = z.infer; + export const ClientSchema = z.object({ adTag: z.string().optional(), allowedIPs: z.array(z.string()).optional(), @@ -1033,6 +1042,13 @@ export const ProbeResultUISchema = z.object({ }); export type ProbeResultUI = z.infer; +export const ReadinessSchema = z.object({ + blockers: z.array(z.lazy(() => BlockerSchema)), + installCommand: z.string(), + ready: z.boolean(), +}); +export type Readiness = z.infer; + export const RealityScanResultSchema = z.object({ alpn: z.string(), certChainBytes: z.number().int(), @@ -1079,6 +1095,7 @@ export const ServerSettingsSchema = z.object({ jmax: z.number().int(), jmin: z.number().int(), keepaliveTimeout: z.string().optional(), + kernelMode: z.boolean().optional(), maxHandshakeAttempts: z.string().optional(), mtu: z.number().int().optional(), primaryDns: z.string(), diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 43094bb13..46436b25f 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -333,6 +333,7 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting primaryDns: '8.8.8.8', secondaryDns: '8.8.4.4', externalInterface: '', + kernelMode: false, ipv6Enabled: false, ipv6Subnet: '', ipv6ExternalInterface: '', diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f59fb9d40..b8cee9666 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -283,6 +283,13 @@ export const sections: readonly Section[] = [ responseSchema: 'InboundOption', responseSchemaArray: true, }, + { + method: 'GET', + path: '/panel/api/inbounds/amneziawgKernelReadiness', + responseSchema: 'Readiness', + summary: + 'Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.', + }, { method: 'GET', path: '/panel/api/inbounds/allLinks', diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx index 368b6fec5..c70d9aa15 100644 --- a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -1,9 +1,11 @@ import { useId } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, InputNumber, Space, Switch } from 'antd'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { Alert, Button, Form, Input, InputNumber, Space, Switch, Typography } from 'antd'; import { ReloadOutlined } from '@ant-design/icons'; import { FormField } from '@/components/form/rhf'; +import { useAmneziawgKernelReadiness } from '@/api/queries/useAmneziawgKernelReadiness'; interface AmneziawgFieldsProps { awgPubKey: string; @@ -18,6 +20,18 @@ export default function AmneziawgFields({ }: AmneziawgFieldsProps) { const { t } = useTranslation(); const uid = useId(); + const kernelModeOn = useWatch({ name: 'settings.server.kernelMode' }); + const nodeId = useWatch({ name: 'nodeId' }) as number | null | undefined; + const remoteTarget = typeof nodeId === 'number'; + const { formState } = useFormContext(); + const { data: readiness } = useAmneziawgKernelReadiness(!remoteTarget); + const hostReady = remoteTarget || readiness?.ready !== false; + // The master can probe only its local host; a remote node checks on apply. + const savedKernelMode = Boolean( + (formState.defaultValues as { settings?: { server?: { kernelMode?: boolean } } } | undefined) + ?.settings?.server?.kernelMode, + ); + const toggleDisabled = !hostReady && !savedKernelMode; return ( <> @@ -213,6 +227,44 @@ export default function AmneziawgFields({ > + + + + {!hostReady && ( + +
    + {(readiness?.blockers ?? []).map((b) => ( +
  • + {t(`pages.xray.amneziawg.kernelBlocker.${b.code}`, { + defaultValue: b.code, + })} + {b.detail ? ` (${b.detail})` : ''} +
  • + ))} +
+ {readiness?.installCommand ? ( + + {readiness.installCommand} + + ) : null} + + } + /> + )} ); } diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index e781c73a2..06576d06a 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -67,6 +67,9 @@ export const AmneziawgServerSchema = z.object({ // z.object's default unknown-key stripping doesn't silently drop it from // an existing stored settings blob on the next save. routeThroughXray: z.boolean().default(false).optional(), + // kernelMode runs this inbound on the DKMS kernel module instead of the + // embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding. + kernelMode: z.boolean().default(false).optional(), // Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go): // jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail. jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)), diff --git a/frontend/src/test/amneziawg-kernel-readiness.test.tsx b/frontend/src/test/amneziawg-kernel-readiness.test.tsx new file mode 100644 index 000000000..8c8fb267e --- /dev/null +++ b/frontend/src/test/amneziawg-kernel-readiness.test.tsx @@ -0,0 +1,79 @@ +import { Form } from 'antd'; +import { FormProvider, useForm } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; + +import AmneziawgFields from '@/pages/inbounds/form/protocols/amneziawg'; +import { HttpUtil, Msg } from '@/utils'; +import { makeTestQueryClient, renderWithProviders } from './test-utils'; + +interface FormValues { + nodeId: number | null; + settings: { server: { kernelMode: boolean } }; +} + +function Harness({ nodeId, kernelMode }: { nodeId: number | null; kernelMode: boolean }) { + const form = useForm({ + defaultValues: { nodeId, settings: { server: { kernelMode } } }, + }); + return ( + +
+ {}} + regenInboundAwgObfuscation={() => {}} + /> + +
+ ); +} + +const unavailable = new Msg(true, '', { + ready: false, + blockers: [{ code: 'tools', detail: 'awg' }], + installCommand: 'sudo x-ui install-awg-kernel', +}); + +describe('AmneziaWG kernel readiness in the inbound form', () => { + it('shows an informational warning while kernel mode is off', async () => { + vi.spyOn(HttpUtil, 'get').mockResolvedValue(unavailable); + + const { container, getByRole } = renderWithProviders( + , + { queryClient: makeTestQueryClient() }, + ); + + await waitFor(() => expect(getByRole('alert')).toBeTruthy()); + expect(container.querySelector('.ant-alert-info')).toBeTruthy(); + expect(container.querySelector('.ant-alert-warning')).toBeNull(); + }); + + it('shows a warning when an enabled local kernel inbound is unavailable', async () => { + vi.spyOn(HttpUtil, 'get').mockResolvedValue(unavailable); + + const { container, getByRole } = renderWithProviders(, { + queryClient: makeTestQueryClient(), + }); + + await waitFor(() => expect(getByRole('alert')).toBeTruthy()); + expect(container.querySelector('.ant-alert-warning')).toBeTruthy(); + }); + + it('does not use master readiness to block a remote-node inbound', async () => { + const get = vi.spyOn(HttpUtil, 'get').mockResolvedValue(unavailable); + + const { container, getByText } = renderWithProviders(, { + queryClient: makeTestQueryClient(), + }); + + await waitFor(() => expect(container.querySelector('.ant-alert')).toBeNull()); + const kernelModeItem = getByText('Kernel mode').closest('.ant-form-item'); + expect(kernelModeItem?.querySelector('[role="switch"]')?.getAttribute('disabled')).toBeNull(); + expect(get).not.toHaveBeenCalledWith( + '/panel/api/inbounds/amneziawgKernelReadiness', + undefined, + expect.anything(), + ); + }); +}); diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go index 42462062a..8c75af6ed 100644 --- a/internal/amneziawg/instance.go +++ b/internal/amneziawg/instance.go @@ -1,13 +1,3 @@ -// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes -// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the -// pure functions that derive an Instance from a stored inbound row. It no -// longer manages any OS-level interface itself: that was the kernel-module -// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally, -// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor -// netstack, see internal/amneziawgnet) in a hard cutover. This package's -// remaining code is deliberately protocol-shape-only, with no OS dependency -// at all, so both the (now-removed) kernel-module path and the embedded -// path could read -- and, historically, did read -- it identically. package amneziawg import ( @@ -74,6 +64,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { IPv6Enabled: server.IPv6Enabled, IPv6ExternalInterface: server.IPv6ExternalInterface, RouteThroughXray: server.RouteThroughXray, + KernelMode: server.KernelMode, }, true } diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index 9fc912a73..f2a30ce3b 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -366,14 +366,8 @@ func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error { return nil } -// ValidateConfigValue rejects control characters in any value interpolated -// verbatim into a rendered .conf: a newline re-opens an [Interface] section -// whose "PostUp = ..." runs as root the moment whoever downloaded that -// config -- the client app, or an admin importing it into the official -// awg-quick CLI directly -- applies it. The panel's own server side never -// runs awg-quick itself (internal/amneziawgnet applies config via -// amneziawg-go's UAPI, not a parsed text file), but this exact value still -// reaches a real text-based config downstream. field names the value. +// ValidateConfigValue rejects control characters in values written to AWG configs, +// preventing newline injection into config directives and awg-quick hooks. func ValidateConfigValue(field, v string) error { for _, r := range v { if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f { diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go index 6d6a8ef0b..9b755629f 100644 --- a/internal/amneziawg/types.go +++ b/internal/amneziawg/types.go @@ -1,13 +1,5 @@ -// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes -// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the -// pure functions that derive an Instance from a stored inbound row. It has -// no OS dependency of its own: internal/amneziawgnet embeds amneziawg-go -// over a gVisor netstack and owns the actual running interfaces, one -// Manager-managed Device per desired Instance -- see that package's Manager -// for the reconcile-on-tick lifecycle (modeled on internal/mtproto's own -// Manager), and instance.go's own doc comment for how this package's role -// narrowed to protocol-shape-only after the kernel-module (DKMS) + awg-quick -// architecture this fork originally shipped was retired. +// Package amneziawg holds AmneziaWG's DB-backed shapes and derives an Instance from an inbound row, with no OS +// dependency; internal/amneziawgnet (embedded, default) and internal/amneziawgkern (opt-in kernel module) run it. package amneziawg import "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -124,6 +116,10 @@ type Instance struct { // for backward compatibility, but not acted on by anything. Slated for // removal alongside the frontend toggle in a follow-up. RouteThroughXray bool + + // KernelMode selects internal/amneziawgkern over internal/amneziawgnet as + // this inbound's datapath. See ServerSettings.KernelMode. + KernelMode bool } // ServerSettings is the "server" block of an AmneziaWG inbound's Settings @@ -160,6 +156,10 @@ type ServerSettings struct { RouteThroughXray bool `json:"routeThroughXray,omitempty"` + // KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern) + // instead of the embedded amneziawg-go + gVisor stack. + KernelMode bool `json:"kernelMode,omitempty"` + // Obfuscation31's fields, repeated flat (not embedded) rather than // nested under their own key: encoding/json would happily inline an // embedded Obfuscation31 the same way, but the frontend's Go->Zod/TS diff --git a/internal/amneziawgkern/config.go b/internal/amneziawgkern/config.go new file mode 100644 index 000000000..c53107d99 --- /dev/null +++ b/internal/amneziawgkern/config.go @@ -0,0 +1,343 @@ +// Package amneziawgkern runs an AmneziaWG inbound on the Linux kernel module instead of +// internal/amneziawgnet's embedded amneziawg-go + gVisor stack; nothing it writes is executed. +package amneziawgkern + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/netip" + "path/filepath" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/config" +) + +// interfacePrefix keeps panel interfaces out of the awg0/awg1 names operators +// give their own awg-quick tunnels. +const interfacePrefix = "xui-awg" + +// fallbackInterface is the last resort when no usable host NIC is detected; +// the overwhelmingly common name on the VPS images this runs on. +const fallbackInterface = "eth0" + +// skippedInterfacePrefixes are never NAT egress candidates: tunnels, and the +// virtual bridges container runtimes leave lying around. +var skippedInterfacePrefixes = []string{interfacePrefix, "awg", "wg", "docker", "br-", "veth"} + +// defaultConfDir is panel-private, so no operator config can ever sit at a +// path the panel writes. +func defaultConfDir() string { + return filepath.Join(config.GetDBFolderPath(), "amneziawg") +} + +// ConfPathIn returns where one interface's setconf input is kept. +func ConfPathIn(dir, interfaceName string) string { + return filepath.Join(dir, interfaceName+".conf") +} + +func ownedInterfaceName(id int) string { + return fmt.Sprintf("%s%d", interfacePrefix, id) +} + +func natTableName(id int) string { + return fmt.Sprintf("xui_awg%d", id) +} + +// spec is everything one inbound's kernel interface is built from. +type spec struct { + name string + // conf is the `awg setconf` input: keys, port, obfuscation and peers. + conf string + addresses []string + mtu int + // structFP covers what syncconf cannot change on a live link. + structFP string + // nat is the nft transaction that replaces this inbound's NAT table. + nat string + forward forwardRules +} + +// forwardRules lets tunnel traffic through a FORWARD chain whose policy is +// DROP (Docker and ufw both set one); zero value means no NAT egress. +type forwardRules struct { + tunnel, egress string +} + +func buildSpec(d Desired) (spec, error) { + inst := d.Instance + if inst.Id <= 0 { + return spec{}, fmt.Errorf("amneziawgkern: invalid inbound id %d", inst.Id) + } + name := ownedInterfaceName(inst.Id) + if err := amneziawg.ValidateInterfaceName(name); err != nil { + return spec{}, fmt.Errorf("amneziawgkern: inbound %d: %w", inst.Id, err) + } + if listenNeedsPinnedBind(inst.Listen) { + return spec{}, fmt.Errorf("amneziawgkern: kernel mode cannot bind to listen address %q; clear it or use a wildcard", inst.Listen) + } + conf, err := Render(inst) + if err != nil { + return spec{}, err + } + addresses, err := interfaceAddresses(inst) + if err != nil { + return spec{}, err + } + s := spec{name: name, conf: conf, addresses: addresses, mtu: effectiveMTU(inst)} + s.structFP = structuralFingerprint(conf, addresses, s.mtu) + s.nat = natTransaction(inst.Id, "") + if !d.RouteThroughXray { + egress := d.EgressInterface + if egress == "" { + egress = DetectDefaultInterface() + } + if err := amneziawg.ValidateInterfaceName(egress); err != nil { + return spec{}, fmt.Errorf("amneziawgkern: %w", err) + } + s.nat = natTransaction(inst.Id, natChain(addresses, egress)) + s.forward = forwardRules{tunnel: name, egress: egress} + } + return s, nil +} + +// effectiveMTU is the tunnel MTU this instance runs at: the admin's value when +// set, otherwise the S4-aware default shared with the embedded datapath. +func effectiveMTU(inst amneziawg.Instance) int { + return amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4) +} + +// Render builds the `awg setconf` input for inst. +func Render(inst amneziawg.Instance) (string, error) { + if inst.PrivateKey == "" { + return "", fmt.Errorf("amneziawgkern: inbound %d has no private key", inst.Id) + } + if err := validateConfigValues(inst); err != nil { + return "", err + } + + var b strings.Builder + b.WriteString(ownershipMarker(ownedInterfaceName(inst.Id)) + "\n[Interface]\n") + fmt.Fprintf(&b, "PrivateKey = %s\n", inst.PrivateKey) + fmt.Fprintf(&b, "ListenPort = %d\n", inst.ListenPort) + writeObfuscation(&b, inst.Obfuscation) + + for _, p := range inst.Peers { + if p.PublicKey == "" || len(p.AllowedIPs) == 0 { + continue + } + b.WriteString("\n[Peer]\n") + if p.Email != "" { + fmt.Fprintf(&b, "# %s\n", p.Email) + } + fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey) + if p.PresharedKey != "" { + fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey) + } + fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", ")) + } + return b.String(), nil +} + +func validateConfigValues(inst amneziawg.Instance) error { + values := []struct{ field, value string }{ + {"privateKey", inst.PrivateKey}, + {"headerProtectionKey", inst.Obfuscation.HeaderProtectionKey}, + {"contentPaddingAddition", inst.Obfuscation.ContentPaddingAddition}, + {"rekeyAfterTime", inst.Obfuscation.RekeyAfterTime}, + {"rekeyTimeout", inst.Obfuscation.RekeyTimeout}, + {"rejectAfterTime", inst.Obfuscation.RejectAfterTime}, + {"keepaliveTimeout", inst.Obfuscation.KeepaliveTimeout}, + {"maxHandshakeAttempts", inst.Obfuscation.MaxHandshakeAttempts}, + } + for i, value := range []string{inst.Obfuscation.H1, inst.Obfuscation.H2, inst.Obfuscation.H3, inst.Obfuscation.H4} { + values = append(values, struct{ field, value string }{fmt.Sprintf("H%d", i+1), value}) + } + for i, value := range []string{inst.Obfuscation.I1, inst.Obfuscation.I2, inst.Obfuscation.I3, inst.Obfuscation.I4, inst.Obfuscation.I5} { + values = append(values, struct{ field, value string }{fmt.Sprintf("I%d", i+1), value}) + } + for _, peer := range inst.Peers { + values = append(values, + struct{ field, value string }{"email", peer.Email}, + struct{ field, value string }{"publicKey", peer.PublicKey}, + struct{ field, value string }{"presharedKey", peer.PresharedKey}, + ) + for i, allowedIP := range peer.AllowedIPs { + values = append(values, struct{ field, value string }{fmt.Sprintf("allowedIPs[%d]", i), allowedIP}) + } + } + for _, value := range values { + if err := amneziawg.ValidateConfigValue(value.field, value.value); err != nil { + return fmt.Errorf("amneziawgkern: %w", err) + } + } + return nil +} + +func listenNeedsPinnedBind(listen string) bool { + switch strings.TrimSpace(listen) { + case "", "0.0.0.0", "::", "::0": + return false + default: + return true + } +} + +// writeObfuscation emits the AmneziaWG parameter block. +func writeObfuscation(b *strings.Builder, o amneziawg.Obfuscation31) { + fmt.Fprintf(b, "Jc = %d\n", o.Jc) + fmt.Fprintf(b, "Jmin = %d\n", o.Jmin) + fmt.Fprintf(b, "Jmax = %d\n", o.Jmax) + fmt.Fprintf(b, "S1 = %d\n", o.S1) + fmt.Fprintf(b, "S2 = %d\n", o.S2) + if o.S3 > 0 { + fmt.Fprintf(b, "S3 = %d\n", o.S3) + } + if o.S4 > 0 { + fmt.Fprintf(b, "S4 = %d\n", o.S4) + } + for i, v := range []string{o.H1, o.H2, o.H3, o.H4} { + fmt.Fprintf(b, "H%d = %s\n", i+1, headerOrDefault(v, i+1)) + } + for i, v := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} { + if v != "" { + fmt.Fprintf(b, "I%d = %s\n", i+1, v) + } + } + for _, p := range []struct{ key, v string }{ + {"HeaderProtectionKey", o.HeaderProtectionKey}, + {"ContentPaddingAddition", o.ContentPaddingAddition}, + {"RekeyAfterTime", o.RekeyAfterTime}, + {"RekeyTimeout", o.RekeyTimeout}, + {"RejectAfterTime", o.RejectAfterTime}, + {"KeepaliveTimeout", o.KeepaliveTimeout}, + {"MaxHandshakeAttempts", o.MaxHandshakeAttempts}, + } { + if v := strings.TrimSpace(p.v); v != "" { + fmt.Fprintf(b, "%s = %s\n", p.key, v) + } + } + if o.RandomTrailers { + b.WriteString("RandomTrailers = on\n") + } + if o.DisableCookies { + b.WriteString("DisableCookies = on\n") + } +} + +func headerOrDefault(v string, n int) string { + if s := strings.TrimSpace(v); s != "" { + return s + } + return fmt.Sprint(n) +} + +// interfaceAddresses parses the tunnel's own addresses into canonical prefixes. +func interfaceAddresses(inst amneziawg.Instance) ([]string, error) { + var out []string + for _, a := range inst.Address { + a = strings.TrimSpace(a) + if a == "" { + continue + } + p, err := netip.ParsePrefix(a) + if err != nil { + return nil, fmt.Errorf("amneziawgkern: parse interface address %q: %w", a, err) + } + if !p.Addr().Is4() { + return nil, fmt.Errorf("amneziawgkern: inbound %d: kernel mode is IPv4-only, got %q", inst.Id, a) + } + out = append(out, p.String()) + } + if len(out) == 0 { + return nil, fmt.Errorf("amneziawgkern: inbound %d has no interface address", inst.Id) + } + return out, nil +} + +// natChain masquerades each tunnel subnet (not the host address) out of egress. +func natChain(addresses []string, egress string) string { + var b strings.Builder + b.WriteString("\tchain postrouting {\n\t\ttype nat hook postrouting priority 100; policy accept;\n") + for _, a := range addresses { + fmt.Fprintf(&b, "\t\tip saddr %s oifname %q masquerade\n", netip.MustParsePrefix(a).Masked(), egress) + } + b.WriteString("\t}\n") + return b.String() +} + +// natTransaction replaces the inbound's table atomically; an empty chain only +// deletes it, and "add" first makes the delete safe when the table is absent. +func natTransaction(id int, chain string) string { + table := natTableName(id) + tx := fmt.Sprintf("add table ip %s\ndelete table ip %s\n", table, table) + if chain != "" { + tx += fmt.Sprintf("table ip %s {\n%s}\n", table, chain) + } + return tx +} + +// structuralFingerprint hashes everything above the first [Peer] section plus +// the link's addresses and MTU -- what a live syncconf cannot change. +func structuralFingerprint(conf string, addresses []string, mtu int) string { + head := conf + if i := strings.Index(conf, "\n[Peer]"); i >= 0 { + head = conf[:i] + } + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\naddress=%s\nmtu=%d", head, strings.Join(addresses, ","), mtu))) + return hex.EncodeToString(sum[:]) +} + +// DetectDefaultInterface picks the host NIC to masquerade out of when the admin left ExternalInterface +// blank: the first up, non-loopback, non-virtual interface holding a routable IPv4 address. +func DetectDefaultInterface() string { + ifaces, err := net.Interfaces() + if err != nil { + return fallbackInterface + } + for _, ifi := range ifaces { + if ifi.Flags&net.FlagLoopback != 0 || ifi.Flags&net.FlagUp == 0 { + continue + } + if hasSkippedPrefix(ifi.Name) { + continue + } + if interfaceHasRoutableV4(ifi) { + return ifi.Name + } + } + return fallbackInterface +} + +func hasSkippedPrefix(name string) bool { + for _, p := range skippedInterfacePrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +func interfaceHasRoutableV4(ifi net.Interface) bool { + addrs, err := ifi.Addrs() + if err != nil { + return false + } + for _, a := range addrs { + n, ok := a.(*net.IPNet) + if !ok { + continue + } + ip, ok := netip.AddrFromSlice(n.IP) + if !ok { + continue + } + ip = ip.Unmap() + if ip.Is4() && !ip.IsLinkLocalUnicast() && !ip.IsLoopback() { + return true + } + } + return false +} diff --git a/internal/amneziawgkern/config_test.go b/internal/amneziawgkern/config_test.go new file mode 100644 index 000000000..3b26fc5f4 --- /dev/null +++ b/internal/amneziawgkern/config_test.go @@ -0,0 +1,227 @@ +package amneziawgkern + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" +) + +func testInstance() amneziawg.Instance { + return amneziawg.Instance{ + Id: 1, + ListenPort: 31884, + PrivateKey: "cPrivateKeyBase64ValueForTestingOnlyPadding0=", + Address: []string{"10.8.1.1/24"}, + MTU: 1380, + Obfuscation: amneziawg.Obfuscation31{ + Jc: 3, Jmin: 62, Jmax: 284, + S1: 15, S2: 89, S3: 40, S4: 12, + HeaderProtectionKey: "hpkBase64ValueForTestingOnlyPaddingXXXXXXXX=", + ContentPaddingAddition: "14-47", + RekeyAfterTime: "107-133", + RandomTrailers: true, + DisableCookies: true, + }, + Peers: []amneziawg.Peer{ + {Email: "alice", PublicKey: "peerAPublicKeyBase64ValueForTestsPadding000=", AllowedIPs: []string{"10.8.1.2/32"}}, + {Email: "bob", PublicKey: "peerBPublicKeyBase64ValueForTestsPadding000=", AllowedIPs: []string{"10.8.1.3/32"}, PresharedKey: "pskBase64ValueForTestingOnlyPaddingXXXXXXX="}, + }, + } +} + +// TestRenderEmitsOnlySetconfKeys: `awg setconf` rejects wg-quick keys (Address, +// MTU, hooks) outright, and a hook line would reintroduce a root-shell sink. +func TestRenderEmitsOnlySetconfKeys(t *testing.T) { + conf, err := Render(testInstance()) + if err != nil { + t.Fatalf("Render: %v", err) + } + for _, want := range []string{ + "PrivateKey = cPrivateKeyBase64ValueForTestingOnlyPadding0=\n", + "ListenPort = 31884\n", + "Jc = 3\n", "S3 = 40\n", "S4 = 12\n", + "HeaderProtectionKey = hpkBase64ValueForTestingOnlyPaddingXXXXXXXX=\n", + "ContentPaddingAddition = 14-47\n", + "RandomTrailers = on\n", "DisableCookies = on\n", + "# alice\nPublicKey = peerAPublicKeyBase64ValueForTestsPadding000=\nAllowedIPs = 10.8.1.2/32\n", + "PresharedKey = pskBase64ValueForTestingOnlyPaddingXXXXXXX=\n", + } { + if !strings.Contains(conf, want) { + t.Errorf("rendered config lacks %q:\n%s", want, conf) + } + } + for _, line := range strings.Split(conf, "\n") { + key, _, _ := strings.Cut(line, " = ") + switch key { + case "Address", "MTU", "DNS", "Table", "PreUp", "PostUp", "PreDown", "PostDown", "SaveConfig": + t.Errorf("rendered config carries wg-quick key %q", line) + } + } + if !validOwnedConfig(conf, ownedInterfaceName(1)) { + t.Errorf("ownership grammar rejects the panel's own rendered config:\n%s", conf) + } +} + +// TestRenderFillsHeaderDefaults: the 3.x module and tools reject a missing H1-H4, +// so a blank one must fall back to the vanilla message type it replaces. +func TestRenderFillsHeaderDefaults(t *testing.T) { + inst := testInstance() + inst.Obfuscation.H2 = "1000-2000" + conf, err := Render(inst) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"H1 = 1\n", "H2 = 1000-2000\n", "H3 = 3\n", "H4 = 4\n"} { + if !strings.Contains(conf, want) { + t.Errorf("rendered config lacks %q", want) + } + } +} + +// TestRenderRejectsControlCharacters: a newline in a client value would add a +// directive of the caller's choosing to the file the kernel is configured from. +func TestRenderRejectsControlCharacters(t *testing.T) { + cases := map[string]func(*amneziawg.Instance){ + "peer key": func(i *amneziawg.Instance) { i.Peers[0].PublicKey = "x\n[Interface]\nListenPort = 1" }, + "psk": func(i *amneziawg.Instance) { i.Peers[1].PresharedKey = "x\rPublicKey = y" }, + "email": func(i *amneziawg.Instance) { i.Peers[0].Email = "a\nAllowedIPs = 0.0.0.0/0" }, + "allowed ips": func(i *amneziawg.Instance) { i.Peers[0].AllowedIPs = []string{"10.8.1.2/32\nEndpoint = 1.2.3.4:1"} }, + "i1": func(i *amneziawg.Instance) { i.Obfuscation.I1 = "\nJc = 1" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + inst := testInstance() + mutate(&inst) + if _, err := Render(inst); err == nil || !strings.Contains(err.Error(), "control characters") { + t.Fatalf("Render accepted a control character, err = %v", err) + } + }) + } +} + +// TestBuildSpecMasqueradesSubnetNotHostAddress: matching the server's own +// 10.8.1.1 would NAT nothing the clients send. +func TestBuildSpecMasqueradesSubnetNotHostAddress(t *testing.T) { + s, err := buildSpec(Desired{Instance: testInstance(), EgressInterface: "ens3"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(s.nat, "ip saddr 10.8.1.0/24 oifname \"ens3\" masquerade") { + t.Fatalf("NAT transaction does not masquerade the tunnel subnet:\n%s", s.nat) + } + if !strings.HasPrefix(s.nat, "add table ip xui_awg1\ndelete table ip xui_awg1\n") { + t.Fatalf("NAT transaction does not replace its own table atomically:\n%s", s.nat) + } + if s.forward != (forwardRules{tunnel: "xui-awg1", egress: "ens3"}) { + t.Fatalf("forward rules = %+v, want tunnel xui-awg1 -> ens3", s.forward) + } +} + +// TestBuildSpecRouteThroughXrayOnlyDeletesNAT: a legacy routeThroughXray row +// must not leave a masquerade rule behind from an earlier save. +func TestBuildSpecRouteThroughXrayOnlyDeletesNAT(t *testing.T) { + s, err := buildSpec(Desired{Instance: testInstance(), EgressInterface: "eth0", RouteThroughXray: true}) + if err != nil { + t.Fatal(err) + } + if s.nat != "add table ip xui_awg1\ndelete table ip xui_awg1\n" { + t.Fatalf("NAT transaction = %q, want only the table delete", s.nat) + } + if s.forward != (forwardRules{}) { + t.Fatalf("forward rules = %+v, want none without NAT egress", s.forward) + } +} + +// TestBuildSpecRejectsUnsafeInputs: each lands in an argv or an nft +// transaction, and the kernel interface name has a hard 15-byte limit. +func TestBuildSpecRejectsUnsafeInputs(t *testing.T) { + cases := map[string]Desired{ + "egress with quote": {Instance: testInstance(), EgressInterface: `eth0" accept; #`}, + "pinned listen": {Instance: func() amneziawg.Instance { i := testInstance(); i.Listen = "203.0.113.10"; return i }()}, + "ipv6 address": {Instance: func() amneziawg.Instance { i := testInstance(); i.Address = []string{"fd00::1/64"}; return i }(), EgressInterface: "eth0"}, + "no address": {Instance: func() amneziawg.Instance { i := testInstance(); i.Address = nil; return i }(), EgressInterface: "eth0"}, + "no private key": {Instance: func() amneziawg.Instance { i := testInstance(); i.PrivateKey = ""; return i }(), EgressInterface: "eth0"}, + "name over 15 bytes": {Instance: func() amneziawg.Instance { i := testInstance(); i.Id = 123456789; return i }(), EgressInterface: "eth0"}, + "non-positive id": {Instance: func() amneziawg.Instance { i := testInstance(); i.Id = 0; return i }(), EgressInterface: "eth0"}, + "unparsable address": {Instance: func() amneziawg.Instance { i := testInstance(); i.Address = []string{"10.8.1.1"}; return i }(), EgressInterface: "eth0"}, + "egress with newline": {Instance: testInstance(), EgressInterface: "eth0\n"}, + } + for name, d := range cases { + t.Run(name, func(t *testing.T) { + if _, err := buildSpec(d); err == nil { + t.Fatal("buildSpec accepted it") + } + }) + } +} + +// TestBuildSpecMTU: an unset MTU must still be S4-aware, or every full-size +// packet fragments; an explicit one must win. +func TestBuildSpecMTU(t *testing.T) { + inst := testInstance() + inst.MTU = 0 + s, err := buildSpec(Desired{Instance: inst, EgressInterface: "eth0"}) + if err != nil { + t.Fatal(err) + } + if s.mtu != 1408 { + t.Fatalf("default MTU = %d, want 1408 (1420 minus S4 = 12)", s.mtu) + } + inst.MTU = 1280 + if s, _ = buildSpec(Desired{Instance: inst, EgressInterface: "eth0"}); s.mtu != 1280 { + t.Fatalf("explicit MTU = %d, want 1280", s.mtu) + } +} + +// TestStructuralFingerprintIgnoresPeersOnly: a peer edit must sync in place, +// while key, port, address or MTU changes need a rebuild. +func TestStructuralFingerprintIgnoresPeersOnly(t *testing.T) { + base, err := buildSpec(Desired{Instance: testInstance(), EgressInterface: "eth0"}) + if err != nil { + t.Fatal(err) + } + peerEdit := testInstance() + peerEdit.Peers = peerEdit.Peers[:1] + if s, _ := buildSpec(Desired{Instance: peerEdit, EgressInterface: "eth0"}); s.structFP != base.structFP { + t.Error("a peer edit changed the structural fingerprint") + } + for name, mutate := range map[string]func(*amneziawg.Instance){ + "private key": func(i *amneziawg.Instance) { i.PrivateKey = "dPrivateKeyBase64ValueForTestingOnlyPadding0=" }, + "listen port": func(i *amneziawg.Instance) { i.ListenPort++ }, + "address": func(i *amneziawg.Instance) { i.Address = []string{"10.9.1.1/24"} }, + "mtu": func(i *amneziawg.Instance) { i.MTU = 1300 }, + "obfuscation": func(i *amneziawg.Instance) { i.Obfuscation.Jc = 7 }, + } { + inst := testInstance() + mutate(&inst) + if s, _ := buildSpec(Desired{Instance: inst, EgressInterface: "eth0"}); s.structFP == base.structFP { + t.Errorf("%s change kept the structural fingerprint", name) + } + } +} + +// TestOwnershipGrammarRefusesForeignConfigs: only a file Render wrote may be +// adopted, synced or torn down. +func TestOwnershipGrammarRefusesForeignConfigs(t *testing.T) { + name := ownedInterfaceName(1) + conf, err := Render(testInstance()) + if err != nil { + t.Fatal(err) + } + cases := map[string]string{ + "unmarked": strings.TrimPrefix(conf, ownershipMarker(name)+"\n"), + "other marker": strings.Replace(conf, ownershipMarker(name), ownershipMarker(ownedInterfaceName(2)), 1), + "hook line": strings.Replace(conf, "ListenPort", "PostUp = id\nListenPort", 1), + "wg-quick address": strings.Replace(conf, "ListenPort", "Address = 10.8.1.1/24\nListenPort", 1), + "peer endpoint": conf + "Endpoint = 1.2.3.4:1\n", + "interface comment": strings.Replace(conf, "[Interface]\n", "[Interface]\n# note\n", 1), + "second interface": conf + "[Interface]\n", + "crlf": strings.ReplaceAll(conf, "\n", "\r\n"), + } + for label, body := range cases { + if validOwnedConfig(body, name) { + t.Errorf("%s: ownership grammar accepted a config Render never writes", label) + } + } +} diff --git a/internal/amneziawgkern/manager.go b/internal/amneziawgkern/manager.go new file mode 100644 index 000000000..f01282af7 --- /dev/null +++ b/internal/amneziawgkern/manager.go @@ -0,0 +1,623 @@ +package amneziawgkern + +import ( + "context" + "fmt" + "net" + "net/netip" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// commandTimeout bounds every awg/ip/nft/iptables invocation. +const commandTimeout = 20 * time.Second + +// Desired is one inbound's wanted kernel-interface state. +type Desired struct { + Instance amneziawg.Instance + // EgressInterface is the host NIC masquerade attaches to; blank means + // detect it (DetectDefaultInterface). + EgressInterface string + // RouteThroughXray reflects a persisted setting rejected by kernel preflight. + RouteThroughXray bool +} + +// managed is one live interface and the spec it was last brought to. +type managed struct { + spec spec + trafficGeneration uint64 + trafficBaselinePending bool +} + +// TrafficInterfaceState identifies one managed interface generation. +type TrafficInterfaceState struct { + Name string + Generation uint64 + Adopted bool +} + +// host is the part of the OS the manager drives; tests substitute a fake. +type host interface { + linkExists(name string) bool + linkMatches(name string, addresses []string, mtu int) bool + run(stdin, name string, args ...string) (string, error) + hasCommand(name string) bool + moduleLoaded() bool + enableIPv4Forwarding() error +} + +// Manager owns the running kernel interfaces keyed by inbound id, mirroring +// internal/amneziawgnet.Manager's shape so callers can hold either behind the same call sites. +type Manager struct { + mu sync.Mutex + ifaces map[int]*managed + generation uint64 + host host + confDir string +} + +var ( + managerOnce sync.Once + manager *Manager +) + +// GetManager returns the process-wide kernel-AmneziaWG manager singleton. +func GetManager() *Manager { + managerOnce.Do(func() { + manager = newManager(osHost{}, defaultConfDir()) + }) + return manager +} + +func newManager(h host, confDir string) *Manager { + return &Manager{ifaces: map[int]*managed{}, host: h, confDir: confDir} +} + +// Ensure brings the inbound's kernel interface to the state d describes. +func (m *Manager) Ensure(d Desired) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.ensureLocked(d) +} + +func (m *Manager) ensureLocked(d Desired) error { + s, err := buildSpec(d) + if err != nil { + return err + } + id := d.Instance.Id + cur, isManaged := m.ifaces[id] + linkUp := m.host.linkExists(s.name) + + owned, found, err := readOwnedConfig(m.confDir, id) + if err != nil { + return err + } + if linkUp && !isManaged { + if !found { + return fmt.Errorf("amneziawgkern: refusing occupied interface %s without an owned config", s.name) + } + if err := m.verifyLiveInterface(s.name, owned); err != nil { + return err + } + } + + if linkUp && isManaged && cur.spec.structFP == s.structFP { + changed := cur.spec.conf != s.conf || cur.spec.nat != s.nat || cur.spec.forward != s.forward + if err := m.reconfigure(cur.spec, s); err != nil { + return err + } + cur.spec = s + if changed { + logger.Debug("amneziawgkern: synced ", s.name) + } + return nil + } + if linkUp && !isManaged && interfaceSection(owned) == interfaceSection(s.conf) && + m.host.linkMatches(s.name, s.addresses, s.mtu) { + err := m.adopt(s) + if err == nil { + m.ifaces[id] = m.newManaged(s, true) + logger.Info("amneziawgkern: adopted running interface ", s.name, " for inbound ", id) + return nil + } + logger.Warning("amneziawgkern: could not adopt ", s.name, ", rebuilding: ", err) + } + + // The old config stays on disk until its link is gone, so an interrupted + // rebuild still has the key that proves the leftover link is ours. + if linkUp { + if err := m.deleteLink(s.name); err != nil { + return err + } + } + if err := writeConf(m.confDir, s.name, s.conf); err != nil { + return err + } + if err := m.createLink(s); err != nil { + if m.host.linkExists(s.name) { + if out, delErr := m.host.run("", "ip", "link", "del", "dev", s.name); delErr != nil { + logger.Warning("amneziawgkern: cleaning up half-built ", s.name, ": ", delErr, " ", out) + } + } + return err + } + // Without a managed entry the rules a previous process left are unknown, + // so the sentinel forces a resync. + prevForward := forwardRules{tunnel: s.name} + if isManaged { + prevForward = cur.spec.forward + } + if err := m.applyEgress(s, prevForward, true); err != nil { + return err + } + m.ifaces[id] = m.newManaged(s, false) + logger.Info("amneziawgkern: started kernel interface ", s.name, " for inbound ", id) + return nil +} + +func (m *Manager) newManaged(s spec, adopted bool) *managed { + m.generation++ + return &managed{spec: s, trafficGeneration: m.generation, trafficBaselinePending: adopted} +} + +// reconfigure applies peer and egress changes to a live, structurally +// unchanged interface without dropping its sessions. +func (m *Manager) reconfigure(prev, s spec) error { + if prev.conf != s.conf { + if err := writeConf(m.confDir, s.name, s.conf); err != nil { + return err + } + if out, err := m.host.run("", "awg", "syncconf", s.name, ConfPathIn(m.confDir, s.name)); err != nil { + return fmt.Errorf("amneziawgkern: awg syncconf %s: %w (%s)", s.name, err, out) + } + } + return m.applyEgress(s, prev.forward, prev.nat != s.nat) +} + +// adopt takes over an interface that outlived the panel; its egress state is +// unknown, so NAT and forwarding are re-applied unconditionally. +func (m *Manager) adopt(s spec) error { + if err := writeConf(m.confDir, s.name, s.conf); err != nil { + return err + } + if out, err := m.host.run("", "awg", "syncconf", s.name, ConfPathIn(m.confDir, s.name)); err != nil { + return fmt.Errorf("amneziawgkern: awg syncconf %s: %w (%s)", s.name, err, out) + } + return m.applyEgress(s, forwardRules{tunnel: s.name}, true) +} + +func (m *Manager) createLink(s spec) error { + if !m.host.moduleLoaded() { + if out, err := m.host.run("", "modprobe", "amneziawg"); err != nil { + return fmt.Errorf("amneziawgkern: modprobe amneziawg: %w (%s)", err, out) + } + } + steps := [][]string{ + {"ip", "link", "add", "dev", s.name, "type", "amneziawg"}, + {"awg", "setconf", s.name, ConfPathIn(m.confDir, s.name)}, + } + for _, a := range s.addresses { + steps = append(steps, []string{"ip", "-4", "address", "add", a, "dev", s.name}) + } + steps = append(steps, []string{"ip", "link", "set", "dev", s.name, "mtu", fmt.Sprint(s.mtu), "up"}) + for _, step := range steps { + if out, err := m.host.run("", step[0], step[1:]...); err != nil { + return fmt.Errorf("amneziawgkern: %s: %w (%s)", strings.Join(step, " "), err, out) + } + } + return nil +} + +func (m *Manager) deleteLink(name string) error { + out, err := m.host.run("", "ip", "link", "del", "dev", name) + if m.host.linkExists(name) { + if err != nil { + return fmt.Errorf("amneziawgkern: ip link del %s: %w (%s)", name, err, out) + } + return fmt.Errorf("amneziawgkern: ip link del %s left the interface running", name) + } + return nil +} + +// applyEgress converges NAT (when natChanged) and the FORWARD accepts. +func (m *Manager) applyEgress(s spec, prevForward forwardRules, natChanged bool) error { + if natChanged { + if out, err := m.host.run(s.nat, "nft", "-f", "-"); err != nil { + return fmt.Errorf("amneziawgkern: applying NAT for %s: %w (%s)", s.name, err, out) + } + } + if s.forward.egress != "" { + if err := m.host.enableIPv4Forwarding(); err != nil { + return fmt.Errorf("amneziawgkern: enabling IPv4 forwarding: %w", err) + } + } + if prevForward != s.forward { + m.syncForwardRules(s.name, s.forward) + } + return nil +} + +// syncForwardRules is best effort: with a FORWARD policy of ACCEPT the rules +// are redundant, so a host without iptables still forwards. +func (m *Manager) syncForwardRules(name string, f forwardRules) { + if !m.host.hasCommand("iptables") { + return + } + m.removeForwardRules(name) + for _, rule := range f.rules() { + if out, err := m.host.run("", "iptables", append([]string{"-w", "5", "-I", "FORWARD", "1"}, rule...)...); err != nil { + logger.Warning("amneziawgkern: adding FORWARD accept for ", name, ": ", err, " ", out) + } + } +} + +// removeForwardRules deletes every FORWARD rule tagged with name's comment, +// whatever egress it was written for. +func (m *Manager) removeForwardRules(name string) { + if !m.host.hasCommand("iptables") { + return + } + listing, err := m.host.run("", "iptables", "-w", "5", "-S", "FORWARD") + if err != nil { + logger.Warning("amneziawgkern: listing FORWARD rules: ", err, " ", listing) + return + } + tag := forwardComment(name) + for _, line := range strings.Split(listing, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "-A" { + continue + } + // -S quotes the comment; passed back verbatim the quotes would be part of it. + for i := range fields { + fields[i] = strings.Trim(fields[i], `"`) + } + if !hasCommentTag(fields, tag) { + continue + } + fields[0] = "-D" + if out, err := m.host.run("", "iptables", append([]string{"-w", "5"}, fields...)...); err != nil { + logger.Warning("amneziawgkern: removing FORWARD accept for ", name, ": ", err, " ", out) + } + } +} + +func forwardComment(name string) string { return "3x-ui:" + name } + +func hasCommentTag(fields []string, tag string) bool { + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "--comment" && fields[i+1] == tag { + return true + } + } + return false +} + +func (f forwardRules) rules() [][]string { + if f.egress == "" { + return nil + } + comment := []string{"-m", "comment", "--comment", forwardComment(f.tunnel), "-j", "ACCEPT"} + return [][]string{ + append([]string{"-i", f.tunnel, "-o", f.egress}, comment...), + append([]string{"-i", f.egress, "-o", f.tunnel, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED"}, comment...), + } +} + +// verifyLiveInterface refuses a live link whose key is not the owned config's. +// A keyless link is one a crashed create left behind. +func (m *Manager) verifyLiveInterface(name, config string) error { + want, err := configInterfacePublicKey(config) + if err != nil { + return fmt.Errorf("amneziawgkern: refusing to manage %s: %w", name, err) + } + got, err := m.host.run("", "awg", "show", name, "public-key") + if err != nil { + return fmt.Errorf("amneziawgkern: refusing to manage %s: cannot verify live AmneziaWG interface: %w", name, err) + } + if got = strings.TrimSpace(got); got != want && got != "(none)" && got != "" { + return fmt.Errorf("amneziawgkern: refusing to manage %s: live interface key does not match its owned config", name) + } + return nil +} + +// Reconcile brings the whole set of kernel interfaces to what desired describes, tearing down any that +// are no longer wanted. +func (m *Manager) Reconcile(desired []Desired) { + m.mu.Lock() + defer m.mu.Unlock() + + want := make(map[int]struct{}, len(desired)) + for _, d := range desired { + want[d.Instance.Id] = struct{}{} + } + attempted := make(map[int]struct{}) + for id := range m.ifaces { + if _, ok := want[id]; ok { + continue + } + attempted[id] = struct{}{} + if err := m.teardown(id); err != nil { + logger.Warning("amneziawgkern: stopping removed inbound ", id, ": ", err) + continue + } + logger.Info("amneziawgkern: stopped kernel interface for removed inbound ", id) + } + ownedIDs, err := ownedConfigIDs(m.confDir) + if err != nil { + logger.Warning("amneziawgkern: listing owned configs: ", err) + } + for _, id := range ownedIDs { + if _, ok := want[id]; ok { + continue + } + if _, ok := attempted[id]; ok { + continue + } + if err := m.teardown(id); err != nil { + logger.Warning("amneziawgkern: stopping recovered inbound ", id, ": ", err) + continue + } + logger.Info("amneziawgkern: stopped recovered kernel interface for inbound ", id) + } + for _, d := range desired { + if err := m.ensureLocked(d); err != nil { + logger.Warning("amneziawgkern: ensure inbound ", d.Instance.Id, ": ", err) + } + } +} + +// Remove tears down one inbound's interface and forgets it. +func (m *Manager) Remove(id int) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.teardown(id) +} + +// StopAll tears every managed interface down, like the embedded datapath does +// on shutdown: a stopped panel enforces no quota, so nothing may keep forwarding. +func (m *Manager) StopAll() { + m.mu.Lock() + defer m.mu.Unlock() + + for id := range m.ifaces { + if err := m.teardown(id); err != nil { + logger.Warning("amneziawgkern: stopping inbound ", id, " on shutdown: ", err) + } + } +} + +// ManagedIDs lists the inbounds with a managed interface, in id order. +func (m *Manager) ManagedIDs() []int { + m.mu.Lock() + defer m.mu.Unlock() + ids := make([]int, 0, len(m.ifaces)) + for id := range m.ifaces { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +// HasRunning reports whether any kernel interface is currently managed. +func (m *Manager) HasRunning() bool { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.ifaces) > 0 +} + +// Running reports whether this inbound has a live kernel interface. +func (m *Manager) Running(id int) bool { + m.mu.Lock() + defer m.mu.Unlock() + name := ownedInterfaceName(id) + if !m.host.linkExists(name) { + return false + } + if _, ok := m.ifaces[id]; ok { + return true + } + _, found, err := readOwnedConfig(m.confDir, id) + return err == nil && found +} + +// TrafficStateFor returns the current generation and whether it was adopted. +func (m *Manager) TrafficStateFor(id int) (TrafficInterfaceState, bool) { + m.mu.Lock() + defer m.mu.Unlock() + cur, ok := m.ifaces[id] + if !ok || !m.host.linkExists(cur.spec.name) { + return TrafficInterfaceState{}, false + } + return TrafficInterfaceState{ + Name: cur.spec.name, Generation: cur.trafficGeneration, + Adopted: cur.trafficBaselinePending, + }, true +} + +// PrimeAdoptedTrafficBaseline consumes a matching adoption after its first dump. +func (m *Manager) PrimeAdoptedTrafficBaseline(id int, generation uint64, stats []PeerStat, collector *Collector) bool { + m.mu.Lock() + defer m.mu.Unlock() + cur, ok := m.ifaces[id] + if !ok || cur.trafficGeneration != generation || !cur.trafficBaselinePending || !m.host.linkExists(cur.spec.name) { + return false + } + collector.Prime(cur.spec.name, stats) + cur.trafficBaselinePending = false + return true +} + +// teardown removes the link, its NAT table, its FORWARD accepts and its +// private config, refusing a live link the panel cannot prove is its own. +func (m *Manager) teardown(id int) error { + name := ownedInterfaceName(id) + owned, found, err := readOwnedConfig(m.confDir, id) + if err != nil { + return err + } + _, isManaged := m.ifaces[id] + if !found && !isManaged { + if m.host.linkExists(name) { + return fmt.Errorf("amneziawgkern: refusing to stop %s without an owned config", name) + } + return nil + } + if m.host.linkExists(name) { + if found { + if err := m.verifyLiveInterface(name, owned); err != nil { + return err + } + } + if err := m.deleteLink(name); err != nil { + return err + } + } + if out, err := m.host.run(natTransaction(id, ""), "nft", "-f", "-"); err != nil { + return fmt.Errorf("amneziawgkern: removing NAT for %s: %w (%s)", name, err, out) + } + m.removeForwardRules(name) + if found { + if err := os.Remove(ConfPathIn(m.confDir, name)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("amneziawgkern: removing private config %s: %w", name, err) + } + } + delete(m.ifaces, id) + GetCollector().Forget(name) + return nil +} + +// interfaceSection is the config above its first [Peer] -- what adoption +// requires to match before a live link's peers may be replaced. +func interfaceSection(conf string) string { + if i := strings.Index(conf, "\n[Peer]"); i >= 0 { + return conf[:i] + } + return conf +} + +// writeConf replaces the interface's config atomically at 0600: it holds the +// inbound's private key. +func writeConf(dir, name, conf string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("amneziawgkern: creating %s: %w", dir, err) + } + path := ConfPathIn(dir, name) + tmp, err := os.CreateTemp(filepath.Dir(path), ".awgconf-*") + if err != nil { + return fmt.Errorf("amneziawgkern: temp config: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.WriteString(conf); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("amneziawgkern: writing config: %w", err) + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("amneziawgkern: chmod config: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("amneziawgkern: closing config: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("amneziawgkern: installing config at %s: %w", path, err) + } + return nil +} + +// osHost is the real Linux host. +type osHost struct{} + +func (osHost) linkExists(name string) bool { + _, err := os.Stat("/sys/class/net/" + name) + return err == nil +} + +// linkMatches checks whether a live link already has the requested addresses and MTU. +func (osHost) linkMatches(name string, addresses []string, mtu int) bool { + ifi, err := net.InterfaceByName(name) + if err != nil || ifi.MTU != mtu { + return false + } + addrs, err := ifi.Addrs() + if err != nil { + return false + } + live := make([]string, 0, len(addrs)) + for _, a := range addrs { + live = append(live, a.String()) + } + return addressesSatisfied(addresses, live) +} + +func (osHost) run(stdin, name string, args ...string) (string, error) { + return run(stdin, name, args...) +} + +func (osHost) hasCommand(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func (osHost) moduleLoaded() bool { + _, err := os.Stat("/sys/module/amneziawg") + return err == nil +} + +func (osHost) enableIPv4Forwarding() error { + return os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0o644) +} + +// addressesSatisfied reports whether every wanted address is present on the interface. +func addressesSatisfied(wanted, live []string) bool { + if len(wanted) == 0 { + return false + } + have := make(map[string]struct{}, len(live)) + for _, a := range live { + have[normalizeCIDR(a)] = struct{}{} + } + for _, w := range wanted { + if _, ok := have[normalizeCIDR(w)]; !ok { + return false + } + } + return true +} + +// normalizeCIDR makes "10.8.1.1/24" comparable however it was spelled. +func normalizeCIDR(s string) string { + s = strings.TrimSpace(s) + p, err := netip.ParsePrefix(s) + if err != nil { + return s + } + return p.String() +} + +// run executes one command, no shell involved, with a bounded timeout and +// returns its combined output for the error message. +func run(stdin, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} diff --git a/internal/amneziawgkern/manager_test.go b/internal/amneziawgkern/manager_test.go new file mode 100644 index 000000000..3f5eb560a --- /dev/null +++ b/internal/amneziawgkern/manager_test.go @@ -0,0 +1,639 @@ +package amneziawgkern + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +const ( + testPrivateKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + otherPrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" +) + +var errInjected = errors.New("injected failure") + +type fakeLink struct { + publicKey string + addresses []string + mtu int + up bool + conf string +} + +// fakeHost models the kernel state the manager drives, so tests assert what +// the host ends up with rather than which commands ran. +type fakeHost struct { + links map[string]*fakeLink + nat map[string]string + forward []string + iptables bool + module bool + forwarding bool + commands []string + failing map[string]error +} + +func newFakeHost() *fakeHost { + return &fakeHost{links: map[string]*fakeLink{}, nat: map[string]string{}, iptables: true, failing: map[string]error{}} +} + +func (h *fakeHost) linkExists(name string) bool { + _, ok := h.links[name] + return ok +} + +func (h *fakeHost) linkMatches(name string, addresses []string, mtu int) bool { + l, ok := h.links[name] + return ok && l.mtu == mtu && addressesSatisfied(addresses, l.addresses) +} + +func (h *fakeHost) hasCommand(name string) bool { return name != "iptables" || h.iptables } +func (h *fakeHost) moduleLoaded() bool { return h.module } + +func (h *fakeHost) enableIPv4Forwarding() error { + h.forwarding = true + return nil +} + +func (h *fakeHost) run(stdin, name string, args ...string) (string, error) { + cmd := strings.Join(append([]string{name}, args...), " ") + h.commands = append(h.commands, cmd) + for prefix, err := range h.failing { + if strings.HasPrefix(cmd, prefix) { + return "injected", err + } + } + switch name { + case "modprobe": + h.module = true + return "", nil + case "ip": + return "", h.ip(args) + case "awg": + return h.awg(args) + case "nft": + h.nftApply(stdin) + return "", nil + case "iptables": + return h.iptablesCmd(args) + } + return "", fmt.Errorf("fake host: unexpected command %q", cmd) +} + +func (h *fakeHost) ip(args []string) error { + switch { + case len(args) == 6 && args[0] == "link" && args[1] == "add" && args[5] == "amneziawg": + if h.linkExists(args[3]) { + return errors.New("RTNETLINK answers: File exists") + } + h.links[args[3]] = &fakeLink{} + case len(args) == 4 && args[0] == "link" && args[1] == "del": + if !h.linkExists(args[3]) { + return errors.New("Cannot find device") + } + delete(h.links, args[3]) + case len(args) == 6 && args[0] == "-4" && args[2] == "add": + l, ok := h.links[args[5]] + if !ok { + return errors.New("Cannot find device") + } + l.addresses = append(l.addresses, args[3]) + case len(args) == 7 && args[0] == "link" && args[1] == "set" && args[4] == "mtu": + l, ok := h.links[args[3]] + if !ok { + return errors.New("Cannot find device") + } + l.mtu, _ = strconv.Atoi(args[5]) + l.up = args[6] == "up" + default: + return fmt.Errorf("fake host: unexpected ip %v", args) + } + return nil +} + +func (h *fakeHost) awg(args []string) (string, error) { + if len(args) != 3 { + return "", fmt.Errorf("fake host: unexpected awg %v", args) + } + l, ok := h.links[args[1]] + if !ok { + return "", errors.New("Unable to access interface: No such device") + } + switch args[0] { + case "show": + if l.publicKey == "" { + return "(none)", nil + } + return l.publicKey, nil + case "setconf", "syncconf": + data, err := os.ReadFile(args[2]) + if err != nil { + return "", err + } + pub, err := configInterfacePublicKey(string(data)) + if err != nil { + return "", err + } + l.publicKey, l.conf = pub, string(data) + return "", nil + } + return "", fmt.Errorf("fake host: unexpected awg %v", args) +} + +func (h *fakeHost) nftApply(tx string) { + for _, line := range strings.Split(tx, "\n") { + f := strings.Fields(line) + switch { + case len(f) == 4 && f[0] == "delete" && f[1] == "table": + delete(h.nat, f[3]) + case len(f) == 4 && f[0] == "table" && f[3] == "{": + h.nat[f[2]] = tx + } + } +} + +func (h *fakeHost) iptablesCmd(args []string) (string, error) { + if len(args) < 4 || args[0] != "-w" || args[3] != "FORWARD" { + return "", fmt.Errorf("fake host: unexpected iptables %v", args) + } + rest := args[4:] + switch args[2] { + case "-S": + // Like iptables-nft, print the comment quoted. + listing := []string{"-P FORWARD DROP"} + for _, r := range h.forward { + f := strings.Fields(r) + for i := 0; i+1 < len(f); i++ { + if f[i] == "--comment" { + f[i+1] = strconv.Quote(f[i+1]) + } + } + listing = append(listing, strings.Join(f, " ")) + } + return strings.Join(listing, "\n"), nil + case "-I": + h.forward = append([]string{"-A FORWARD " + strings.Join(rest[1:], " ")}, h.forward...) + return "", nil + case "-D": + rule := "-A FORWARD " + strings.Join(rest, " ") + for i, r := range h.forward { + if r == rule { + h.forward = append(h.forward[:i], h.forward[i+1:]...) + return "", nil + } + } + return "", errors.New("Bad rule (does a matching rule exist in that chain?)") + } + return "", fmt.Errorf("fake host: unexpected iptables %v", args) +} + +func (h *fakeHost) ran(prefix string) bool { + for _, c := range h.commands { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +func publicKeyOf(t *testing.T, privateKey string) string { + t.Helper() + pub, err := wgutil.PublicKeyFromPrivate(privateKey) + if err != nil { + t.Fatal(err) + } + return pub +} + +func managerDesired(id int) Desired { + inst := testInstance() + inst.Id = id + inst.PrivateKey = testPrivateKey + return Desired{Instance: inst, EgressInterface: "eth0"} +} + +func newTestManager(t *testing.T) (*Manager, *fakeHost, string) { + t.Helper() + dir := t.TempDir() + h := newFakeHost() + return newManager(h, dir), h, dir +} + +func diskKey(t *testing.T, dir string, id int) string { + t.Helper() + conf, found, err := readOwnedConfig(dir, id) + if err != nil || !found { + t.Fatalf("owned config for %d: found=%v err=%v", id, found, err) + } + pub, err := configInterfacePublicKey(conf) + if err != nil { + t.Fatal(err) + } + return pub +} + +func forwardRulesFor(h *fakeHost, name string) []string { + var out []string + for _, r := range h.forward { + if strings.Contains(r, "--comment "+forwardComment(name)) { + out = append(out, r) + } + } + return out +} + +// TestEnsureBuildsInterfaceWithoutShell: every step is a direct exec, so no +// client-controlled value ever reaches a shell running as root. +func TestEnsureBuildsInterfaceWithoutShell(t *testing.T) { + m, h, dir := newTestManager(t) + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatalf("Ensure: %v", err) + } + link := h.links["xui-awg1"] + if link == nil || !link.up || link.mtu != 1380 || strings.Join(link.addresses, ",") != "10.8.1.1/24" { + t.Fatalf("link = %+v, want xui-awg1 up at MTU 1380 with 10.8.1.1/24", link) + } + if link.publicKey != publicKeyOf(t, testPrivateKey) || !strings.Contains(link.conf, "PublicKey = peerBPublicKeyBase64ValueForTestsPadding000=") { + t.Fatalf("link was not configured from the rendered config: %+v", link) + } + if !h.module || !h.forwarding { + t.Fatalf("module loaded=%v ip_forward=%v, want both", h.module, h.forwarding) + } + if !strings.Contains(h.nat["xui_awg1"], `ip saddr 10.8.1.0/24 oifname "eth0" masquerade`) { + t.Fatalf("NAT table = %q", h.nat["xui_awg1"]) + } + if got := forwardRulesFor(h, "xui-awg1"); len(got) != 2 { + t.Fatalf("FORWARD accepts = %v, want the outbound and the established-return rule", got) + } + for _, c := range h.commands { + switch strings.Fields(c)[0] { + case "ip", "awg", "nft", "iptables", "modprobe": + default: + t.Errorf("unexpected command %q", c) + } + } + if runtime.GOOS != "windows" { + info, err := os.Stat(ConfPathIn(dir, "xui-awg1")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("config holding the private key: mode %v err %v, want 0600", info.Mode().Perm(), err) + } + } +} + +// TestEnsureUnchangedTickRunsNothing: the job re-ensures every inbound every +// 10s; an unchanged one must not fork a single process. +func TestEnsureUnchangedTickRunsNothing(t *testing.T) { + m, h, _ := newTestManager(t) + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + h.commands = nil + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + if len(h.commands) != 0 { + t.Fatalf("unchanged ensure ran %v", h.commands) + } +} + +// TestEnsurePeerEditSyncsInPlace: rebuilding the link on a peer edit would +// drop every other client's session. +func TestEnsurePeerEditSyncsInPlace(t *testing.T) { + m, h, _ := newTestManager(t) + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + h.commands = nil + d := managerDesired(1) + d.Instance.Peers = d.Instance.Peers[:1] + if err := m.Ensure(d); err != nil { + t.Fatal(err) + } + if h.ran("ip link del") || h.ran("ip link add") || !h.ran("awg syncconf xui-awg1") { + t.Fatalf("peer edit ran %v, want only a syncconf", h.commands) + } + if strings.Contains(h.links["xui-awg1"].conf, "peerB") { + t.Fatal("removed peer is still on the live interface") + } +} + +// TestEnsureEgressChangeMovesNATAndForwardRules: a stale masquerade or +// FORWARD accept would keep pointing at the NIC the admin moved away from. +func TestEnsureEgressChangeMovesNATAndForwardRules(t *testing.T) { + m, h, _ := newTestManager(t) + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + d := managerDesired(1) + d.EgressInterface = "ens3" + if err := m.Ensure(d); err != nil { + t.Fatal(err) + } + if h.ran("ip link del") { + t.Fatal("an egress change rebuilt the link") + } + if nat := h.nat["xui_awg1"]; strings.Contains(nat, `"eth0"`) || !strings.Contains(nat, `oifname "ens3"`) { + t.Fatalf("NAT table after egress change = %q", nat) + } + rules := forwardRulesFor(h, "xui-awg1") + if len(rules) != 2 || strings.Contains(strings.Join(rules, "\n"), "eth0") { + t.Fatalf("FORWARD accepts after egress change = %v, want two for ens3 only", rules) + } +} + +// TestKeyRotationKeepsOldConfigUntilOldLinkIsGone: the old key on disk is +// what proves a leftover link is the panel's after a failed teardown. +func TestKeyRotationKeepsOldConfigUntilOldLinkIsGone(t *testing.T) { + m, h, dir := newTestManager(t) + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + rotated := managerDesired(1) + rotated.Instance.PrivateKey = otherPrivateKey + + h.failing["ip link del"] = errInjected + if err := m.Ensure(rotated); err == nil { + t.Fatal("rotation succeeded although the old link could not be removed") + } + if got := diskKey(t, dir, 1); got != publicKeyOf(t, testPrivateKey) { + t.Fatal("failed rotation replaced the config the live link still runs") + } + + delete(h.failing, "ip link del") + if err := m.Ensure(rotated); err != nil { + t.Fatalf("retry: %v", err) + } + want := publicKeyOf(t, otherPrivateKey) + if h.links["xui-awg1"].publicKey != want || diskKey(t, dir, 1) != want { + t.Fatal("retried rotation did not converge link and disk on the new key") + } +} + +// TestCreateFailureLeavesNoHalfBuiltLink: a keyless leftover would hold the +// name and port with nothing listening. +func TestCreateFailureLeavesNoHalfBuiltLink(t *testing.T) { + m, h, _ := newTestManager(t) + h.failing["ip -4 address add"] = errInjected + if err := m.Ensure(managerDesired(1)); err == nil { + t.Fatal("Ensure succeeded although address setup failed") + } + if h.linkExists("xui-awg1") || m.HasRunning() { + t.Fatal("failed create left a link or a managed entry behind") + } + delete(h.failing, "ip -4 address add") + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatalf("retry: %v", err) + } +} + +// TestFreshManagerAdoptsSurvivingInterface: after a crash the next process +// must take the link over without dropping its sessions. +func TestFreshManagerAdoptsSurvivingInterface(t *testing.T) { + first, h, dir := newTestManager(t) + if err := first.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + h.commands = nil + second := newManager(h, dir) + if err := second.Ensure(managerDesired(1)); err != nil { + t.Fatalf("adopt: %v", err) + } + if h.ran("ip link del") || h.ran("ip link add") { + t.Fatalf("adoption rebuilt the link: %v", h.commands) + } + state, ok := second.TrafficStateFor(1) + if !ok || !state.Adopted { + t.Fatalf("traffic state = %+v ok=%v, want an adopted baseline pending", state, ok) + } + if !h.ran("nft -f -") { + t.Fatal("adoption did not re-assert the NAT table it cannot see") + } +} + +// TestFreshManagerRebuildsKeylessLeftover: a crash between link add and +// setconf leaves a keyless link that must not block the inbound forever. +func TestFreshManagerRebuildsKeylessLeftover(t *testing.T) { + m, h, dir := newTestManager(t) + s, err := buildSpec(managerDesired(1)) + if err != nil { + t.Fatal(err) + } + if err := writeConf(dir, s.name, s.conf); err != nil { + t.Fatal(err) + } + h.links["xui-awg1"] = &fakeLink{} + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatalf("Ensure: %v", err) + } + if h.links["xui-awg1"].publicKey != publicKeyOf(t, testPrivateKey) { + t.Fatal("keyless leftover was not rebuilt") + } +} + +// TestEnsureRefusesInterfacesItCannotProveAreItsOwn: a same-named link +// without the panel's config, or with another key, belongs to someone else. +func TestEnsureRefusesInterfacesItCannotProveAreItsOwn(t *testing.T) { + foreignKey := publicKeyOf(t, otherPrivateKey) + cases := map[string]func(t *testing.T, dir string){ + "no owned config": func(*testing.T, string) {}, + "other key": func(t *testing.T, dir string) { + s, err := buildSpec(managerDesired(1)) + if err != nil { + t.Fatal(err) + } + if err := writeConf(dir, s.name, s.conf); err != nil { + t.Fatal(err) + } + }, + } + for name, seed := range cases { + t.Run(name, func(t *testing.T) { + m, h, dir := newTestManager(t) + seed(t, dir) + before, _ := os.ReadFile(ConfPathIn(dir, "xui-awg1")) + h.links["xui-awg1"] = &fakeLink{publicKey: foreignKey, mtu: 1380, addresses: []string{"10.8.1.1/24"}} + + if err := m.Ensure(managerDesired(1)); err == nil || !strings.Contains(err.Error(), "refusing") { + t.Fatalf("Ensure err = %v, want a refusal", err) + } + if err := m.Remove(1); err == nil { + t.Fatal("Remove tore down an interface the panel cannot prove is its own") + } + after, _ := os.ReadFile(ConfPathIn(dir, "xui-awg1")) + if h.links["xui-awg1"] == nil || h.links["xui-awg1"].publicKey != foreignKey || string(before) != string(after) { + t.Fatal("a refused interface or its config was changed") + } + }) + } +} + +// TestEnsureRefusesNonRegularOrForeignConfig: following a symlink or a +// directory at the config path would write the private key somewhere else. +func TestEnsureRefusesNonRegularOrForeignConfig(t *testing.T) { + cases := map[string]func(t *testing.T, path string){ + "directory": func(t *testing.T, path string) { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + }, + "unmarked file": func(t *testing.T, path string) { + if err := os.WriteFile(path, []byte("[Interface]\nPrivateKey = x\n"), 0o600); err != nil { + t.Fatal(err) + } + }, + "symlink": func(t *testing.T, path string) { + target := filepath.Join(filepath.Dir(path), "elsewhere") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + }, + } + for name, seed := range cases { + t.Run(name, func(t *testing.T) { + m, h, dir := newTestManager(t) + path := ConfPathIn(dir, "xui-awg1") + seed(t, path) + before, _ := os.Lstat(path) + if err := m.Ensure(managerDesired(1)); err == nil { + t.Fatal("Ensure accepted a config path the panel did not write") + } + after, err := os.Lstat(path) + if err != nil || after.Mode() != before.Mode() || after.ModTime() != before.ModTime() || h.linkExists("xui-awg1") { + t.Fatal("a refused config path was replaced or a link was created") + } + }) + } +} + +// TestReconcileTearsDownRecoveredUndesiredInterface: an inbound disabled +// while the panel was down must not keep forwarding with no quota enforced. +func TestReconcileTearsDownRecoveredUndesiredInterface(t *testing.T) { + first, h, dir := newTestManager(t) + if err := first.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + newManager(h, dir).Reconcile(nil) + if h.linkExists("xui-awg1") || h.nat["xui_awg1"] != "" || len(forwardRulesFor(h, "xui-awg1")) != 0 { + t.Fatalf("recovered interface survived: links=%v nat=%v forward=%v", h.links, h.nat, h.forward) + } + if _, err := os.Stat(ConfPathIn(dir, "xui-awg1")); !os.IsNotExist(err) { + t.Fatalf("private config survived teardown: %v", err) + } +} + +// TestReconcileLeavesForeignStateAlone: only state the panel can prove it +// wrote may be removed. +func TestReconcileLeavesForeignStateAlone(t *testing.T) { + m, h, dir := newTestManager(t) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + foreign := map[string]string{ + "xui-awg2.conf": "[Interface]\nPrivateKey = x\n", + "awg0.conf": ownershipMarker("awg0") + "\n[Interface]\nPrivateKey = " + testPrivateKey + "\n", + } + for name, body := range foreign { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + h.links["xui-awg4"] = &fakeLink{publicKey: publicKeyOf(t, otherPrivateKey)} + h.forward = []string{"-A FORWARD -i eth0 -j DROP"} + + m.Reconcile(nil) + for name := range foreign { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("foreign %s was removed: %v", name, err) + } + } + if !h.linkExists("xui-awg4") || len(h.forward) != 1 { + t.Fatal("a link or FORWARD rule the panel did not create was removed") + } +} + +// TestStopAllTearsInterfacesDown: a stopped panel enforces no quota or +// expiry, so its peers must stop forwarding, as on the embedded datapath. +func TestStopAllTearsInterfacesDown(t *testing.T) { + m, h, _ := newTestManager(t) + for _, id := range []int{1, 2} { + d := managerDesired(id) + d.Instance.ListenPort += id + if err := m.Ensure(d); err != nil { + t.Fatal(err) + } + } + m.StopAll() + if len(h.links) != 0 || len(h.nat) != 0 || len(h.forward) != 0 || m.HasRunning() { + t.Fatalf("after StopAll: links=%v nat=%v forward=%v running=%v", h.links, h.nat, h.forward, m.HasRunning()) + } +} + +// TestTeardownFailureKeepsInterfaceManagedForRetry: forgetting a link that +// is still up would leak its UDP port and block embedded mode from binding it. +func TestTeardownFailureKeepsInterfaceManagedForRetry(t *testing.T) { + m, h, _ := newTestManager(t) + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatal(err) + } + h.failing["ip link del"] = errInjected + if err := m.Remove(1); err == nil { + t.Fatal("Remove reported success although the link is still up") + } + m.Reconcile(nil) + if !m.Running(1) || !m.HasRunning() { + t.Fatal("a link that failed to come down is no longer reported running") + } + delete(h.failing, "ip link del") + m.Reconcile(nil) + if m.Running(1) || h.linkExists("xui-awg1") { + t.Fatal("retry did not complete the teardown") + } +} + +// TestTeardownRemovesOnlyItsOwnForwardRules: another inbound's accepts and +// the operator's own rules must survive one inbound's removal. +func TestTeardownRemovesOnlyItsOwnForwardRules(t *testing.T) { + m, h, _ := newTestManager(t) + h.forward = []string{"-A FORWARD -i eth0 -j DROP"} + for _, id := range []int{1, 2} { + d := managerDesired(id) + d.Instance.ListenPort += id + if err := m.Ensure(d); err != nil { + t.Fatal(err) + } + } + if err := m.Remove(1); err != nil { + t.Fatal(err) + } + if len(forwardRulesFor(h, "xui-awg1")) != 0 || len(forwardRulesFor(h, "xui-awg2")) != 2 || + !strings.Contains(strings.Join(h.forward, "\n"), "-A FORWARD -i eth0 -j DROP") { + t.Fatalf("FORWARD after removing inbound 1 = %v", h.forward) + } +} + +// TestEnsureWithoutIptablesStillForwards: on a pure nftables host the +// FORWARD accepts are an optimisation, not a requirement. +func TestEnsureWithoutIptablesStillForwards(t *testing.T) { + m, h, _ := newTestManager(t) + h.iptables = false + if err := m.Ensure(managerDesired(1)); err != nil { + t.Fatalf("Ensure without iptables: %v", err) + } + if h.ran("iptables") || h.nat["xui_awg1"] == "" { + t.Fatalf("commands = %v nat = %v", h.commands, h.nat) + } + if err := m.Remove(1); err != nil { + t.Fatalf("Remove without iptables: %v", err) + } +} diff --git a/internal/amneziawgkern/ownership.go b/internal/amneziawgkern/ownership.go new file mode 100644 index 000000000..8fb546514 --- /dev/null +++ b/internal/amneziawgkern/ownership.go @@ -0,0 +1,144 @@ +package amneziawgkern + +import ( + "fmt" + "os" + "strconv" + "strings" + + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +func ownershipMarker(name string) string { + return "# 3x-ui-owned-amneziawg: " + name +} + +func parseOwnedInterfaceName(name string) (int, bool) { + if !strings.HasPrefix(name, interfacePrefix) { + return 0, false + } + id, err := strconv.Atoi(strings.TrimPrefix(name, interfacePrefix)) + if err != nil || id <= 0 || ownedInterfaceName(id) != name { + return 0, false + } + return id, true +} + +// ownedConfigIDs lists the inbounds whose config the panel left on disk, so a +// fresh manager can find interfaces that outlived a crash. +func ownedConfigIDs(dir string) ([]int, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var ids []int + for _, entry := range entries { + id, ok := parseOwnedInterfaceName(strings.TrimSuffix(entry.Name(), ".conf")) + if !ok || !strings.HasSuffix(entry.Name(), ".conf") { + continue + } + if _, found, err := readOwnedConfig(dir, id); found && err == nil { + ids = append(ids, id) + } + } + return ids, nil +} + +// readOwnedConfig returns the config the panel wrote for id; found with an +// error means a file is there that the panel did not write. +func readOwnedConfig(dir string, id int) (string, bool, error) { + name := ownedInterfaceName(id) + path := ConfPathIn(dir, name) + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, err + } + if !info.Mode().IsRegular() { + return "", true, fmt.Errorf("amneziawgkern: refusing non-regular config %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return "", true, err + } + if !validOwnedConfig(string(data), name) { + return "", true, fmt.Errorf("amneziawgkern: refusing untrusted config %s", path) + } + return string(data), true, nil +} + +func configInterfacePublicKey(config string) (string, error) { + var privateKey string + for _, line := range strings.Split(config, "\n") { + if !strings.HasPrefix(line, "PrivateKey = ") { + continue + } + if privateKey != "" { + return "", fmt.Errorf("owned config has multiple private keys") + } + privateKey = strings.TrimPrefix(line, "PrivateKey = ") + } + publicKey, err := wgutil.PublicKeyFromPrivate(privateKey) + if err != nil { + return "", fmt.Errorf("owned config has an invalid private key: %w", err) + } + return publicKey, nil +} + +// interfaceKeys and peerKeys are every key Render emits; anything else means +// the file did not come from Render. +var ( + interfaceKeys = map[string]bool{ + "PrivateKey": true, "ListenPort": true, "Jc": true, "Jmin": true, "Jmax": true, + "S1": true, "S2": true, "S3": true, "S4": true, "H1": true, "H2": true, "H3": true, "H4": true, + "I1": true, "I2": true, "I3": true, "I4": true, "I5": true, "HeaderProtectionKey": true, + "ContentPaddingAddition": true, "RekeyAfterTime": true, "RekeyTimeout": true, "RejectAfterTime": true, + "KeepaliveTimeout": true, "MaxHandshakeAttempts": true, "RandomTrailers": true, "DisableCookies": true, + } + peerKeys = map[string]bool{"PublicKey": true, "PresharedKey": true, "AllowedIPs": true} +) + +func validOwnedConfig(config, name string) bool { + if !strings.HasPrefix(config, ownershipMarker(name)+"\n") || strings.ContainsAny(config, "\r\x00") { + return false + } + section := "" + interfaceCount := 0 + for _, line := range strings.Split(config, "\n")[1:] { + switch { + case line == "": + continue + case strings.HasPrefix(line, "#"): + if section != "Peer" { + return false + } + continue + case line == "[Interface]": + interfaceCount++ + if interfaceCount != 1 || section != "" { + return false + } + section = "Interface" + continue + case line == "[Peer]": + if section == "" { + return false + } + section = "Peer" + continue + } + key, value, ok := strings.Cut(line, " = ") + if !ok || value == "" { + return false + } + if (section == "Interface" && !interfaceKeys[key]) || (section == "Peer" && !peerKeys[key]) || section == "" { + return false + } + } + return interfaceCount == 1 +} diff --git a/internal/amneziawgkern/preflight.go b/internal/amneziawgkern/preflight.go new file mode 100644 index 000000000..d545b00b2 --- /dev/null +++ b/internal/amneziawgkern/preflight.go @@ -0,0 +1,211 @@ +package amneziawgkern + +import ( + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" +) + +// installCommand is what an admin runs to satisfy every host-level blocker at once. +func installCommand() string { + exe, err := os.Executable() + if err != nil || exe == "" { + return "sudo /usr/local/x-ui/x-ui install-awg-kernel" + } + return "sudo " + exe + " install-awg-kernel" +} + +// BlockerCode identifies one reason kernel mode cannot run. +type BlockerCode string + +const ( + BlockerNotRoot BlockerCode = "root" + BlockerToolsMissing BlockerCode = "tools" + BlockerModuleMissing BlockerCode = "module" + BlockerNftMissing BlockerCode = "nft" + BlockerIPv6 BlockerCode = "ipv6" + BlockerPortForward BlockerCode = "portForwarding" + BlockerRouteXray BlockerCode = "routeThroughXray" + BlockerListenAddress BlockerCode = "listenAddress" +) + +// Blocker is one unmet requirement. +type Blocker struct { + Code BlockerCode `json:"code" example:"tools"` + Detail string `json:"detail,omitempty" example:"awg"` +} + +// Readiness is the host's ability to run any kernel-mode inbound at all. +type Readiness struct { + Ready bool `json:"ready" example:"false"` + Blockers []Blocker `json:"blockers" example:"[{\"code\":\"tools\",\"detail\":\"awg\"}]"` + InstallCommand string `json:"installCommand" example:"sudo /usr/local/x-ui/x-ui install-awg-kernel"` +} + +// readinessTTL keeps the form's polling from re-running modprobe on every +// open. Short enough that installing the module shows up almost immediately. +const readinessTTL = 10 * time.Second + +var ( + readinessMu sync.Mutex + readinessCached Readiness + readinessAt time.Time +) + +// HostReadiness reports host requirements independently of a particular inbound. +// The UI can check it before an inbound exists. +func HostReadiness() Readiness { + readinessMu.Lock() + defer readinessMu.Unlock() + + if !readinessAt.IsZero() && time.Since(readinessAt) < readinessTTL { + return readinessCached + } + readinessCached = probeHost() + readinessAt = time.Now() + return readinessCached +} + +// resetReadinessCache exists for tests; production code has no reason to force +// a re-probe. +func resetReadinessCache() { + readinessMu.Lock() + defer readinessMu.Unlock() + readinessAt = time.Time{} + readinessCached = Readiness{} +} + +func probeHost() Readiness { + r := Readiness{Blockers: []Blocker{}, InstallCommand: installCommand()} + + if os.Geteuid() != 0 { + r.Blockers = append(r.Blockers, Blocker{Code: BlockerNotRoot}) + } + for _, bin := range []string{"awg", "ip"} { + if _, err := exec.LookPath(bin); err != nil { + r.Blockers = append(r.Blockers, Blocker{Code: BlockerToolsMissing, Detail: bin}) + } + } + if !moduleAvailable() { + r.Blockers = append(r.Blockers, Blocker{Code: BlockerModuleMissing}) + } + // NAT is the only egress kernel mode implements today, so nft is required unconditionally. + if _, err := exec.LookPath("nft"); err != nil { + r.Blockers = append(r.Blockers, Blocker{Code: BlockerNftMissing}) + } + + r.Ready = len(r.Blockers) == 0 + return r +} + +// Preflight reports host and inbound blockers before kernel mode is enabled. +// This protects API saves when the frontend readiness check is stale. +func Preflight(inst amneziawg.Instance, routeThroughXray bool) error { + blockers := append([]Blocker(nil), HostReadiness().Blockers...) + blockers = append(blockers, instanceFeatureBlockers(inst, routeThroughXray)...) + if len(blockers) == 0 { + return nil + } + return fmt.Errorf("kernel mode unavailable: %s", strings.Join(describe(blockers), "; ")) +} + +// ValidateInstanceFeatures rejects kernel features this backend cannot serve. +func ValidateInstanceFeatures(inst amneziawg.Instance, routeThroughXray bool) error { + blockers := instanceFeatureBlockers(inst, routeThroughXray) + if len(blockers) == 0 { + return nil + } + return fmt.Errorf("kernel mode unavailable: %s", strings.Join(describe(blockers), "; ")) +} + +func instanceFeatureBlockers(inst amneziawg.Instance, routeThroughXray bool) []Blocker { + var blockers []Blocker + if instanceWantsIPv6(inst) { + blockers = append(blockers, Blocker{Code: BlockerIPv6}) + } + if instanceWantsPortForwarding(inst) { + blockers = append(blockers, Blocker{Code: BlockerPortForward}) + } + if routeThroughXray { + blockers = append(blockers, Blocker{Code: BlockerRouteXray}) + } + if listenNeedsPinnedBind(inst.Listen) { + blockers = append(blockers, Blocker{Code: BlockerListenAddress, Detail: inst.Listen}) + } + return blockers +} + +// describe renders blockers for the error a direct API caller sees. The panel +// UI never reaches this path -- it translates the codes itself. +func describe(blockers []Blocker) []string { + out := make([]string, 0, len(blockers)) + for _, b := range blockers { + switch b.Code { + case BlockerNotRoot: + out = append(out, "the panel is not running as root, so it cannot create a network interface") + case BlockerToolsMissing: + out = append(out, fmt.Sprintf("%s not found in PATH (install amneziawg-tools and iproute2)", b.Detail)) + case BlockerModuleMissing: + out = append(out, "the amneziawg kernel module is not installed (run "+installCommand()+")") + case BlockerNftMissing: + out = append(out, "nft not found in PATH (install nftables); it is needed for the tunnel's NAT rules") + case BlockerIPv6: + out = append(out, "per-peer IPv6 egress is not supported in kernel mode yet; disable IPv6 on this inbound first") + case BlockerPortForward: + out = append(out, "per-client port forwarding is not supported in kernel mode yet; clear the forwarded ports first") + case BlockerRouteXray: + out = append(out, "routeThroughXray has no kernel-mode implementation; it would leave the tunnel with no egress path -- turn it off first") + case BlockerListenAddress: + out = append(out, fmt.Sprintf("kernel mode cannot bind to the configured listen address %q; clear it or use a wildcard", b.Detail)) + default: + out = append(out, string(b.Code)) + } + } + return out +} + +// moduleAvailable reports a loaded or installed module; the dry run keeps a +// readiness GET from loading kernel code, Ensure loads it when it is needed. +func moduleAvailable() bool { + if _, err := os.Stat("/sys/module/amneziawg"); err == nil { + return true + } + if _, err := exec.LookPath("modprobe"); err != nil { + return false + } + _, err := run("", "modprobe", "--dry-run", "--quiet", "amneziawg") + return err == nil +} + +func instanceWantsIPv6(inst amneziawg.Instance) bool { + if inst.IPv6Enabled { + return true + } + for _, a := range inst.Address { + if strings.Contains(a, ":") { + return true + } + } + for _, p := range inst.Peers { + for _, a := range p.AllowedIPs { + if strings.Contains(a, ":") { + return true + } + } + } + return false +} + +func instanceWantsPortForwarding(inst amneziawg.Instance) bool { + for _, p := range inst.Peers { + if strings.TrimSpace(p.ForwardedPorts) != "" { + return true + } + } + return false +} diff --git a/internal/amneziawgkern/preflight_test.go b/internal/amneziawgkern/preflight_test.go new file mode 100644 index 000000000..786668329 --- /dev/null +++ b/internal/amneziawgkern/preflight_test.go @@ -0,0 +1,117 @@ +package amneziawgkern + +import ( + "os" + "strings" + "testing" + "time" +) + +// TestInstallCommandNamesThePanelBinary: /usr/bin/x-ui is the management shell +// script, whose case list has no install-awg-kernel -- it just opens the menu. +func TestInstallCommandNamesThePanelBinary(t *testing.T) { + resetReadinessCache() + got := HostReadiness().InstallCommand + + exe, err := os.Executable() + if err != nil { + t.Skipf("os.Executable unavailable: %v", err) + } + if !strings.Contains(got, exe) { + t.Errorf("InstallCommand = %q, want it to name the running binary %q", got, exe) + } + if !strings.HasSuffix(got, " install-awg-kernel") { + t.Errorf("InstallCommand = %q, want it to end with the subcommand", got) + } +} + +// TestHostReadinessIsCached keeps the form's polling from re-running modprobe +// on every open. +func TestHostReadinessIsCached(t *testing.T) { + resetReadinessCache() + + HostReadiness() + readinessMu.Lock() + first := readinessAt + readinessMu.Unlock() + if first.IsZero() { + t.Fatal("first call must record a probe time") + } + + HostReadiness() + readinessMu.Lock() + second := readinessAt + readinessMu.Unlock() + if !second.Equal(first) { + t.Error("a call inside the TTL must reuse the cached result, not re-probe") + } + + // Age the entry past the TTL and confirm it re-probes. + readinessMu.Lock() + readinessAt = time.Now().Add(-2 * readinessTTL) + readinessMu.Unlock() + HostReadiness() + readinessMu.Lock() + third := readinessAt + readinessMu.Unlock() + if third.Equal(first) { + t.Error("an expired entry must be re-probed") + } +} + +// TestPreflightAddsInstanceBlockersToHostOnes: the two kinds are collected in +// one pass so the admin sees everything to fix at once. +func TestPreflightAddsInstanceBlockers(t *testing.T) { + resetReadinessCache() + + v6 := testInstance() + v6.IPv6Enabled = true + err := Preflight(v6, false) + if err == nil || !strings.Contains(err.Error(), "IPv6") { + t.Errorf("Preflight must refuse IPv6 inbounds, got %v", err) + } + + pf := testInstance() + pf.Peers[0].ForwardedPorts = "80, 443" + if err := Preflight(pf, false); err == nil || !strings.Contains(err.Error(), "port forwarding") { + t.Errorf("Preflight must refuse port forwarding, got %v", err) + } + + v6addr := testInstance() + v6addr.Peers[0].AllowedIPs = append(v6addr.Peers[0].AllowedIPs, "fd00::2/128") + if err := Preflight(v6addr, false); err == nil || !strings.Contains(err.Error(), "IPv6") { + t.Errorf("Preflight must refuse peers with IPv6 AllowedIPs, got %v", err) + } + + pinned := testInstance() + pinned.Listen = "203.0.113.10" + if err := Preflight(pinned, false); err == nil || !strings.Contains(err.Error(), "cannot bind to the configured listen address") { + t.Errorf("Preflight must refuse a pinned listen address the kernel backend cannot honor, got %v", err) + } +} + +// TestPreflightDoesNotInventInstanceBlockers: a plain instance must only ever +// be refused for host reasons, never for a feature it is not using. +func TestPreflightDoesNotInventInstanceBlockers(t *testing.T) { + resetReadinessCache() + + if err := Preflight(testInstance(), false); err != nil { + if strings.Contains(err.Error(), "IPv6") || strings.Contains(err.Error(), "port forwarding") { + t.Errorf("plain instance must not trip feature gates, got %v", err) + } + } +} + +// TestPreflightRefusesRouteThroughXray: Render drops the whole NAT block for it +// and nothing replaces it, so the tunnel would come up with no egress path. +func TestPreflightRefusesRouteThroughXray(t *testing.T) { + resetReadinessCache() + + err := Preflight(testInstance(), true) + if err == nil || !strings.Contains(err.Error(), "routeThroughXray") { + t.Errorf("Preflight must refuse routeThroughXray in kernel mode, got %v", err) + } + if err := Preflight(testInstance(), false); err != nil && strings.Contains(err.Error(), "routeThroughXray") { + t.Errorf("routeThroughXray must not be reported when it is off, got %v", err) + } +} diff --git a/internal/amneziawgkern/stats.go b/internal/amneziawgkern/stats.go new file mode 100644 index 000000000..9adaadd2d --- /dev/null +++ b/internal/amneziawgkern/stats.go @@ -0,0 +1,414 @@ +package amneziawgkern + +import ( + "fmt" + "strconv" + "strings" + "sync" + "time" +) + +// PeerStat is one row of `awg show dump`. +type PeerStat struct { + PublicKey string + // Endpoint is "ip:port", or "(none)" while the peer has never been seen. + Endpoint string + // LatestHandshake is a unix timestamp; 0 means never. + LatestHandshake int64 + // RxBytes is what the server received from the peer -- the client's + // upload. TxBytes is what the server sent -- the client's download. + RxBytes int64 + TxBytes int64 +} + +// Delta is one peer's traffic since the previous poll, already corrected for +// counter resets. +type Delta struct { + PublicKey string + Up int64 + Down int64 +} + +// Dump reads the live per-peer counters for one interface. +func Dump(iface string) ([]PeerStat, error) { + out, err := run("", "awg", "show", iface, "dump") + if err != nil { + return nil, fmt.Errorf("amneziawgkern: awg show %s dump: %w (%s)", iface, err, out) + } + return ParseDump(out) +} + +// ParseDump parses the tab-separated output from `awg show dump`. +func ParseDump(out string) ([]PeerStat, error) { + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + return nil, nil + } + + var stats []PeerStat + // lines[0] is the interface itself (private key, public key, listen port, + // fwmark) and carries no per-peer counters. + for _, line := range lines[1:] { + f := strings.Split(strings.TrimRight(line, "\r"), "\t") + if len(f) < 7 { + continue + } + handshake, err := strconv.ParseInt(f[4], 10, 64) + if err != nil { + return nil, fmt.Errorf("amneziawgkern: parsing handshake %q: %w", f[4], err) + } + rx, err := strconv.ParseInt(f[5], 10, 64) + if err != nil { + return nil, fmt.Errorf("amneziawgkern: parsing rx %q: %w", f[5], err) + } + tx, err := strconv.ParseInt(f[6], 10, 64) + if err != nil { + return nil, fmt.Errorf("amneziawgkern: parsing tx %q: %w", f[6], err) + } + stats = append(stats, PeerStat{ + PublicKey: f[0], + Endpoint: f[2], + LatestHandshake: handshake, + RxBytes: rx, + TxBytes: tx, + }) + } + return stats, nil +} + +type counters struct { + rx, tx int64 +} + +type endpointSighting struct { + addr string + seen time.Time +} + +// Collector converts kernel counters to traffic deltas and tracks recent peer +// endpoints; adopted interfaces are primed once before deltas resume. +type Collector struct { + mu sync.Mutex + last map[string]counters + endpoints map[string][]endpointSighting + // emails maps iface/publicKey to the owning client, so observations can be + // reported against the identity the rest of the panel keys on. + emails map[string]string + // handshakes is each peer's latest handshake, the only online signal a + // kernel interface exposes. + handshakes map[string]int64 +} + +func NewCollector() *Collector { + return &Collector{ + last: map[string]counters{}, + endpoints: map[string][]endpointSighting{}, + emails: map[string]string{}, + handshakes: map[string]int64{}, + } +} + +func key(iface, publicKey string) string { return iface + "/" + publicKey } + +// Delta records the current counters and returns what changed since the last call. +func (c *Collector) Delta(iface string, stats []PeerStat) []Delta { + c.mu.Lock() + defer c.mu.Unlock() + + out := make([]Delta, 0, len(stats)) + for _, s := range stats { + k := key(iface, s.PublicKey) + prev, seen := c.last[k] + up, down := s.RxBytes, s.TxBytes + if seen { + up = s.RxBytes - prev.rx + down = s.TxBytes - prev.tx + if up < 0 { + up = s.RxBytes + } + if down < 0 { + down = s.TxBytes + } + } + c.last[k] = counters{rx: s.RxBytes, tx: s.TxBytes} + if up == 0 && down == 0 { + continue + } + out = append(out, Delta{PublicKey: s.PublicKey, Up: up, Down: down}) + } + return out +} + +// Prime sets an adopted interface's current counters as the starting point. +func (c *Collector) Prime(iface string, stats []PeerStat) { + c.mu.Lock() + defer c.mu.Unlock() + + prefix := iface + "/" + for k := range c.last { + if strings.HasPrefix(k, prefix) { + delete(c.last, k) + } + } + for _, s := range stats { + c.last[key(iface, s.PublicKey)] = counters{rx: s.RxBytes, tx: s.TxBytes} + } +} + +// ObserveEndpoints tracks recent source addresses used by kernel-mode peers. +func (c *Collector) ObserveEndpoints(iface string, stats []PeerStat, now time.Time, window time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + cutoff := now.Add(-window) + prefix := iface + "/" + for k, sightings := range c.endpoints { + if !strings.HasPrefix(k, prefix) { + continue + } + kept := sightings[:0] + for _, sighting := range sightings { + if !sighting.seen.Before(cutoff) { + kept = append(kept, sighting) + } + } + if len(kept) == 0 { + delete(c.endpoints, k) + } else { + c.endpoints[k] = kept + } + } + for _, s := range stats { + addr := endpointHost(s.Endpoint) + if addr == "" || s.LatestHandshake <= 0 { + continue + } + seen := time.Unix(s.LatestHandshake, 0) + if seen.After(now) || seen.Before(cutoff) { + continue + } + k := key(iface, s.PublicKey) + kept := c.endpoints[k][:0] + found := false + for _, e := range c.endpoints[k] { + if e.addr == addr { + if seen.After(e.seen) { + e.seen = seen + } + found = true + } + kept = append(kept, e) + } + if !found { + kept = append(kept, endpointSighting{addr: addr, seen: seen}) + } + if len(kept) == 0 { + delete(c.endpoints, k) + } else { + c.endpoints[k] = kept + } + } +} + +// DistinctEndpoints reports how many different addresses a peer has connected +// from inside the window last passed to ObserveEndpoints. +func (c *Collector) DistinctEndpoints(iface, publicKey string) int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.endpoints[key(iface, publicKey)]) +} + +// EndpointsFor returns the tracked addresses for a peer, for surfacing in the +// UI next to the limit that acted on them. +func (c *Collector) EndpointsFor(iface, publicKey string) []string { + c.mu.Lock() + defer c.mu.Unlock() + + seen := c.endpoints[key(iface, publicKey)] + out := make([]string, 0, len(seen)) + for _, e := range seen { + out = append(out, e.addr) + } + return out +} + +// Forget drops all state for an interface that is going away, so a later +// inbound reusing the name cannot inherit stale counters or sightings. +func (c *Collector) Forget(iface string) { + c.mu.Lock() + defer c.mu.Unlock() + + prefix := iface + "/" + for k := range c.last { + if strings.HasPrefix(k, prefix) { + delete(c.last, k) + } + } + for k := range c.endpoints { + if strings.HasPrefix(k, prefix) { + delete(c.endpoints, k) + } + } + for k := range c.emails { + if strings.HasPrefix(k, prefix) { + delete(c.emails, k) + } + } + for k := range c.handshakes { + if strings.HasPrefix(k, prefix) { + delete(c.handshakes, k) + } + } +} + +// endpointHost strips the port so a peer behind a NAT that renumbers its +// source port on every rekey is not mistaken for a peer in a new location. +func endpointHost(endpoint string) string { + e := strings.TrimSpace(endpoint) + if e == "" || e == "(none)" { + return "" + } + if i := strings.LastIndex(e, "]:"); i >= 0 { // [v6]:port + return e[:i+1] + } + if i := strings.LastIndex(e, ":"); i >= 0 && strings.Count(e, ":") == 1 { + return e[:i] + } + return e +} + +var ( + collectorOnce sync.Once + collector *Collector +) + +// GetCollector returns the process-wide counter/endpoint collector. +func GetCollector() *Collector { + collectorOnce.Do(func() { collector = NewCollector() }) + return collector +} + +// EmailFor returns the client a peer key was last recorded against, so a peer +// that just left the desired set is still charged for its final counters. +func (c *Collector) EmailFor(iface, publicKey string) string { + c.mu.Lock() + defer c.mu.Unlock() + return c.emails[key(iface, publicKey)] +} + +// SetEmails records which client each peer key belongs to, so observations can +// be reported against the identity the rest of the panel uses. +func (c *Collector) SetEmails(iface string, byPublicKey map[string]string) { + c.mu.Lock() + defer c.mu.Unlock() + + active := make(map[string]string, len(byPublicKey)) + for pk, email := range byPublicKey { + if pk != "" && email != "" { + active[key(iface, pk)] = email + } + } + prefix := iface + "/" + for k := range c.last { + if strings.HasPrefix(k, prefix) { + if _, ok := active[k]; !ok { + delete(c.last, k) + } + } + } + for k := range c.endpoints { + if strings.HasPrefix(k, prefix) { + if _, ok := active[k]; !ok { + delete(c.endpoints, k) + } + } + } + for k := range c.handshakes { + if strings.HasPrefix(k, prefix) { + if _, ok := active[k]; !ok { + delete(c.handshakes, k) + } + } + } + if c.emails == nil { + c.emails = map[string]string{} + } + for k := range c.emails { + if strings.HasPrefix(k, prefix) { + if _, ok := active[k]; !ok { + delete(c.emails, k) + } + } + } + for k, email := range active { + c.emails[k] = email + } +} + +// ClientIPs returns recent peer endpoints as email -> address -> last-seen Unix timestamp. +func (c *Collector) ClientIPs() map[string]map[string]int64 { + c.mu.Lock() + defer c.mu.Unlock() + + out := map[string]map[string]int64{} + for k, sightings := range c.endpoints { + email := c.emails[k] + if email == "" { + continue + } + for _, s := range sightings { + addr := strings.Trim(s.addr, "[]") + if _, ok := out[email]; !ok { + out[email] = map[string]int64{} + } + if prev, seen := out[email][addr]; !seen || s.seen.Unix() > prev { + out[email][addr] = s.seen.Unix() + } + } + } + return out +} + +// onlineHandshakeAge is how recently a peer must have completed a handshake to count as online. +const onlineHandshakeAge = 3 * time.Minute + +// ObserveHandshakes records each peer's latest handshake so online status can be derived. +func (c *Collector) ObserveHandshakes(iface string, stats []PeerStat) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.handshakes == nil { + c.handshakes = map[string]int64{} + } + for _, s := range stats { + if s.LatestHandshake > 0 { + c.handshakes[key(iface, s.PublicKey)] = s.LatestHandshake + } + } +} + +// OnlineEmails returns the clients whose peers handshook recently enough to be +// considered connected. +func (c *Collector) OnlineEmails(now time.Time) []string { + c.mu.Lock() + defer c.mu.Unlock() + + cutoff := now.Add(-onlineHandshakeAge).Unix() + seen := make(map[string]struct{}, len(c.handshakes)) + out := make([]string, 0, len(c.handshakes)) + for k, ts := range c.handshakes { + if ts < cutoff { + continue + } + email := c.emails[k] + if email == "" { + continue + } + if _, dup := seen[email]; dup { + continue + } + seen[email] = struct{}{} + out = append(out, email) + } + return out +} diff --git a/internal/amneziawgkern/stats_test.go b/internal/amneziawgkern/stats_test.go new file mode 100644 index 000000000..4eb0faf26 --- /dev/null +++ b/internal/amneziawgkern/stats_test.go @@ -0,0 +1,312 @@ +package amneziawgkern + +import ( + "testing" + "time" +) + +// realDump is a verbatim `awg show awg1 dump` body: interface line first, then +// one tab-separated line per peer, including a peer that has never connected. +const realDump = "cPriv=\tsMVZKrv7dcR79rp3z5rBzbiOawzyykZVr9meuqMbIDg=\t31884\toff\n" + + "peerAPublicKeyBase64ValueForTestsPadding000=\t(none)\t92.36.61.153:51820\t10.8.1.2/32\t1788032481\t1048576\t4194304\t0\n" + + "peerBPublicKeyBase64ValueForTestsPadding000=\t(none)\t(none)\t10.8.1.3/32\t0\t0\t0\t0\n" + +func TestParseDumpSkipsInterfaceLine(t *testing.T) { + t.Parallel() + + stats, err := ParseDump(realDump) + if err != nil { + t.Fatalf("ParseDump: %v", err) + } + if len(stats) != 2 { + t.Fatalf("got %d peers, want 2 (the interface line must not be counted)", len(stats)) + } + a := stats[0] + if a.PublicKey != "peerAPublicKeyBase64ValueForTestsPadding000=" { + t.Errorf("public key = %q", a.PublicKey) + } + if a.Endpoint != "92.36.61.153:51820" { + t.Errorf("endpoint = %q", a.Endpoint) + } + if a.LatestHandshake != 1788032481 { + t.Errorf("handshake = %d", a.LatestHandshake) + } + if a.RxBytes != 1048576 || a.TxBytes != 4194304 { + t.Errorf("rx/tx = %d/%d, want 1048576/4194304", a.RxBytes, a.TxBytes) + } + if stats[1].Endpoint != "(none)" || stats[1].LatestHandshake != 0 { + t.Errorf("a never-connected peer must still be reported: %+v", stats[1]) + } +} + +func TestParseDumpEmpty(t *testing.T) { + t.Parallel() + + stats, err := ParseDump("") + if err != nil { + t.Fatalf("ParseDump(empty): %v", err) + } + if len(stats) != 0 { + t.Errorf("got %d peers from empty output", len(stats)) + } +} + +// TestDeltaFirstPollCountsCurrentTotals: the interface was just created, so +// whatever the counters already hold is genuinely new traffic. +func TestDeltaFirstPollCountsCurrentTotals(t *testing.T) { + t.Parallel() + + c := NewCollector() + got := c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100, TxBytes: 200}}) + if len(got) != 1 || got[0].Up != 100 || got[0].Down != 200 { + t.Fatalf("first poll = %+v, want up=100 down=200", got) + } +} + +// TestDeltaReportsOnlyTheIncrement is the core of the accounting: reporting +// totals every poll would multiply every client's usage by the poll count. +func TestDeltaReportsOnlyTheIncrement(t *testing.T) { + t.Parallel() + + c := NewCollector() + c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100, TxBytes: 200}}) + got := c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 350, TxBytes: 700}}) + if len(got) != 1 || got[0].Up != 250 || got[0].Down != 500 { + t.Fatalf("second poll = %+v, want up=250 down=500", got) + } +} + +// TestDeltaSkipsIdlePeers keeps the write path quiet: an idle inbound should +// not produce a database write every tick. +func TestDeltaSkipsIdlePeers(t *testing.T) { + t.Parallel() + + c := NewCollector() + c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100, TxBytes: 200}}) + if got := c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100, TxBytes: 200}}); len(got) != 0 { + t.Fatalf("unchanged counters must produce no delta, got %+v", got) + } +} + +// TestDeltaSurvivesInterfaceRebuild is the reset case: awg-quick down/up zeroes the kernel counters, +// and a naive subtraction would go negative and either wrap or silently credit the client back their +func TestDeltaSurvivesInterfaceRebuild(t *testing.T) { + t.Parallel() + + c := NewCollector() + c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 5_000_000, TxBytes: 9_000_000}}) + got := c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 1_000, TxBytes: 2_000}}) + if len(got) != 1 { + t.Fatalf("post-rebuild poll must report the new counters, got %+v", got) + } + if got[0].Up != 1_000 || got[0].Down != 2_000 { + t.Errorf("post-rebuild delta = up %d down %d, want 1000/2000", got[0].Up, got[0].Down) + } +} + +func TestDeltaKeepsInterfacesSeparate(t *testing.T) { + t.Parallel() + + c := NewCollector() + c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100}}) + got := c.Delta("awg2", []PeerStat{{PublicKey: "p", RxBytes: 100}}) + if len(got) != 1 || got[0].Up != 100 { + t.Errorf("the same peer key on another interface is a separate counter, got %+v", got) + } +} + +func TestForgetDropsInterfaceState(t *testing.T) { + t.Parallel() + + c := NewCollector() + c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100}}) + c.Forget("awg1") + got := c.Delta("awg1", []PeerStat{{PublicKey: "p", RxBytes: 100}}) + if len(got) != 1 || got[0].Up != 100 { + t.Errorf("after Forget the next poll starts fresh, got %+v", got) + } +} + +// TestEndpointRoamingIgnoresPortChanges: a peer behind NAT gets a new source port on every rekey, and +// counting those as new locations would ban every client with a limit of 1. +func TestEndpointRoamingIgnoresPortChanges(t *testing.T) { + t.Parallel() + + c := NewCollector() + now := time.Unix(1_800_000_000, 0) + window := time.Hour + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "203.0.113.7:51820", LatestHandshake: now.Unix()}}, now, window) + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "203.0.113.7:40001", LatestHandshake: now.Unix()}}, now.Add(time.Minute), window) + + if got := c.DistinctEndpoints("awg1", "p"); got != 1 { + t.Errorf("same address on a new port = %d distinct endpoints, want 1", got) + } +} + +func TestEndpointRoamingCountsDistinctAddresses(t *testing.T) { + t.Parallel() + + c := NewCollector() + now := time.Unix(1_800_000_000, 0) + window := time.Hour + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "203.0.113.7:51820", LatestHandshake: now.Unix()}}, now, window) + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "198.51.100.4:51820", LatestHandshake: now.Add(time.Minute).Unix()}}, now.Add(time.Minute), window) + + if got := c.DistinctEndpoints("awg1", "p"); got != 2 { + t.Errorf("two locations = %d distinct endpoints, want 2", got) + } +} + +// TestEndpointRoamingExpires: a client who moved home weeks ago must not stay +// counted forever, or the limit becomes a one-way ratchet. +func TestEndpointRoamingExpires(t *testing.T) { + t.Parallel() + + c := NewCollector() + now := time.Unix(1_800_000_000, 0) + window := 10 * time.Minute + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "203.0.113.7:51820", LatestHandshake: now.Unix()}}, now, window) + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "198.51.100.4:51820", LatestHandshake: now.Add(time.Hour).Unix()}}, now.Add(time.Hour), window) + + if got := c.DistinctEndpoints("awg1", "p"); got != 1 { + t.Errorf("the stale sighting should have aged out, got %d", got) + } +} + +func TestEndpointRoamingExpiresIdleEndpointAndKeepsActiveInterfaces(t *testing.T) { + t.Parallel() + + c := NewCollector() + start := time.Unix(1_800_000_000, 0) + window := 30 * time.Minute + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "idle", Endpoint: "203.0.113.7:51820", LatestHandshake: start.Unix()}}, start, window) + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "roaming", Endpoint: "198.51.100.1:51820", LatestHandshake: start.Unix()}}, start, window) + c.ObserveEndpoints("awg2", []PeerStat{{PublicKey: "other", Endpoint: "192.0.2.9:51820", LatestHandshake: start.Unix()}}, start, window) + + later := start.Add(31 * time.Minute) + c.ObserveEndpoints("awg1", []PeerStat{ + {PublicKey: "idle", Endpoint: "203.0.113.7:51820", LatestHandshake: start.Unix()}, + {PublicKey: "roaming", Endpoint: "198.51.100.2:51820", LatestHandshake: later.Unix()}, + }, later, window) + c.ObserveEndpoints("awg2", []PeerStat{{PublicKey: "other", Endpoint: "192.0.2.9:51820", LatestHandshake: later.Unix()}}, later, window) + + if got := c.EndpointsFor("awg1", "idle"); len(got) != 0 { + t.Fatalf("idle peer endpoint remained visible after its handshake aged out: %v", got) + } + if got := c.EndpointsFor("awg1", "roaming"); len(got) != 1 || got[0] != "198.51.100.2" { + t.Fatalf("active roaming peer endpoints = %v, want only current address", got) + } + if got := c.EndpointsFor("awg2", "other"); len(got) != 1 || got[0] != "192.0.2.9" { + t.Fatalf("active peer on another interface was lost: %v", got) + } +} + +// A peer that never completed a handshake has no observed location. +func TestEndpointRoamingSkipsNeverConnected(t *testing.T) { + t.Parallel() + + c := NewCollector() + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "p", Endpoint: "(none)"}}, time.Now(), time.Hour) + if got := c.DistinctEndpoints("awg1", "p"); got != 0 { + t.Errorf("a peer that never connected has %d endpoints, want 0", got) + } +} + +func TestEndpointHostHandlesIPv6(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "203.0.113.7:51820": "203.0.113.7", + "[2a12:bec4::1]:51820": "[2a12:bec4::1]", + "(none)": "", + "": "", + " 198.51.100.4:1 ": "198.51.100.4", + } + for in, want := range cases { + if got := endpointHost(in); got != want { + t.Errorf("endpointHost(%q) = %q, want %q", in, got, want) + } + } +} + +// TestOnlineEmailsUsesHandshakeFreshness: kernel-mode peers are invisible to Xray's online-users API, +// so a stale handshake is the only thing separating a connected client from one whose laptop is shut. +func TestOnlineEmailsUsesHandshakeFreshness(t *testing.T) { + t.Parallel() + + now := time.Now() + c := NewCollector() + c.SetEmails("awg1", map[string]string{"fresh": "alice", "stale": "bob"}) + c.ObserveHandshakes("awg1", []PeerStat{ + {PublicKey: "fresh", LatestHandshake: now.Add(-30 * time.Second).Unix()}, + {PublicKey: "stale", LatestHandshake: now.Add(-2 * time.Hour).Unix()}, + }) + + got := c.OnlineEmails(now) + if len(got) != 1 || got[0] != "alice" { + t.Fatalf("OnlineEmails = %v, want [alice]", got) + } +} + +// TestOnlineEmailsIgnoresNeverConnected: handshake 0 is "never", not "now". +func TestOnlineEmailsIgnoresNeverConnected(t *testing.T) { + t.Parallel() + + now := time.Now() + c := NewCollector() + c.SetEmails("awg1", map[string]string{"p": "alice"}) + c.ObserveHandshakes("awg1", []PeerStat{{PublicKey: "p", LatestHandshake: 0}}) + + if got := c.OnlineEmails(now); len(got) != 0 { + t.Errorf("OnlineEmails = %v, want empty", got) + } +} + +// TestOnlineEmailsDedupesPeersOfOneClient: a client with two devices on the +// same email must be counted once, matching how the Xray side reports. +func TestOnlineEmailsDedupesPeersOfOneClient(t *testing.T) { + t.Parallel() + + now := time.Now() + c := NewCollector() + c.SetEmails("awg1", map[string]string{"a": "alice", "b": "alice"}) + c.ObserveHandshakes("awg1", []PeerStat{ + {PublicKey: "a", LatestHandshake: now.Unix()}, + {PublicKey: "b", LatestHandshake: now.Unix()}, + }) + + if got := c.OnlineEmails(now); len(got) != 1 { + t.Errorf("OnlineEmails = %v, want one entry", got) + } +} + +func TestSetEmailsPrunesRemovedPeerStateWithoutAffectingOtherInterfaces(t *testing.T) { + c := NewCollector() + now := time.Unix(1_800_000_000, 0) + c.Delta("awg1", []PeerStat{{PublicKey: "removed", RxBytes: 10, TxBytes: 20}}) + c.ObserveEndpoints("awg1", []PeerStat{{PublicKey: "removed", Endpoint: "203.0.113.4:1234", LatestHandshake: now.Unix()}}, now, time.Hour) + c.ObserveHandshakes("awg1", []PeerStat{{PublicKey: "removed", LatestHandshake: now.Unix()}}) + c.SetEmails("awg1", map[string]string{"removed": "old@example.test"}) + + c.Delta("awg2", []PeerStat{{PublicKey: "kept", RxBytes: 30, TxBytes: 40}}) + c.ObserveEndpoints("awg2", []PeerStat{{PublicKey: "kept", Endpoint: "203.0.113.5:1234", LatestHandshake: now.Unix()}}, now, time.Hour) + c.ObserveHandshakes("awg2", []PeerStat{{PublicKey: "kept", LatestHandshake: now.Unix()}}) + c.SetEmails("awg2", map[string]string{"kept": "kept@example.test"}) + + c.SetEmails("awg1", map[string]string{}) + if got := c.ClientIPs(); len(got) != 1 || len(got["old@example.test"]) != 0 { + t.Fatalf("removed peer endpoint remained visible: %+v", got) + } + if got := c.OnlineEmails(now); len(got) != 1 || got[0] != "kept@example.test" { + t.Fatalf("removed peer remained online or other interface was lost: %v", got) + } + if got := c.DistinctEndpoints("awg1", "removed"); got != 0 { + t.Fatalf("removed peer endpoint count = %d, want 0", got) + } + if got := c.Delta("awg1", []PeerStat{{PublicKey: "removed", RxBytes: 15, TxBytes: 25}}); len(got) != 1 || got[0].Up != 15 || got[0].Down != 25 { + t.Fatalf("re-added peer did not start a fresh counter baseline: %+v", got) + } + if got := c.DistinctEndpoints("awg2", "kept"); got != 1 { + t.Fatalf("other interface endpoint count = %d, want 1", got) + } +} diff --git a/internal/util/wirecodec/wirecodec.go b/internal/util/wirecodec/wirecodec.go index 63e9e09c3..0b3a130a1 100644 --- a/internal/util/wirecodec/wirecodec.go +++ b/internal/util/wirecodec/wirecodec.go @@ -22,6 +22,11 @@ const ( EncodingZstd = "zstd" // CapZstd is the capability token advertised in CapsHeader. CapZstd = "zstd" + // CapAmneziaWGKernel marks a node that honours an AmneziaWG inbound's + // kernelMode; an older node drops the flag and serves it embedded. + CapAmneziaWGKernel = "awg-kernel" + // NodeCaps is the full CapsHeader value this build advertises. + NodeCaps = CapZstd + "," + CapAmneziaWGKernel // maxDecodeBytes bounds in-memory decompression to defuse a zstd bomb from // an (authenticated) node-API caller. diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 44c96624d..7db982adb 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" @@ -80,6 +81,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.GET("/allLinks", a.getAllInboundLinks) g.GET("/get/:id", a.getInbound) g.GET("/:id/fallbacks", a.getFallbacks) + g.GET("/amneziawgKernelReadiness", a.getAmneziaWGKernelReadiness) g.POST("/add", a.addInbound) g.POST("/del/:id", a.delInbound) @@ -144,6 +146,12 @@ func (a *InboundController) getInboundOptions(c *gin.Context) { jsonObj(c, options, nil) } +// getAmneziaWGKernelReadiness reports whether this host can run an AmneziaWG +// inbound on the kernel module, so the form can disable the toggle and say why. +func (a *InboundController) getAmneziaWGKernelReadiness(c *gin.Context) { + jsonObj(c, amneziawgkern.HostReadiness(), nil) +} + // getInbound retrieves a specific inbound by its ID. func (a *InboundController) getInbound(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) diff --git a/internal/web/job/amneziawg_job.go b/internal/web/job/amneziawg_job.go index 452c4b4ac..9863d7207 100644 --- a/internal/web/job/amneziawg_job.go +++ b/internal/web/job/amneziawg_job.go @@ -2,16 +2,22 @@ package job import ( "encoding/json" + "time" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) -// AmneziaWGJob converges embedded AmneziaWG interfaces (inbounds AND the -// template's "amneziawg" outbounds) every 10s; stats stay with Xray. +// endpointWindow is how long a kernel-mode peer's connect location stays +// counted toward its IP limit -- Xray's access log never sees these peers. +const endpointWindow = 30 * time.Minute + +// AmneziaWGJob converges AmneziaWG interfaces (inbounds on either datapath AND +// the template's "amneziawg" outbounds) every 10s; only kernel mode needs stats. type AmneziaWGJob struct { inboundService service.InboundService settingService service.SettingService @@ -31,9 +37,53 @@ func (j *AmneziaWGJob) Run() { return } - wanted := make([]amneziawgnet.Desired, 0, len(desired)) + embedded, kernel := splitAmneziaWGDatapaths(desired) + // Counters first: reconcile drops disabled peers and removed interfaces, + // and their counters with them. + j.collectKernelTraffic(kernel) + reconcileAmneziaWGDatapaths(embedded, kernel, amneziawgnet.GetManager(), amneziawgkern.GetManager()) + + outboundDesired, err := j.desiredOutboundInstances() + if err != nil { + logger.Warning("amneziawg job: get desired outbound instances failed:", err) + return + } + amneziawgnet.GetOutboundManager().Reconcile(outboundDesired) + // Xray's bridges are generated apart from the listener; one that moved needs them regenerated. + if amneziawgnet.BridgesStale() { + j.xrayService.SetToNeedRestart() + } +} + +// FlushKernelTraffic charges what kernel interfaces counted since the last +// tick; shutdown runs it before tearing the interfaces and counters down. +func (j *AmneziaWGJob) FlushKernelTraffic() { + // With no kernel interface there is nothing to charge, so a panel that + // never used kernel mode shuts down without touching the database. + if !amneziawgkern.GetManager().HasRunning() { + return + } + desired, err := j.inboundService.DesiredAmneziaWGInstances() + if err != nil { + logger.Warning("amneziawg job: get desired instances for the final traffic flush failed:", err) + } + _, kernel := splitAmneziaWGDatapaths(desired) + j.collectKernelTraffic(kernel) +} + +func splitAmneziaWGDatapaths(desired []amneziawg.Instance) ([]amneziawgnet.Desired, []amneziawgkern.Desired) { + embedded := make([]amneziawgnet.Desired, 0, len(desired)) + kernel := make([]amneziawgkern.Desired, 0, len(desired)) for _, inst := range desired { - wanted = append(wanted, amneziawgnet.Desired{ + if inst.KernelMode { + kernel = append(kernel, amneziawgkern.Desired{ + Instance: inst, + EgressInterface: inst.ExternalInterface, + RouteThroughXray: inst.RouteThroughXray, + }) + continue + } + embedded = append(embedded, amneziawgnet.Desired{ Instance: inst, Options: amneziawgnet.DeviceOptions{ HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey, @@ -48,18 +98,32 @@ func (j *AmneziaWGJob) Run() { }, }) } - amneziawgnet.GetManager().Reconcile(wanted) + return embedded, kernel +} - outboundDesired, err := j.desiredOutboundInstances() - if err != nil { - logger.Warning("amneziawg job: get desired outbound instances failed:", err) - return +type embeddedInboundManager interface { + Remove(int) + Reconcile([]amneziawgnet.Desired) +} + +type kernelInboundManager interface { + Reconcile([]amneziawgkern.Desired) + Running(int) bool +} + +func reconcileAmneziaWGDatapaths(embedded []amneziawgnet.Desired, kernel []amneziawgkern.Desired, embeddedManager embeddedInboundManager, kernelManager kernelInboundManager) { + for _, d := range kernel { + embeddedManager.Remove(d.Instance.Id) } - amneziawgnet.GetOutboundManager().Reconcile(outboundDesired) - // Xray's bridges are generated apart from the listener; one that moved needs them regenerated. - if amneziawgnet.BridgesStale() { - j.xrayService.SetToNeedRestart() + kernelManager.Reconcile(kernel) + readyEmbedded := embedded[:0] + for _, d := range embedded { + if kernelManager.Running(d.Instance.Id) { + continue + } + readyEmbedded = append(readyEmbedded, d) } + embeddedManager.Reconcile(readyEmbedded) } // desiredOutboundInstances derives client instances per template "amneziawg" outbound. @@ -114,3 +178,92 @@ func (j *AmneziaWGJob) desiredOutboundInstances() ([]amneziawgnet.OutboundDesire } return out, nil } + +// collectKernelTraffic feeds each kernel interface's per-peer counter +// increments into the same accounting the rest of the panel uses. +func (j *AmneziaWGJob) collectKernelTraffic(kernel []amneziawgkern.Desired) { + collectKernelTrafficWith( + &j.inboundService, kernel, amneziawgkern.GetManager(), + amneziawgkern.GetCollector(), amneziawgkern.Dump, + ) +} + +type kernelTrafficManager interface { + ManagedIDs() []int + TrafficStateFor(id int) (amneziawgkern.TrafficInterfaceState, bool) + PrimeAdoptedTrafficBaseline(id int, generation uint64, stats []amneziawgkern.PeerStat, collector *amneziawgkern.Collector) bool +} + +type kernelTrafficDumper func(string) ([]amneziawgkern.PeerStat, error) + +// collectKernelTrafficWith reads every managed interface, not only desired +// ones, so a peer or inbound that is about to be dropped is still charged. +func collectKernelTrafficWith(inboundService *service.InboundService, kernel []amneziawgkern.Desired, manager kernelTrafficManager, collector *amneziawgkern.Collector, dump kernelTrafficDumper) { + emailsByID := make(map[int]map[string]string, len(kernel)) + for _, d := range kernel { + emails := make(map[string]string, len(d.Instance.Peers)) + for _, p := range d.Instance.Peers { + emails[p.PublicKey] = p.Email + } + emailsByID[d.Instance.Id] = emails + } + + now := time.Now() + var traffics []*xray.ClientTraffic + for _, id := range manager.ManagedIDs() { + state, ok := manager.TrafficStateFor(id) + if !ok { + continue + } + stats, err := dump(state.Name) + if err != nil { + logger.Warning("amneziawg job: reading counters for ", state.Name, ": ", err) + continue + } + if len(stats) == 0 { + continue + } + current, ok := manager.TrafficStateFor(id) + if !ok || current.Name != state.Name || current.Generation != state.Generation { + continue + } + if state.Adopted && !manager.PrimeAdoptedTrafficBaseline(id, state.Generation, stats, collector) { + continue + } + + emails, desired := emailsByID[id] + // Adopted: downtime traffic is unknowable without durable accounting state. + // Otherwise deltas, never totals: counters restart at zero on every rebuild. + if !state.Adopted { + for _, delta := range collector.Delta(state.Name, stats) { + email := emails[delta.PublicKey] + if email == "" { + email = collector.EmailFor(state.Name, delta.PublicKey) + } + if email == "" { + continue + } + traffics = append(traffics, &xray.ClientTraffic{ + InboundId: id, + Email: email, + Up: delta.Up, + Down: delta.Down, + }) + } + } + if desired { + collector.SetEmails(state.Name, emails) + } + collector.ObserveEndpoints(state.Name, stats, now, endpointWindow) + collector.ObserveHandshakes(state.Name, stats) + } + + if len(traffics) == 0 { + return + } + // A client that just crossed its limit is disabled here; the next reconcile + // tick drops its peer from the interface, which is what cuts it off. + if _, _, err := inboundService.AddTraffic(nil, traffics); err != nil { + logger.Warning("amneziawg job: recording kernel-mode traffic:", err) + } +} diff --git a/internal/web/job/amneziawg_job_traffic_restart_test.go b/internal/web/job/amneziawg_job_traffic_restart_test.go new file mode 100644 index 000000000..610c5fec4 --- /dev/null +++ b/internal/web/job/amneziawg_job_traffic_restart_test.go @@ -0,0 +1,234 @@ +package job + +import ( + "errors" + "strconv" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +type kernelTrafficTestManager struct { + id int + state amneziawgkern.TrafficInterfaceState +} + +func (m *kernelTrafficTestManager) ManagedIDs() []int { return []int{m.id} } + +func (m *kernelTrafficTestManager) TrafficStateFor(int) (amneziawgkern.TrafficInterfaceState, bool) { + return m.state, true +} + +func (m *kernelTrafficTestManager) PrimeAdoptedTrafficBaseline(_ int, generation uint64, stats []amneziawgkern.PeerStat, collector *amneziawgkern.Collector) bool { + if !m.state.Adopted || m.state.Generation != generation { + return false + } + collector.Prime(m.state.Name, stats) + m.state.Adopted = false + return true +} + +func TestKernelTrafficJobDoesNotRechargeAfterAdoptingInterface(t *testing.T) { + setupIntegrationDB(t) + db := database.GetDB() + inbound := &model.Inbound{ + UserId: 1, Tag: "kernel-restart", Enable: true, Port: 45201, + Protocol: model.AmneziaWG, Settings: `{"clients":[]}`, + } + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + const email = "kernel-restart@example.test" + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil { + t.Fatalf("create client traffic: %v", err) + } + + const ( + iface = "awg-restart" + publicKey = "peer-public-key" + ) + desired := []amneziawgkern.Desired{{Instance: amneziawg.Instance{ + Id: inbound.Id, Peers: []amneziawg.Peer{{Email: email, PublicKey: publicKey}}, + }}} + firstCollector := amneziawgkern.NewCollector() + firstManager := &kernelTrafficTestManager{id: inbound.Id, state: amneziawgkern.TrafficInterfaceState{Name: iface, Generation: 1}} + current := []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: 100, TxBytes: 200}} + collect := func(manager *kernelTrafficTestManager, collector *amneziawgkern.Collector, dump kernelTrafficDumper) { + t.Helper() + collectKernelTrafficWith(&service.InboundService{}, desired, manager, collector, dump) + } + collect(firstManager, firstCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil }) + + // The panel restarts while the kernel interface keeps its cumulative counters. + restartedCollector := amneziawgkern.NewCollector() + restartedManager := &kernelTrafficTestManager{id: inbound.Id, state: amneziawgkern.TrafficInterfaceState{ + Name: iface, Generation: 2, Adopted: true, + }} + collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { + return nil, errors.New("temporary dump error") + }) + collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return nil, nil }) + if !restartedManager.state.Adopted { + t.Fatal("failed and empty dumps consumed the adopted baseline") + } + current = []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: 125, TxBytes: 250}} + collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil }) + var afterBaseline xray.ClientTraffic + if err := db.Where("email = ?", email).First(&afterBaseline).Error; err != nil { + t.Fatalf("reload traffic after baseline: %v", err) + } + if afterBaseline.Up != 100 || afterBaseline.Down != 200 { + t.Fatalf("first adopted dump charged traffic: up %d down %d, want unchanged 100/200", afterBaseline.Up, afterBaseline.Down) + } + if restartedManager.state.Adopted { + t.Fatal("successful nonempty dump did not consume the adopted baseline") + } + current = []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: 150, TxBytes: 300}} + collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil }) + + var got xray.ClientTraffic + if err := db.Where("email = ?", email).First(&got).Error; err != nil { + t.Fatalf("reload client traffic: %v", err) + } + if got.Up != 125 || got.Down != 250 { + t.Fatalf("usage after adopted-interface restart = up %d down %d, want 125/250 (100/200 before restart plus 25/50 after the baseline)", got.Up, got.Down) + } + + const newEmail = "kernel-new-peer@example.test" + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: newEmail, Enable: true}).Error; err != nil { + t.Fatalf("create new peer traffic: %v", err) + } + const newPublicKey = "new-peer-public-key" + desired[0].Instance.Peers = append(desired[0].Instance.Peers, amneziawg.Peer{Email: newEmail, PublicKey: newPublicKey}) + current = []amneziawgkern.PeerStat{ + {PublicKey: publicKey, RxBytes: 150, TxBytes: 300}, + {PublicKey: newPublicKey, RxBytes: 7, TxBytes: 9}, + } + collect(restartedManager, restartedCollector, func(string) ([]amneziawgkern.PeerStat, error) { return current, nil }) + var newPeerTraffic xray.ClientTraffic + if err := db.Where("email = ?", newEmail).First(&newPeerTraffic).Error; err != nil { + t.Fatalf("reload new peer traffic: %v", err) + } + if newPeerTraffic.Up != 7 || newPeerTraffic.Down != 9 { + t.Fatalf("new peer first counters = up %d down %d, want 7/9", newPeerTraffic.Up, newPeerTraffic.Down) + } +} + +// TestKernelTrafficChargesPeersLeavingTheDesiredSet: reconcile removes a +// disabled peer or inbound from the kernel, so its last counters are read first. +func TestKernelTrafficChargesPeersLeavingTheDesiredSet(t *testing.T) { + cases := map[string]func(id int) []amneziawgkern.Desired{ + "peer disabled": func(id int) []amneziawgkern.Desired { + return []amneziawgkern.Desired{{Instance: amneziawg.Instance{Id: id}}} + }, + "inbound removed": func(int) []amneziawgkern.Desired { return nil }, + } + port := 45210 + for name, nextDesired := range cases { + t.Run(name, func(t *testing.T) { + setupIntegrationDB(t) + db := database.GetDB() + port++ + inbound := &model.Inbound{ + UserId: 1, Tag: "kernel-leaving-" + strconv.Itoa(port), Enable: true, Port: port, + Protocol: model.AmneziaWG, Settings: `{"clients":[]}`, + } + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + const email = "kernel-leaving@example.test" + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil { + t.Fatalf("create client traffic: %v", err) + } + const publicKey = "leaving-peer-key" + manager := &kernelTrafficTestManager{id: inbound.Id, state: amneziawgkern.TrafficInterfaceState{Name: "xui-awg-leaving", Generation: 1}} + collector := amneziawgkern.NewCollector() + dump := func(rx, tx int64) kernelTrafficDumper { + return func(string) ([]amneziawgkern.PeerStat, error) { + return []amneziawgkern.PeerStat{{PublicKey: publicKey, RxBytes: rx, TxBytes: tx}}, nil + } + } + first := []amneziawgkern.Desired{{Instance: amneziawg.Instance{ + Id: inbound.Id, Peers: []amneziawg.Peer{{Email: email, PublicKey: publicKey}}, + }}} + collectKernelTrafficWith(&service.InboundService{}, first, manager, collector, dump(100, 200)) + collectKernelTrafficWith(&service.InboundService{}, nextDesired(inbound.Id), manager, collector, dump(150, 260)) + + var got xray.ClientTraffic + if err := db.Where("email = ?", email).First(&got).Error; err != nil { + t.Fatalf("reload traffic: %v", err) + } + if got.Up != 150 || got.Down != 260 { + t.Fatalf("traffic = up %d down %d, want 150/260 including the final 50/60", got.Up, got.Down) + } + }) + } +} + +type transitionEmbeddedManager struct { + removed []int + started []int +} + +func (m *transitionEmbeddedManager) Remove(id int) { m.removed = append(m.removed, id) } +func (m *transitionEmbeddedManager) Reconcile(desired []amneziawgnet.Desired) { + m.started = m.started[:0] + for _, d := range desired { + m.started = append(m.started, d.Instance.Id) + } +} + +type transitionKernelManager struct { + id int + running bool + failRemoval bool +} + +func (m *transitionKernelManager) Reconcile(desired []amneziawgkern.Desired) { + wanted := false + for _, d := range desired { + if d.Instance.Id == m.id { + wanted = true + } + } + if wanted { + m.running = true + return + } + if m.running && m.failRemoval { + m.failRemoval = false + return + } + m.running = false +} + +func (m *transitionKernelManager) Running(id int) bool { return id == m.id && m.running } + +func TestKernelToEmbeddedWaitsForKernelTeardownRetry(t *testing.T) { + const id = 91 + embedded := []amneziawgnet.Desired{{Instance: amneziawg.Instance{Id: id}}} + kernel := &transitionKernelManager{id: id, running: true, failRemoval: true} + embeddedManager := &transitionEmbeddedManager{} + + reconcileAmneziaWGDatapaths(embedded, nil, embeddedManager, kernel) + if len(embeddedManager.started) != 0 { + t.Fatalf("embedded reconcile started inbound %d while kernel teardown was still pending", embeddedManager.started[0]) + } + if !kernel.running { + t.Fatal("first reconcile should retain the kernel interface after its simulated teardown failure") + } + + reconcileAmneziaWGDatapaths(embedded, nil, embeddedManager, kernel) + if kernel.running { + t.Fatal("second reconcile should complete the kernel teardown") + } + if len(embeddedManager.started) != 1 || embeddedManager.started[0] != id { + t.Fatalf("embedded reconcile after teardown = %v, want inbound %d", embeddedManager.started, id) + } +} diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index 89d488364..74649a924 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -60,7 +61,25 @@ func NewCheckClientIpJob() *CheckClientIpJob { func (j *CheckClientIpJob) Run() { j.pruneStaleIpRows() observed, apiMode := j.collectFromOnlineAPI() - if !apiMode { + // Kernel-mode AmneziaWG peers never reach Xray, so its online users cannot + // see them; their connect locations come from the kernel interface instead. + kernelIPs := amneziawgkern.GetCollector().ClientIPs() + if len(kernelIPs) > 0 { + if observed == nil { + observed = make(map[string]map[string]int64, len(kernelIPs)) + } + for email, ips := range kernelIPs { + if _, ok := observed[email]; !ok { + observed[email] = make(map[string]int64, len(ips)) + } + for ip, ts := range ips { + if prev, seen := observed[email][ip]; !seen || ts > prev { + observed[email][ip] = ts + } + } + } + } + if !apiMode && len(kernelIPs) == 0 { // xray is down or predates the online-stats API. There is no access-log // fallback anymore, so there is nothing to do this run. logger.Debug("[LimitIP] online-stats API unavailable this run; skipping") diff --git a/internal/web/middleware/config_envelope.go b/internal/web/middleware/config_envelope.go index 27bac77d9..2f2acaaf4 100644 --- a/internal/web/middleware/config_envelope.go +++ b/internal/web/middleware/config_envelope.go @@ -22,7 +22,7 @@ const maxDecodedConfigBytes = 8 << 20 // panels and plain calls keep working (mixed-version safe). func ConfigEnvelopeMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - c.Header(wirecodec.CapsHeader, wirecodec.CapZstd) + c.Header(wirecodec.CapsHeader, wirecodec.NodeCaps) enc := c.GetHeader("Content-Encoding") sum := c.GetHeader(wirecodec.HashHeader) diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go index 89746e58e..f130921ce 100644 --- a/internal/web/runtime/local.go +++ b/internal/web/runtime/local.go @@ -12,8 +12,10 @@ import ( "google.golang.org/grpc/status" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/mtproto" "github.com/mhsanaei/3x-ui/v3/internal/tuic" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -75,20 +77,7 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error { if !ok { return nil } - err := amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{ - Instance: inst, - Options: amneziawgnet.DeviceOptions{ - HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey, - ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition, - RekeyAfterTime: inst.Obfuscation.RekeyAfterTime, - RekeyTimeout: inst.Obfuscation.RekeyTimeout, - RejectAfterTime: inst.Obfuscation.RejectAfterTime, - KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout, - MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts, - RandomTrailers: inst.Obfuscation.RandomTrailers, - DisableCookies: inst.Obfuscation.DisableCookies, - }, - }) + err := ensureAmneziaWG(inst) // A brand new inbound can be the first one to qualify for // injectAmneziawgnetSocks's Xray-side relay inbound (e.g. its first // valid peer). Ensure only updates the embedded Device -- flag Xray @@ -125,7 +114,7 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error { return nil } if ib.Protocol == model.AmneziaWG { - amneziawgnet.GetManager().Remove(ib.Id) + removeAmneziaWG(ib.Id) // The removed inbound may have been the only one backing Xray's // injectAmneziawgnetSocks relay inbound for this tag -- flag a // resync so the now-stale relay gets torn down promptly. @@ -192,27 +181,13 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In return mtproto.GetManager().Ensure(inst) } -// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the -// Remove+Ensure sequence a plain Del+Add would force so that, on an -// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison -// can reconfigure the running embedded Device in place via IpcSet instead -// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked -- -// only an address or effective-MTU change forces a rebuild there, S4 -// included, not a peer edit). -// -// Every exit path below only touches the embedded Device via -// amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which -// is what actually creates/removes injectAmneziawgnetSocks's relay inbound. -// A peer edit that changes whether this inbound has a qualifying peer at -// all (its first peer added, or its last one removed) must still get that -// relay created or torn down, so flag Xray for a resync unconditionally -// here rather than trying to enumerate which of the branches below need it. +// updateAmneziaWGInbound routes AmneziaWG CRUD through the active datapath managers. func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error { if l.deps.SetNeedRestart != nil { l.deps.SetNeedRestart() } if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG { - amneziawgnet.GetManager().Remove(oldIb.Id) + removeAmneziaWG(oldIb.Id) if !newIb.Enable { return nil } @@ -222,14 +197,31 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model. _ = l.DelInbound(ctx, oldIb) } if !newIb.Enable { - amneziawgnet.GetManager().Remove(newIb.Id) + removeAmneziaWG(newIb.Id) return nil } inst, ok := amneziawg.InstanceFromInbound(newIb) if !ok { - amneziawgnet.GetManager().Remove(newIb.Id) + removeAmneziaWG(newIb.Id) return nil } + return ensureAmneziaWG(inst) +} + +// ensureAmneziaWG routes an inbound to its own datapath's manager and clears +// the other's: both bind the inbound's UDP port, so they must never overlap. +func ensureAmneziaWG(inst amneziawg.Instance) error { + if inst.KernelMode { + amneziawgnet.GetManager().Remove(inst.Id) + return amneziawgkern.GetManager().Ensure(amneziawgkern.Desired{ + Instance: inst, + EgressInterface: inst.ExternalInterface, + RouteThroughXray: inst.RouteThroughXray, + }) + } + if err := amneziawgkern.GetManager().Remove(inst.Id); err != nil { + return err + } return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{ Instance: inst, Options: amneziawgnet.DeviceOptions{ @@ -246,6 +238,14 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model. }) } +// removeAmneziaWG tears the inbound down on whichever datapath is holding it. +func removeAmneziaWG(id int) { + amneziawgnet.GetManager().Remove(id) + if err := amneziawgkern.GetManager().Remove(id); err != nil { + logger.Warning("amneziawgkern: removing inbound ", id, ": ", err) + } +} + func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error { // Account changes also need the generated Xray SOCKS relay refreshed after commit. if l.deps.RequestReconcile != nil { diff --git a/internal/web/runtime/local_amneziawg_datapath_test.go b/internal/web/runtime/local_amneziawg_datapath_test.go new file mode 100644 index 000000000..eb7a4e9e0 --- /dev/null +++ b/internal/web/runtime/local_amneziawg_datapath_test.go @@ -0,0 +1,70 @@ +package runtime + +import ( + "context" + "fmt" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// datapathInbound builds a minimal AmneziaWG inbound, kernel mode on or off. +func datapathInbound(t *testing.T, id, port int, kernelMode bool) *model.Inbound { + t.Helper() + srvPriv, srvPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + _, peerPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + settings := fmt.Sprintf( + `{"server":{"privateKey":%q,"publicKey":%q,"subnetIp":"10.209.0.0","subnetCidr":24,"mtu":1420,`+ + `"jc":4,"jmin":40,"jmax":70,"s1":20,"s2":30,"s3":20,"s4":20,"kernelMode":%t},`+ + `"clients":[{"email":"peer@example.test","enable":true,"publicKey":%q,"allowedIPs":["10.209.0.2/32"]}]}`, + srvPriv, srvPub, kernelMode, peerPub) + return &model.Inbound{ + Id: id, + Enable: true, + Protocol: model.AmneziaWG, + Port: port, + Tag: fmt.Sprintf("inbound-%d", id), + Settings: settings, + } +} + +func newTestLocal() *Local { + return NewLocal(LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}) +} + +// Both datapaths bind the inbound's own UDP port, so an embedded device raised +// here collides with the kernel interface the reconcile tick brings up. +func TestAddInboundKeepsKernelModeOffTheEmbeddedDatapath(t *testing.T) { + ib := datapathInbound(t, 4181, 58731, true) + t.Cleanup(func() { amneziawgnet.GetManager().Remove(ib.Id) }) + + // The kernel side wants awg-quick and root, so it is expected to fail on a + // dev box; what matters is that amneziawgnet was never asked to take over. + _ = newTestLocal().AddInbound(context.Background(), ib) + + if _, _, ok := amneziawgnet.GetManager().Lookup(ib.Id); ok { + t.Fatal("a kernel-mode inbound created an embedded device") + } +} + +// The other half: without the toggle the dispatch must still reach +// amneziawgnet, or it would be a blanket opt-out rather than a routing choice. +func TestAddInboundStillUsesTheEmbeddedDatapath(t *testing.T) { + ib := datapathInbound(t, 4182, 58732, false) + t.Cleanup(func() { amneziawgnet.GetManager().Remove(ib.Id) }) + + if err := newTestLocal().AddInbound(context.Background(), ib); err != nil { + t.Fatalf("AddInbound: %v", err) + } + if _, _, ok := amneziawgnet.GetManager().Lookup(ib.Id); !ok { + t.Fatal("an embedded inbound did not create a device") + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 479669d7b..a156027c7 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -12,11 +12,13 @@ import ( "net" "net/http" "net/url" + "slices" "strconv" "strings" "sync" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -90,6 +92,10 @@ type Remote struct { // seen, config pushes to this node are zstd-compressed. Old nodes never set // it, so they keep receiving plain bodies (mixed-version safe). supportsZstd bool + // capsSeen tells a node that advertised no AmneziaWG kernel support apart + // from one this Remote has not heard from yet. + capsSeen bool + supportsAWGKernel bool // Per-node client honoring the TLS verify mode, built once and reused; a // node config change drops the cached Remote so the next one rebuilds it. @@ -130,12 +136,41 @@ func (r *Remote) nodeSupportsZstd() bool { // recordCaps learns the node's capabilities from a response header so later // pushes can use the negotiated envelope. func (r *Remote) recordCaps(h http.Header) { - if !strings.Contains(h.Get(wirecodec.CapsHeader), wirecodec.CapZstd) { - return - } + caps := strings.Split(h.Get(wirecodec.CapsHeader), ",") r.mu.Lock() - r.supportsZstd = true - r.mu.Unlock() + defer r.mu.Unlock() + r.capsSeen = true + r.supportsAWGKernel = slices.Contains(caps, wirecodec.CapAmneziaWGKernel) + if slices.Contains(caps, wirecodec.CapZstd) { + r.supportsZstd = true + } +} + +// requireAmneziaWGKernel refuses to push a kernel-mode AmneziaWG inbound to a +// node that would drop the flag and silently serve it on the embedded datapath. +func (r *Remote) requireAmneziaWGKernel(ctx context.Context, ib *model.Inbound) error { + if ib.Protocol != model.AmneziaWG { + return nil + } + var settings amneziawg.InboundSettings + if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || settings.Server == nil || !settings.Server.KernelMode { + return nil + } + r.mu.RLock() + seen := r.capsSeen + r.mu.RUnlock() + if !seen { + if err := r.refreshRemoteIDs(ctx); err != nil { + return err + } + } + r.mu.RLock() + supported := r.supportsAWGKernel + r.mu.RUnlock() + if !supported { + return fmt.Errorf("node %s does not support AmneziaWG kernel mode; upgrade its panel or switch the inbound to the embedded datapath", r.node.Name) + } + return nil } // httpClient lazily builds and caches the per-node client honoring the TLS @@ -432,6 +467,9 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error { } func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error { + if err := r.requireAmneziaWGKernel(ctx, ib); err != nil { + return err + } payload := wireInbound(ib, r.node.Id) env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload) if err != nil { @@ -468,6 +506,9 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) if err != nil { return r.AddInbound(ctx, newIb) } + if err := r.requireAmneziaWGKernel(ctx, newIb); err != nil { + return err + } payload := wireInbound(newIb, r.node.Id) if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil { return err diff --git a/internal/web/runtime/remote_envelope_test.go b/internal/web/runtime/remote_envelope_test.go index 207b73a32..9a9b77690 100644 --- a/internal/web/runtime/remote_envelope_test.go +++ b/internal/web/runtime/remote_envelope_test.go @@ -90,3 +90,44 @@ func TestRemoteSendsPlainWhenNoCap(t *testing.T) { t.Fatalf("integrity hash should always be sent, got %q", capturedHash) } } + +// TestRemoteRefusesKernelAmneziaWGOnNodeWithoutCap: an older node drops the +// kernelMode flag and silently runs the inbound on the embedded datapath. +func TestRemoteRefusesKernelAmneziaWGOnNodeWithoutCap(t *testing.T) { + cases := []struct { + name string + caps string + kernel bool + wantRefuse bool + }{ + {"old node, kernel inbound", wirecodec.CapZstd, true, true}, + {"node without any caps, kernel inbound", "", true, true}, + {"kernel-capable node, kernel inbound", wirecodec.NodeCaps, true, false}, + {"old node, embedded inbound", wirecodec.CapZstd, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var pushed bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.caps != "" { + w.Header().Set(wirecodec.CapsHeader, tc.caps) + } + if r.Method == http.MethodPost { + pushed = true + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"obj":[]}`)) + })) + defer srv.Close() + + r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) + err := r.AddInbound(context.Background(), datapathInbound(t, 1, 51820, tc.kernel)) + if refused := err != nil && strings.Contains(err.Error(), "does not support AmneziaWG kernel mode"); refused != tc.wantRefuse { + t.Fatalf("refused = %v (err %v), want %v", refused, err, tc.wantRefuse) + } + if pushed == tc.wantRefuse { + t.Fatalf("inbound pushed = %v, want %v", pushed, !tc.wantRefuse) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index aca4da6c6..773f0d869 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -274,6 +274,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate // comment on the Attach path. clientForInbound.AllowedIPs = nil } + if err := validateAmneziaWGClientWrite(inbound, []model.Client{clientForInbound}); err != nil { + return false, fmt.Errorf("inbound %d: %w", ibId, err) + } settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}}) if mErr != nil { return false, fmt.Errorf("inbound %d: %w", ibId, mErr) @@ -824,6 +827,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model // instead. clientForInbound.AllowedIPs = nil } + if err := validateAmneziaWGClientWrite(inbound, []model.Client{clientForInbound}); err != nil { + return false, fmt.Errorf("inbound %d: %w", ibId, err) + } settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}}) if mErr != nil { return false, mErr diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index d1204ae2b..08b2abdc0 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -29,6 +31,45 @@ func droppedClientNeedsRestart() bool { return on } +func validateAmneziaWGClientWrite(inbound *model.Inbound, clients []model.Client) error { + if inbound.Protocol != model.AmneziaWG { + return nil + } + var settings amneziawg.InboundSettings + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + return fmt.Errorf("amneziawg: invalid settings: %w", err) + } + for _, client := range clients { + for _, value := range []struct{ field, value string }{ + {"email", client.Email}, + {"publicKey", client.PublicKey}, + {"preSharedKey", client.PreSharedKey}, + } { + if err := amneziawg.ValidateConfigValue(value.field, value.value); err != nil { + return fmt.Errorf("amneziawg: client %q: %w", client.Email, err) + } + } + for _, allowedIP := range client.AllowedIPs { + if err := amneziawg.ValidateConfigValue("allowedIPs", allowedIP); err != nil { + return fmt.Errorf("amneziawg: client %q: %w", client.Email, err) + } + } + } + if settings.Server == nil || !settings.Server.KernelMode { + return nil + } + inst := amneziawg.Instance{Listen: inbound.Listen, IPv6Enabled: settings.Server.IPv6Enabled} + for _, client := range clients { + inst.Peers = append(inst.Peers, amneziawg.Peer{ + AllowedIPs: client.AllowedIPs, ForwardedPorts: client.ForwardedPorts, + }) + } + if err := amneziawgkern.ValidateInstanceFeatures(inst, settings.Server.RouteThroughXray); err != nil { + return err + } + return nil +} + func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool { aa := maps.Clone(a) bb := maps.Clone(b) @@ -441,6 +482,9 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model } } } + if err := validateAmneziaWGClientWrite(oldInbound, clients); err != nil { + return false, err + } var portCtx portConflictContext if oldInbound.Protocol == model.AmneziaWG { @@ -801,15 +845,14 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].KeepAlive == nil { clients[0].KeepAlive = old.KeepAlive } - // ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never - // reads it), same carry-forward reasoning as the fields above: a - // partial edit (e.g. a Telegram-bot enable/expiry toggle, or an API - // call that omits the field) must not silently drop a client's - // existing port-forwarding spec. + // AWG partial edits preserve forwarding specs; kernel mode rejects legacy specs. if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == "" { clients[0].ForwardedPorts = old.ForwardedPorts } } + if err := validateAmneziaWGClientWrite(oldInbound, clients); err != nil { + return false, err + } if oldInbound.Protocol == model.AmneziaWG { portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB(), oldInbound.NodeID) if err != nil { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 85f48322f..3bfdbc9d8 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1312,7 +1312,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil { return inbound, false, err } - if err := s.normalizeAmneziaWGSettings(inbound, ""); err != nil { + if err := s.normalizeAmneziaWGSettings(inbound, nil); err != nil { return inbound, false, err } if inbound.NodeID != nil { @@ -1458,9 +1458,8 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil { return err } - // The relay port is derived from the id, only known after Save, and only a - // local row owns one: checkPortConflictTx ran no relay check with ignoreId==0. - if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG { + // Embedded AmneziaWG owns a relay port derived from its local row id. + if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && !amneziawgKernelMode(inbound) { if self := amneziawgnetSocksSelfConflict(inbound, inbound.Id); self != "" { return common.NewError(self) } @@ -2003,7 +2002,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := normalizeTuicSettings(inbound); err != nil { return inbound, false, err } - if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil { + if err := s.normalizeAmneziaWGSettings(inbound, oldInbound); err != nil { return inbound, false, err } inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index f831cf3b5..578f93426 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -188,10 +189,14 @@ func storedAmneziaWGServerKeys(oldSettings string) (priv, pub string) { // valid server block, generating one (fresh obfuscation params + keypair) on // first save and validating a manually-edited one so a bad entry can't bring // the interface down on the next apply. A no-op for every other protocol. -func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldSettings string) error { +func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, old *model.Inbound) error { if inbound.Protocol != model.AmneziaWG { return nil } + oldSettings := "" + if old != nil { + oldSettings = old.Settings + } trimmed := strings.TrimSpace(inbound.Settings) if trimmed == "" || trimmed == "null" || trimmed == "{}" { @@ -301,6 +306,35 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS return err } inbound.Settings = string(bs) + + // Host readiness gates only a save that starts the kernel datapath: a host that + // later loses its module must still let the admin edit, disable or switch it. + if parsed.Server.KernelMode && inbound.NodeID == nil { + inst, ok := amneziawg.InstanceFromInbound(inbound) + if !ok { + inst = amneziawg.Instance{ + Listen: inbound.Listen, + IPv6Enabled: parsed.Server.IPv6Enabled, + RouteThroughXray: parsed.Server.RouteThroughXray, + } + for _, client := range parsed.Clients { + if client.Enable { + inst.Peers = append(inst.Peers, amneziawg.Peer{ + AllowedIPs: client.AllowedIPs, + ForwardedPorts: client.ForwardedPorts, + }) + } + } + } + check := amneziawgkern.ValidateInstanceFeatures + wasRunningKernel := old != nil && old.Enable && amneziawgKernelModeSettings(old.Settings) + if inbound.Enable && !wasRunningKernel { + check = amneziawgkern.Preflight + } + if err := check(inst, inst.RouteThroughXray); err != nil { + return err + } + } return nil } @@ -387,6 +421,9 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo var socksPort int switch ib.Protocol { case model.AmneziaWG: + if amneziawgKernelMode(ib) { + continue + } socksPort = amneziawgnet.SOCKSPortForInbound(ib.Id) case model.TUIC: socksPort = tuic.SOCKSPortForInbound(ib.Id) diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go index 45fecc563..94c057d52 100644 --- a/internal/web/service/inbound_amneziawg_test.go +++ b/internal/web/service/inbound_amneziawg_test.go @@ -10,6 +10,7 @@ import ( "github.com/op/go-logging" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -144,7 +145,7 @@ func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) { setupConflictDB(t) svc := &InboundService{} inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""} - if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil { + if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil { t.Fatalf("normalize empty settings: %v", err) } @@ -199,7 +200,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) { Port: 51820, Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`, } - if err := svc.normalizeAmneziaWGSettings(inbound, ""); err == nil { + if err := svc.normalizeAmneziaWGSettings(inbound, nil); err == nil { t.Errorf("%s must be rejected", c.name) } } @@ -214,7 +215,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) { Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + `"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":" "},"clients":[]}`, } - if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil { + if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil { t.Fatalf("normalize: %v", err) } var parsed amneziawg.InboundSettings @@ -256,7 +257,7 @@ func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T `"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` + `"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`, } - err := svc.normalizeAmneziaWGSettings(inbound, "") + err := svc.normalizeAmneziaWGSettings(inbound, nil) if err == nil { t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings) } @@ -298,7 +299,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T) Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` + `"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`, } - if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil { + if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil { t.Fatalf("normalize: %v", err) } var parsed amneziawg.InboundSettings @@ -398,6 +399,26 @@ func TestCheckForwardedPortsConflict_CollidesWithAmneziawgnetSocksPort(t *testin } } +func TestCheckForwardedPortsConflict_KernelAmneziaWGHasNoEmbeddedRelay(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``, + `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + svc := &InboundService{} + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) + if err != nil { + t.Fatalf("loadPortConflictContext: %v", err) + } + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) + if hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort)); hit != "" { + t.Fatalf("kernel-mode AmneziaWG must not reserve embedded relay port %d, got collision %q", relayPort, hit) + } +} + // A cleared DNS field is meaningful (no DNS line in client configs) and must // survive the save round-trip instead of resurrecting the frontend defaults. func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) { @@ -413,7 +434,7 @@ func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) { t.Fatalf("marshal settings: %v", err) } inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)} - if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, ""); err != nil { + if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, nil); err != nil { t.Fatalf("normalizeAmneziaWGSettings: %v", err) } for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} { @@ -432,7 +453,7 @@ func TestNormalizeAmneziaWGSettings_RejectsEmptyClientAllowedIPs(t *testing.T) { "server": {"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24}, "clients": [{"email":"ghost","enable":true,"publicKey":"` + awgTestPublicKey + `","allowedIPs":[]}] }`} - err := svc.normalizeAmneziaWGSettings(inbound, "") + err := svc.normalizeAmneziaWGSettings(inbound, nil) if err == nil || !strings.Contains(err.Error(), "allowedIPs is required") { t.Fatalf("err = %v, want an allowedIPs refusal naming the client", err) } @@ -449,7 +470,7 @@ func TestNormalizeAmneziaWGSettings_KeepsStoredServerKeysWhenOmitted(t *testing. stored := `{"server":{"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}` inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51824, Settings: `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24,"randomTrailers":true}}`} - if err := svc.normalizeAmneziaWGSettings(inbound, stored); err != nil { + if err := svc.normalizeAmneziaWGSettings(inbound, &model.Inbound{Settings: stored}); err != nil { t.Fatalf("normalize: %v", err) } var parsed amneziawg.InboundSettings @@ -467,7 +488,7 @@ func TestNormalizeAmneziaWGSettings_DerivesServerPublicKeyFromPrivate(t *testing setupConflictDB(t) svc := &InboundService{} inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51825, Settings: `{"server":{"privateKey":"` + awgTestPrivateKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`} - if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil { + if err := svc.normalizeAmneziaWGSettings(inbound, nil); err != nil { t.Fatalf("normalize: %v", err) } var parsed amneziawg.InboundSettings @@ -482,3 +503,80 @@ func TestNormalizeAmneziaWGSettings_DerivesServerPublicKeyFromPrivate(t *testing t.Fatalf("server publicKey = %q, want %q derived from the supplied private key", parsed.Server.PublicKey, want) } } + +func TestNormalizeAmneziaWGSettingsDefersNodeKernelPreflightToRemoteHost(t *testing.T) { + setupConflictDB(t) + server, err := defaultAmneziaWGServer() + if err != nil { + t.Fatalf("defaultAmneziaWGServer: %v", err) + } + server.KernelMode = true + settings, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}}) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + inbound := &model.Inbound{ + Protocol: model.AmneziaWG, + NodeID: new(7), + Port: 51820, + Settings: string(settings), + } + if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, nil); err != nil { + t.Fatalf("master must defer kernel readiness to the target node: %v", err) + } + var normalized amneziawg.InboundSettings + if err := json.Unmarshal([]byte(inbound.Settings), &normalized); err != nil { + t.Fatalf("decode normalized settings: %v", err) + } + if normalized.Server == nil || !normalized.Server.KernelMode { + t.Fatalf("normalized node settings lost kernel mode: %+v", normalized.Server) + } +} + +// TestNormalizeAmneziaWGSettingsGatesHostReadinessOnlyWhenEnablingKernelMode: +// a host that loses its module after enabling must not lock the inbound's edits. +func TestNormalizeAmneziaWGSettingsGatesHostReadinessOnlyWhenEnablingKernelMode(t *testing.T) { + setupConflictDB(t) + if amneziawgkern.HostReadiness().Ready { + t.Skip("this host can run kernel mode, so host readiness cannot refuse anything here") + } + server, err := defaultAmneziaWGServer() + if err != nil { + t.Fatalf("defaultAmneziaWGServer: %v", err) + } + embeddedServer := *server + server.KernelMode = true + kernelSettings, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}}) + if err != nil { + t.Fatal(err) + } + embeddedSettings, err := json.Marshal(amneziawg.InboundSettings{Server: &embeddedServer, Clients: []model.Client{}}) + if err != nil { + t.Fatal(err) + } + kernelRow := func(enable bool) *model.Inbound { + return &model.Inbound{Protocol: model.AmneziaWG, Enable: enable, Settings: string(kernelSettings)} + } + cases := []struct { + name string + enable bool + old *model.Inbound + wantRefused bool + }{ + {"new inbound turns kernel mode on", true, nil, true}, + {"embedded inbound switches to kernel mode", true, &model.Inbound{Protocol: model.AmneziaWG, Enable: true, Settings: string(embeddedSettings)}, true}, + {"disabled kernel inbound is enabled", true, kernelRow(false), true}, + {"running kernel inbound is edited", true, kernelRow(true), false}, + {"running kernel inbound is disabled", false, kernelRow(true), false}, + {"disabled inbound saved with kernel mode", false, nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Enable: tc.enable, Settings: string(kernelSettings)} + err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, tc.old) + if refused := err != nil && strings.Contains(err.Error(), "kernel mode unavailable"); refused != tc.wantRefused { + t.Fatalf("refused = %v (err %v), want %v", refused, err, tc.wantRefused) + } + }) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 848a5a011..4e8035af9 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -1537,11 +1538,29 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) { } func (s *InboundService) GetOnlineClients() []string { + // Kernel-mode AmneziaWG peers are invisible to Xray's online-users set; + // their handshakes are the equivalent signal, unioned in once here. + kernelOnline := amneziawgkern.GetCollector().OnlineEmails(time.Now()) + process := currentXrayProcess() if process == nil { - return []string{} + return kernelOnline } - return process.GetOnlineClients() + xrayOnline := process.GetOnlineClients() + if len(kernelOnline) == 0 { + return xrayOnline + } + + seen := make(map[string]struct{}, len(xrayOnline)+len(kernelOnline)) + out := make([]string, 0, len(xrayOnline)+len(kernelOnline)) + for _, email := range append(append([]string{}, xrayOnline...), kernelOnline...) { + if _, dup := seen[email]; dup { + continue + } + seen[email] = struct{}{} + out = append(out, email) + } + return out } // GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the diff --git a/internal/web/service/kernel_client_guard_test.go b/internal/web/service/kernel_client_guard_test.go new file mode 100644 index 000000000..7e6d15547 --- /dev/null +++ b/internal/web/service/kernel_client_guard_test.go @@ -0,0 +1,199 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func kernelAWGSettings(clients []model.Client) string { + b, _ := json.Marshal(map[string]any{ + "server": map[string]any{"subnetIp": "10.8.1.0", "subnetCidr": 24, "kernelMode": true}, + "clients": clients, + }) + return string(b) +} + +func TestKernelAWGClientWritesRejectUnsafeValuesAndUnsupportedFeatures(t *testing.T) { + cases := []struct { + name, want string + client model.Client + }{ + {"public key hook injection", "control characters", model.Client{Email: "bad-key@awg", Enable: true, PublicKey: "peer-key\n[Interface]\nPostDown = touch /tmp/pwned", AllowedIPs: []string{"10.8.1.2/32"}}}, + {"forwarded ports", "port forwarding is not supported", model.Client{Email: "bad-forward@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: "54321"}}, + {"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "bad-v6@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"2001:db8::2/128"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + ib := mkInbound(t, 52000, model.AmneziaWG, kernelAWGSettings(nil)) + tc.client.AllowedIPsByInbound = map[int][]string{ib.Id: tc.client.AllowedIPs} + _, err := svc.CreateOne(inboundSvc, ib.Id, tc.client) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("CreateOne error = %v, want an error containing %q", err, tc.want) + } + var records int64 + if dbErr := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", tc.client.Email).Count(&records).Error; dbErr != nil { + t.Fatal(dbErr) + } + if records != 0 { + t.Fatalf("rejected create persisted %d client records", records) + } + after, getErr := inboundSvc.GetInbound(ib.Id) + if getErr != nil { + t.Fatal(getErr) + } + if len(mustAWGClients(t, after)) != 0 { + t.Fatal("rejected create changed inbound settings") + } + }) + } +} + +func mustAWGClients(t *testing.T, ib *model.Inbound) []model.Client { + t.Helper() + clients, err := (&InboundService{}).GetClients(ib) + if err != nil { + t.Fatal(err) + } + return clients +} + +func TestKernelAWGClientUpdateRejectsUnsafeValuesAndUnsupportedFeatures(t *testing.T) { + cases := []struct { + name, want string + update model.Client + }{ + {"preshared key hook injection", "control characters", model.Client{Email: "kept@awg", PreSharedKey: "psk\n[Interface]\nPreDown = touch /tmp/pwned"}}, + {"forwarded ports", "port forwarding is not supported", model.Client{Email: "kept@awg", ForwardedPorts: "54321"}}, + {"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "kept@awg", AllowedIPs: []string{"2001:db8::2/128"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + seed := model.Client{Email: "kept@awg", Enable: true, PublicKey: "valid-key", PrivateKey: "valid-private", AllowedIPs: []string{"10.8.1.2/32"}} + ib := mkInbound(t, 52001, model.AmneziaWG, kernelAWGSettings(nil)) + if _, err := svc.CreateOne(inboundSvc, ib.Id, seed); err != nil { + t.Fatalf("seed client: %v", err) + } + rec := lookupClientRecord(t, seed.Email) + before, getErr := inboundSvc.GetInbound(ib.Id) + if getErr != nil { + t.Fatal(getErr) + } + update := tc.update + if len(update.AllowedIPs) > 0 { + update.AllowedIPsByInbound = map[int][]string{ib.Id: update.AllowedIPs} + } + _, err := svc.Update(inboundSvc, rec.Id, update, 0) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("ClientService.Update error = %v, want an error containing %q", err, tc.want) + } + after, err := inboundSvc.GetInbound(ib.Id) + if err != nil { + t.Fatal(err) + } + if after.Settings != before.Settings { + t.Fatalf("rejected update changed settings") + } + var unchanged model.ClientRecord + if err := database.GetDB().First(&unchanged, rec.Id).Error; err != nil { + t.Fatal(err) + } + if unchanged.PreSharedKey != rec.PreSharedKey || unchanged.AllowedIPs != rec.AllowedIPs { + t.Fatalf("rejected update changed client record: before %+v after %+v", rec, unchanged) + } + }) + } +} + +func TestKernelAWGClientFeatureChecksPreserveSupportedPaths(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + kernel := mkInbound(t, 52002, model.AmneziaWG, kernelAWGSettings(nil)) + if _, err := svc.CreateOne(inboundSvc, kernel.Id, model.Client{Email: "valid@kernel", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}}); err != nil { + t.Fatalf("valid IPv4 kernel peer was rejected: %v", err) + } + embedded := mkInbound(t, 52003, model.AmneziaWG, amneziawgClientTestSettings) + if _, err := svc.CreateOne(inboundSvc, embedded.Id, model.Client{Email: "forward@embedded", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.3/32"}, ForwardedPorts: "54321"}); err != nil { + t.Fatalf("embedded AWG port forwarding was rejected: %v", err) + } +} + +func TestKernelAWGSharedCreateAndUpdateValidateAllTargetsBeforeWrites(t *testing.T) { + t.Run("create", func(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + kernel := mkInbound(t, 52005, model.AmneziaWG, kernelAWGSettings(nil)) + embedded := mkInbound(t, 52006, model.AmneziaWG, amneziawgClientTestSettings) + _, err := svc.Create(inboundSvc, &ClientCreatePayload{ + Client: model.Client{Email: "shared-create@awg", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: "54321"}, + InboundIds: []int{embedded.Id, kernel.Id}, + }) + if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") { + t.Fatalf("Create error = %v, want kernel feature rejection", err) + } + for _, ib := range []*model.Inbound{embedded, kernel} { + after, getErr := inboundSvc.GetInbound(ib.Id) + if getErr != nil { + t.Fatal(getErr) + } + if len(mustAWGClients(t, after)) != 0 { + t.Fatalf("failed shared create partially changed inbound %d", ib.Id) + } + } + var records int64 + if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", "shared-create@awg").Count(&records).Error; err != nil { + t.Fatal(err) + } + if records != 0 { + t.Fatalf("failed shared create persisted %d client records", records) + } + }) + + t.Run("update", func(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + kernel := mkInbound(t, 52007, model.AmneziaWG, kernelAWGSettings(nil)) + embedded := mkInbound(t, 52008, model.AmneziaWG, amneziawgClientTestSettings) + seed := model.Client{Email: "shared-update@awg", Enable: true, PublicKey: "valid-key", AllowedIPsByInbound: map[int][]string{kernel.Id: {"10.8.1.2/32"}, embedded.Id: {"10.8.1.3/32"}}} + if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: seed, InboundIds: []int{kernel.Id, embedded.Id}}); err != nil { + t.Fatalf("seed shared client: %v", err) + } + beforeKernel, _ := inboundSvc.GetInbound(kernel.Id) + beforeEmbedded, _ := inboundSvc.GetInbound(embedded.Id) + rec := lookupClientRecord(t, seed.Email) + _, err := svc.Update(inboundSvc, rec.Id, model.Client{Email: seed.Email, ForwardedPorts: "54321"}, 0) + if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") { + t.Fatalf("Update error = %v, want kernel feature rejection", err) + } + afterKernel, getErr := inboundSvc.GetInbound(kernel.Id) + if getErr != nil { + t.Fatal(getErr) + } + afterEmbedded, getErr := inboundSvc.GetInbound(embedded.Id) + if getErr != nil { + t.Fatal(getErr) + } + if afterKernel.Settings != beforeKernel.Settings || afterEmbedded.Settings != beforeEmbedded.Settings { + t.Fatal("failed shared update partially changed inbound settings") + } + var unchanged model.ClientRecord + if err := database.GetDB().First(&unchanged, rec.Id).Error; err != nil { + t.Fatal(err) + } + if unchanged.ForwardedPorts != rec.ForwardedPorts { + t.Fatal("failed shared update changed the client record") + } + }) +} diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index 5d25a3721..07282f1d1 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -129,6 +129,21 @@ func inboundBindAddr(ib *model.Inbound) bindAddr { return bindAddr{listen: ib.Listen, v6only: streamV6Only(ib.StreamSettings)} } +func amneziawgKernelMode(ib *model.Inbound) bool { + if ib == nil || ib.Protocol != model.AmneziaWG { + return false + } + return amneziawgKernelModeSettings(ib.Settings) +} + +func amneziawgKernelModeSettings(raw string) bool { + var settings amneziawg.InboundSettings + if err := json.Unmarshal([]byte(raw), &settings); err != nil || settings.Server == nil { + return false + } + return settings.Server.KernelMode +} + func streamV6Only(streamSettings string) bool { if !strings.Contains(streamSettings, "v6only") { return false @@ -292,14 +307,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po }, nil } - // Every enabled local AmneziaWG inbound gets its own automatic Xray - // SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a - // port derived purely from its id (amneziawgnet.SOCKSPortForInbound) -- - // like the internal Xray API inbound above, that relay inbound is not - // itself a database row, so the ordinary DB-backed query below can never - // see it. Without this check, an unrelated inbound saved onto that exact - // port silently fails at the next Xray start, taking every other - // protocol down with it, not just AmneziaWG. + // Local embedded inbounds reserve an automatically assigned Xray SOCKS relay port. if inbound.NodeID == nil && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) { conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits) if err != nil { @@ -319,7 +327,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po // The reverse direction, only meaningful once the id is known -- AddInbound // runs it after Save. Only a local row owns a relay slot (#6537 review). - if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && ignoreId > 0 { + if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && ignoreId > 0 && !amneziawgKernelMode(inbound) { if self := amneziawgnetSocksSelfConflict(inbound, ignoreId); self != "" { return nil, common.NewError(self) } @@ -417,6 +425,9 @@ func amneziawgForwardedPortOwner(db *gorm.DB, inbound *model.Inbound, ignoreId i if !sameNode(row.NodeID, inbound.NodeID) { continue } + if amneziawgKernelMode(row) { + continue + } instance, ok := amneziawg.InstanceFromInbound(row) if !ok { continue @@ -439,8 +450,7 @@ func amneziawgForwardedPortOwner(db *gorm.DB, inbound *model.Inbound, ignoreId i return nil, nil } -// checkAmneziawgnetSocksConflict: inbound's port vs the relay port every matching -// local row reserves, emitted or not; db keeps it in the caller's transaction (#6225). +// checkAmneziawgnetSocksConflict checks the embedded relay port each local row reserves. func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { // A disabled row still owns the slot its id derives: SetInboundEnable flips // the column with no port check, so enabling it later must not collide. @@ -452,9 +462,11 @@ func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreI if err := q.Find(&candidates).Error; err != nil { return nil, err } - // Ownership does not depend on the peers: the relay appears when the first - // client is added, and the client paths run no port check at all. + // The row reserves its slot before its first peer is added. for _, c := range candidates { + if amneziawgKernelMode(c) { + continue + } if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port { continue } @@ -470,8 +482,8 @@ func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreI return nil, nil } -// checkAmneziawgnetSocksRelayCollision reports whether id's derived relay port -// is already claimed by another local AmneziaWG inbound, disabled rows included. +// checkAmneziawgnetSocksRelayCollision reports whether id's embedded relay port +// is already claimed by another local AmneziaWG inbound, including disabled rows. func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) { relayPort := amneziawgnet.SOCKSPortForInbound(id) var candidates []*model.Inbound @@ -481,6 +493,9 @@ func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDet return nil, err } for _, c := range candidates { + if amneziawgKernelMode(c) { + continue + } if amneziawgnet.SOCKSPortForInbound(c.Id) != relayPort { continue } @@ -500,7 +515,7 @@ func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDet // amneziawgnetSocksSelfConflict: a row's own WireGuard port vs the relay port its // own id derives -- all three checks below exclude that id, so nothing else does. func amneziawgnetSocksSelfConflict(inbound *model.Inbound, id int) string { - if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) { + if id <= 0 || inbound.NodeID != nil || amneziawgKernelMode(inbound) || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) { return "" } relayPort := amneziawgnet.SOCKSPortForInbound(id) @@ -522,6 +537,9 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe return nil, err } for _, c := range candidates { + if amneziawgKernelMode(c) { + continue + } if !listenOverlaps(loopbackBind, inboundBindAddr(c)) { continue } diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index d65cb1791..4b60ab867 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -816,6 +816,41 @@ func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) { } } +func TestCheckPortConflict_KernelAmneziaWGDoesNotReserveEmbeddedRelay(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``, + `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + candidate := &model.Inbound{ + Tag: "vless-kernel-relay-port", + Listen: "0.0.0.0", + Port: amneziawgnet.SOCKSPortForInbound(awgInbound.Id), + Protocol: model.VLESS, + } + if got, err := (&InboundService{}).checkPortConflict(candidate, 0); err != nil || got != nil { + t.Fatalf("kernel-mode AmneziaWG must not reserve an embedded relay port, including with no peers; got=%v err=%v", got, err) + } +} + +func TestCheckPortConflict_KernelAmneziaWGAllowsItsUDPPortToMatchRelayRange(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``, + `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + awgInbound.Port = amneziawgnet.SOCKSPortForInbound(awgInbound.Id) + if got, err := (&InboundService{}).checkPortConflict(&awgInbound, awgInbound.Id); err != nil || got != nil { + t.Fatalf("kernel-mode AmneziaWG UDP port may use a port in the embedded TCP relay range; got=%v err=%v", got, err) + } +} + // Nodes run their own Xray, so a node inbound landing on the central panel's // AmneziaWG relay port must be allowed -- the relay inbound only ever binds // 127.0.0.1 on the local panel's own Xray. diff --git a/internal/web/service/server.go b/internal/web/service/server.go index eba80cefc..26ff874ff 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -33,6 +33,7 @@ import ( "time" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -723,7 +724,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { logger.Warning("count amneziawg inbounds failed:", err) } status.AmneziaWG.Configured = amneziawgCount > 0 - status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning() + status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning() || amneziawgkern.GetManager().HasRunning() status.PanelVersion = config.GetPanelVersion() if guid, err := s.settingService.GetPanelGuid(); err == nil { diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 507df3e63..596986c33 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -790,6 +790,11 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) { if !ok { continue } + // Kernel-mode inbounds forward in the kernel and never dial Xray, so a + // relay inbound for them would listen forever with nothing behind it. + if inst.KernelMode { + continue + } if _, taken := existingTags[inbound.Tag]; taken { logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound") continue diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 9b86f8897..cb3f46a1e 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "عدم إرسال ردود الكوكي — يزيل بصمة DPI لكنه يضعف الحماية من الفيضانات.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "هذا الخادم لا يمكنه تشغيل وضع النواة بعد", + "kernelBlocker": { + "root": "اللوحة لا تعمل بصلاحيات root، لذا لا يمكنها إنشاء واجهة شبكة", + "tools": "أمر مطلوب غير موجود؛ ثبّت amneziawg-tools و iproute2", + "module": "وحدة النواة amneziawg غير مثبتة", + "nft": "nftables غير مثبت؛ وهو مطلوب لقواعد NAT الخاصة بالنفق", + "ipv6": "الخروج عبر IPv6 لكل نظير غير مدعوم بعد في وضع النواة", + "portForwarding": "إعادة توجيه المنافذ لكل عميل غير مدعومة بعد في وضع النواة", + "routeThroughXray": "لا يوجد تنفيذ لـ routeThroughXray في وضع النواة؛ سيترك النفق بلا مسار خروج" + }, + "kernelMode": "وضع النواة", + "kernelModeHint": "تشغيل هذا الـ inbound على وحدة نواة AmneziaWG بدلاً من مكدس مساحة المستخدم المدمج. عادةً أسرع، خاصة على الروابط ذات التأخير العالي، لكن توجيه Xray والـ sniffing و IPv6 وإعادة توجيه المنافذ لا تنطبق على نظرائه. يتطلب وحدة نواة AmneziaWG و amneziawg-tools على الخادم." }, "tuic": { "congestionControl": "التحكم في الازدحام", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 6f05fe75b..3684c5035 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -2357,7 +2357,19 @@ "disableCookiesHint": "Never send cookie replies — removes a DPI fingerprint; weakens flood mitigation.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "This host cannot run kernel mode yet", + "kernelBlocker": { + "root": "The panel is not running as root, so it cannot create a network interface", + "tools": "A required command is missing; install amneziawg-tools and iproute2", + "module": "The amneziawg kernel module is not installed", + "nft": "nftables is not installed; it is needed for the tunnel's NAT rules", + "ipv6": "Per-peer IPv6 egress is not supported in kernel mode yet", + "portForwarding": "Per-client port forwarding is not supported in kernel mode yet", + "routeThroughXray": "routeThroughXray has no kernel-mode implementation; it would leave the tunnel with no egress path" + }, + "kernelMode": "Kernel mode", + "kernelModeHint": "Run this inbound on the AmneziaWG kernel module instead of the built-in userspace stack. Usually faster, especially over high-latency links, but Xray routing, sniffing, IPv6 and port forwarding do not apply to its peers. Requires the AmneziaWG kernel module and amneziawg-tools on the host." }, "tuic": { "congestionControl": "Congestion Control", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index cd190c316..96f8f5117 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "No enviar cookie replies — elimina una huella para DPI, pero debilita la mitigación de inundaciones.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Este host aún no puede ejecutar el modo kernel", + "kernelBlocker": { + "root": "El panel no se ejecuta como root, así que no puede crear una interfaz de red", + "tools": "Falta un comando necesario; instala amneziawg-tools e iproute2", + "module": "El módulo del kernel amneziawg no está instalado", + "nft": "nftables no está instalado; es necesario para las reglas NAT del túnel", + "ipv6": "La salida IPv6 por peer aún no es compatible con el modo kernel", + "portForwarding": "El reenvío de puertos por cliente aún no es compatible con el modo kernel", + "routeThroughXray": "routeThroughXray no tiene implementación en modo kernel; dejaría el túnel sin ruta de salida" + }, + "kernelMode": "Modo kernel", + "kernelModeHint": "Ejecuta este inbound en el módulo del kernel de AmneziaWG en lugar de la pila integrada en espacio de usuario. Suele ser más rápido, sobre todo en enlaces de alta latencia, pero el enrutamiento de Xray, el sniffing, IPv6 y el reenvío de puertos no se aplican a sus peers. Requiere el módulo del kernel de AmneziaWG y amneziawg-tools en el host." }, "tuic": { "congestionControl": "Control de congestión", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 58913ad57..ad8865009 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "هرگز پاسخ کوکی ارسال نشود — اثر انگشت DPI را حذف می‌کند اما دفاع در برابر سیل‌آسا را ضعیف می‌کند.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "این سرور هنوز نمی‌تواند حالت کرنل را اجرا کند", + "kernelBlocker": { + "root": "پنل با دسترسی root اجرا نمی‌شود و نمی‌تواند رابط شبکه بسازد", + "tools": "یک فرمان لازم پیدا نشد؛ amneziawg-tools و iproute2 را نصب کنید", + "module": "ماژول کرنل amneziawg نصب نیست", + "nft": "nftables نصب نیست؛ برای قواعد NAT تونل لازم است", + "ipv6": "خروجی IPv6 برای هر peer هنوز در حالت کرنل پشتیبانی نمی‌شود", + "portForwarding": "فوروارد پورت برای هر کلاینت هنوز در حالت کرنل پشتیبانی نمی‌شود", + "routeThroughXray": "routeThroughXray در حالت کرنل پیاده‌سازی ندارد و تونل را بدون مسیر خروجی می‌گذارد" + }, + "kernelMode": "حالت کرنل", + "kernelModeHint": "این inbound را به‌جای پشتهٔ داخلی userspace روی ماژول کرنل AmneziaWG اجرا می‌کند. معمولاً سریع‌تر است، به‌ویژه روی لینک‌های با تأخیر بالا، اما مسیریابی Xray، sniffing، IPv6 و فوروارد پورت روی peerهای آن اعمال نمی‌شود. به ماژول کرنل AmneziaWG و amneziawg-tools روی سرور نیاز دارد." }, "tuic": { "congestionControl": "کنترل ازدحام", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 76d4b029d..5ad1c8a84 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "Tidak pernah mengirim cookie reply — menghapus sidik jari DPI, tetapi melemahkan mitigasi banjir.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Host ini belum bisa menjalankan mode kernel", + "kernelBlocker": { + "root": "Panel tidak berjalan sebagai root, sehingga tidak bisa membuat antarmuka jaringan", + "tools": "Perintah yang diperlukan tidak ada; pasang amneziawg-tools dan iproute2", + "module": "Modul kernel amneziawg belum terpasang", + "nft": "nftables belum terpasang; diperlukan untuk aturan NAT terowongan", + "ipv6": "Egress IPv6 per peer belum didukung dalam mode kernel", + "portForwarding": "Penerusan port per klien belum didukung dalam mode kernel", + "routeThroughXray": "routeThroughXray tidak punya implementasi mode kernel; terowongan akan tanpa jalur keluar" + }, + "kernelMode": "Mode kernel", + "kernelModeHint": "Jalankan inbound ini pada modul kernel AmneziaWG alih-alih stack userspace bawaan. Biasanya lebih cepat, terutama pada tautan berlatensi tinggi, tetapi routing Xray, sniffing, IPv6, dan penerusan port tidak berlaku untuk peer-nya. Memerlukan modul kernel AmneziaWG dan amneziawg-tools di host." }, "tuic": { "congestionControl": "Kontrol Kemacetan", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 45859f42c..5df309ad2 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "cookie replyを送信しません。DPIの指紋を消しますが、フラッド緩和は弱まります。", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "このホストではまだカーネルモードを実行できません", + "kernelBlocker": { + "root": "パネルが root で実行されていないため、ネットワークインターフェースを作成できません", + "tools": "必要なコマンドがありません。amneziawg-tools と iproute2 をインストールしてください", + "module": "amneziawg カーネルモジュールがインストールされていません", + "nft": "nftables がインストールされていません。トンネルの NAT ルールに必要です", + "ipv6": "カーネルモードではピアごとの IPv6 送信はまだサポートされていません", + "portForwarding": "カーネルモードではクライアントごとのポート転送はまだサポートされていません", + "routeThroughXray": "routeThroughXray にはカーネルモードの実装がなく、トンネルの出口がなくなります" + }, + "kernelMode": "カーネルモード", + "kernelModeHint": "このインバウンドを内蔵のユーザースペーススタックではなく AmneziaWG カーネルモジュールで実行します。特に遅延の大きい回線では通常高速ですが、Xray のルーティング、スニッフィング、IPv6、ポート転送はそのピアに適用されません。ホストに AmneziaWG カーネルモジュールと amneziawg-tools が必要です。" }, "tuic": { "congestionControl": "輻輳制御", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 2bcdf1f5c..0d052ee89 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "Nunca enviar cookie replies — remove uma impressão digital de DPI, mas enfraquece a mitigação de inundações.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Este host ainda não pode executar o modo kernel", + "kernelBlocker": { + "root": "O painel não está rodando como root, então não pode criar uma interface de rede", + "tools": "Falta um comando necessário; instale amneziawg-tools e iproute2", + "module": "O módulo de kernel amneziawg não está instalado", + "nft": "nftables não está instalado; ele é necessário para as regras NAT do túnel", + "ipv6": "Saída IPv6 por peer ainda não é suportada no modo kernel", + "portForwarding": "Encaminhamento de portas por cliente ainda não é suportado no modo kernel", + "routeThroughXray": "routeThroughXray não tem implementação no modo kernel; deixaria o túnel sem rota de saída" + }, + "kernelMode": "Modo kernel", + "kernelModeHint": "Executa este inbound no módulo de kernel do AmneziaWG em vez da pilha embutida em espaço de usuário. Geralmente mais rápido, principalmente em links de alta latência, mas o roteamento do Xray, o sniffing, IPv6 e o encaminhamento de portas não se aplicam aos seus peers. Requer o módulo de kernel do AmneziaWG e o amneziawg-tools no host." }, "tuic": { "congestionControl": "Controle de congestionamento", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index be691549f..d4fd7a841 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "Не отправлять cookie reply — убирает сигнатуру для DPI, но ослабляет защиту от флуда.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Этот сервер пока не может работать в режиме ядра", + "kernelBlocker": { + "root": "Панель запущена не от root, поэтому не может создать сетевой интерфейс", + "tools": "Не найдена нужная команда; установите amneziawg-tools и iproute2", + "module": "Модуль ядра amneziawg не установлен", + "nft": "nftables не установлен; он нужен для правил NAT туннеля", + "ipv6": "Выход по IPv6 для отдельных пиров в режиме ядра пока не поддерживается", + "portForwarding": "Проброс портов для клиентов в режиме ядра пока не поддерживается", + "routeThroughXray": "routeThroughXray не реализован в режиме ядра: у туннеля не останется пути наружу" + }, + "kernelMode": "Режим ядра", + "kernelModeHint": "Запускать этот inbound на модуле ядра AmneziaWG вместо встроенного стека в пространстве пользователя. Обычно быстрее, особенно на каналах с большой задержкой, но маршрутизация Xray, sniffing, IPv6 и проброс портов к его пирам не применяются. Требуются модуль ядра AmneziaWG и amneziawg-tools на сервере." }, "tuic": { "congestionControl": "Контроль перегрузок", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index 59716c81a..233ff2b9f 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -2357,7 +2357,19 @@ "disableCookiesHint": "Nikdy neposielať odpovede cookie. Odstráni rozpoznateľný znak pre DPI, ale oslabí ochranu pred zahltením.", "listenPort": "Port na počúvanie (voliteľné)", "listenPortHint": "Pevný miestny zdrojový port UDP. Hodnota 0 vyberie port automaticky.", - "outboundObfuscationHint": "Musia sa presne zhodovať s parametrami servera." + "outboundObfuscationHint": "Musia sa presne zhodovať s parametrami servera.", + "kernelUnavailable": "Tento server zatiaľ nemôže spustiť režim jadra", + "kernelBlocker": { + "root": "Panel nebeží ako root, preto nemôže vytvoriť sieťové rozhranie", + "tools": "Chýba potrebný príkaz; nainštalujte amneziawg-tools a iproute2", + "module": "Modul jadra amneziawg nie je nainštalovaný", + "nft": "nftables nie je nainštalovaný; je potrebný pre pravidlá NAT tunela", + "ipv6": "Výstup IPv6 pre jednotlivé peery režim jadra zatiaľ nepodporuje", + "portForwarding": "Presmerovanie portov pre jednotlivých klientov režim jadra zatiaľ nepodporuje", + "routeThroughXray": "routeThroughXray nemá implementáciu v režime jadra; tunel by zostal bez výstupnej cesty" + }, + "kernelMode": "Režim jadra", + "kernelModeHint": "Spustí tento inbound na module jadra AmneziaWG namiesto vstavaného zásobníka v používateľskom priestore. Zvyčajne je rýchlejší, najmä na linkách s vysokou latenciou, ale smerovanie Xray, sniffing, IPv6 a presmerovanie portov sa na jeho peery neuplatňujú. Vyžaduje modul jadra AmneziaWG a amneziawg-tools na serveri." }, "tuic": { "congestionControl": "Riadenie zahltenia", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 8ad8f583a..95022dc6f 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "Cookie reply asla gönderilmez — bir DPI parmak izini kaldırır ancak taşma korumasını zayıflatır.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Bu sunucu henüz çekirdek modunu çalıştıramıyor", + "kernelBlocker": { + "root": "Panel root olarak çalışmıyor, bu yüzden ağ arayüzü oluşturamıyor", + "tools": "Gerekli bir komut eksik; amneziawg-tools ve iproute2'yi kurun", + "module": "amneziawg çekirdek modülü kurulu değil", + "nft": "nftables kurulu değil; tünelin NAT kuralları için gerekli", + "ipv6": "Eş başına IPv6 çıkışı çekirdek modunda henüz desteklenmiyor", + "portForwarding": "İstemci başına port yönlendirme çekirdek modunda henüz desteklenmiyor", + "routeThroughXray": "routeThroughXray'in çekirdek modunda bir uygulaması yok; tüneli çıkış yolu olmadan bırakır" + }, + "kernelMode": "Çekirdek modu", + "kernelModeHint": "Bu inbound'u yerleşik kullanıcı alanı yığını yerine AmneziaWG çekirdek modülünde çalıştırır. Özellikle yüksek gecikmeli bağlantılarda genellikle daha hızlıdır, ancak Xray yönlendirmesi, sniffing, IPv6 ve port yönlendirme eşlerine uygulanmaz. Sunucuda AmneziaWG çekirdek modülü ve amneziawg-tools gerekir." }, "tuic": { "congestionControl": "Tıkanıklık Kontrolü", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index c82f55d1d..eebcf7779 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "Ніколи не надсилати cookie reply — прибирає відбиток для DPI, але послаблює захист від флуду.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Цей сервер поки не може працювати в режимі ядра", + "kernelBlocker": { + "root": "Панель запущена не від root, тому не може створити мережевий інтерфейс", + "tools": "Не знайдено потрібну команду; встановіть amneziawg-tools та iproute2", + "module": "Модуль ядра amneziawg не встановлено", + "nft": "nftables не встановлено; він потрібен для правил NAT тунелю", + "ipv6": "Вихід через IPv6 для окремих пірів у режимі ядра поки не підтримується", + "portForwarding": "Переадресація портів для клієнтів у режимі ядра поки не підтримується", + "routeThroughXray": "routeThroughXray не реалізовано в режимі ядра: тунель залишиться без шляху назовні" + }, + "kernelMode": "Режим ядра", + "kernelModeHint": "Запускати цей inbound на модулі ядра AmneziaWG замість вбудованого стеку в просторі користувача. Зазвичай швидше, особливо на каналах із великою затримкою, але маршрутизація Xray, sniffing, IPv6 та переадресація портів до його пірів не застосовуються. Потрібні модуль ядра AmneziaWG та amneziawg-tools на сервері." }, "tuic": { "congestionControl": "Контроль перевантаження", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 57f347a3d..1be0ccc93 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "Không bao giờ gửi cookie reply — xóa một dấu vết DPI nhưng làm yếu khả năng chống flood.", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "Máy chủ này chưa thể chạy chế độ kernel", + "kernelBlocker": { + "root": "Bảng điều khiển không chạy với quyền root nên không thể tạo giao diện mạng", + "tools": "Thiếu một lệnh cần thiết; hãy cài amneziawg-tools và iproute2", + "module": "Chưa cài mô-đun kernel amneziawg", + "nft": "Chưa cài nftables; cần cho các quy tắc NAT của đường hầm", + "ipv6": "Chế độ kernel chưa hỗ trợ đầu ra IPv6 theo từng peer", + "portForwarding": "Chế độ kernel chưa hỗ trợ chuyển tiếp cổng theo từng client", + "routeThroughXray": "routeThroughXray chưa có triển khai cho chế độ kernel; đường hầm sẽ không có đường ra" + }, + "kernelMode": "Chế độ kernel", + "kernelModeHint": "Chạy inbound này trên mô-đun kernel AmneziaWG thay cho ngăn xếp userspace tích hợp. Thường nhanh hơn, nhất là trên đường truyền có độ trễ cao, nhưng định tuyến Xray, sniffing, IPv6 và chuyển tiếp cổng không áp dụng cho các peer của nó. Cần mô-đun kernel AmneziaWG và amneziawg-tools trên máy chủ." }, "tuic": { "congestionControl": "Kiểm soát tắc nghẽn", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 5ff69dd11..339a47c9e 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "从不发送 cookie reply——消除一个 DPI 指纹,但会削弱抗洪泛能力。", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "此主机暂时无法运行内核模式", + "kernelBlocker": { + "root": "面板未以 root 身份运行,无法创建网络接口", + "tools": "缺少必需的命令;请安装 amneziawg-tools 和 iproute2", + "module": "未安装 amneziawg 内核模块", + "nft": "未安装 nftables;隧道的 NAT 规则需要它", + "ipv6": "内核模式暂不支持按对等端的 IPv6 出站", + "portForwarding": "内核模式暂不支持按客户端的端口转发", + "routeThroughXray": "routeThroughXray 没有内核模式实现,会使隧道没有出站路径" + }, + "kernelMode": "内核模式", + "kernelModeHint": "使用 AmneziaWG 内核模块而非内置用户态协议栈运行此入站。通常更快,尤其在高延迟链路上,但 Xray 路由、嗅探、IPv6 和端口转发不适用于其对等端。需要主机上安装 AmneziaWG 内核模块和 amneziawg-tools。" }, "tuic": { "congestionControl": "拥塞控制算法", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 495a55c43..d1c33bc79 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2236,7 +2236,19 @@ "disableCookiesHint": "永不傳送 cookie reply——消除一個 DPI 指紋,但會削弱抗洪泛能力。", "listenPort": "Listen Port (optional)", "listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.", - "outboundObfuscationHint": "Must exactly match the server side parameters." + "outboundObfuscationHint": "Must exactly match the server side parameters.", + "kernelUnavailable": "此主機暫時無法執行核心模式", + "kernelBlocker": { + "root": "面板未以 root 身分執行,無法建立網路介面", + "tools": "缺少必要的指令;請安裝 amneziawg-tools 與 iproute2", + "module": "未安裝 amneziawg 核心模組", + "nft": "未安裝 nftables;通道的 NAT 規則需要它", + "ipv6": "核心模式尚不支援每個對等端的 IPv6 出口", + "portForwarding": "核心模式尚不支援每個用戶端的連接埠轉發", + "routeThroughXray": "routeThroughXray 沒有核心模式實作,會讓通道沒有出口路徑" + }, + "kernelMode": "核心模式", + "kernelModeHint": "以 AmneziaWG 核心模組取代內建的使用者空間堆疊來執行此入站。通常更快,尤其是在高延遲連線上,但 Xray 路由、嗅探、IPv6 與連接埠轉發不適用於其對等端。主機需安裝 AmneziaWG 核心模組與 amneziawg-tools。" }, "tuic": { "congestionControl": "壅塞控制演算法", diff --git a/internal/web/web.go b/internal/web/web.go index 24d4d711d..aaee5dbc6 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" @@ -803,6 +804,8 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { } mtproto.GetManager().StopAll() amneziawgnet.GetManager().StopAll() + job.NewAmneziaWGJob().FlushKernelTraffic() + amneziawgkern.GetManager().StopAll() amneziawgnet.GetOutboundManager().StopAll() } if stopXray { diff --git a/main.go b/main.go index aa2010959..a2fbdce7f 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,16 @@ package main import ( "context" + _ "embed" "flag" "fmt" "log" "net/http" _ "net/http/pprof" "os" + "os/exec" "os/signal" + "runtime" "strings" "syscall" _ "unsafe" @@ -664,6 +667,8 @@ func main() { return } runWebServer() + case "install-awg-kernel": + installAmneziaWGKernelModule() case "migrate": migrateDb() case "encrypt-tokens": @@ -771,5 +776,51 @@ Commands: migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn) encrypt-tokens encrypt node bearer tokens with the configured active key setting set settings + install-awg-kernel + build and install the AmneziaWG kernel module (required for + an AmneziaWG inbound's kernel mode) ` } + +//go:embed scripts/install-amneziawg-kernel.sh +var installAwgKernelScript []byte + +// installAmneziaWGKernelModule runs the bundled module installer. +// It needs a terminal for its apt and DKMS build output. +func installAmneziaWGKernelModule() { + if runtime.GOOS != "linux" { + fmt.Println("the AmneziaWG kernel module is Linux-only") + return + } + if os.Geteuid() != 0 { + exe, err := os.Executable() + if err != nil || exe == "" { + exe = "/usr/local/x-ui/x-ui" + } + fmt.Println("run as root: sudo " + exe + " install-awg-kernel") + return + } + + f, err := os.CreateTemp("", "install-amneziawg-kernel-*.sh") + if err != nil { + fmt.Println("could not stage the installer:", err) + return + } + defer os.Remove(f.Name()) + if _, err := f.Write(installAwgKernelScript); err != nil { + f.Close() + fmt.Println("could not write the installer:", err) + return + } + f.Close() + + // No timeout: an apt+dkms build legitimately runs for minutes. + cmd := exec.CommandContext(context.Background(), "bash", f.Name()) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + fmt.Println("install failed:", err) + os.Exit(1) + } +} diff --git a/scripts/install-amneziawg-kernel.sh b/scripts/install-amneziawg-kernel.sh new file mode 100644 index 000000000..30ae7a6ac --- /dev/null +++ b/scripts/install-amneziawg-kernel.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Installs the AmneziaWG kernel module and tools, the prerequisite for an +# AmneziaWG inbound's "kernel mode" (internal/amneziawgkern). Without it the +# panel refuses to enable the setting and says why. +# +# Verified on Debian 13 (trixie), kernel 7.1.11-x64v3-xanmod1, x86_64. +set -eu + +log() { printf '\n==> %s\n' "$*"; } +die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "run as root" +command -v apt-get >/dev/null || die "this installer supports Debian and Ubuntu; elsewhere install amneziawg-linux-kernel-module and amneziawg-tools 3.x by hand" + +KERNEL="$(uname -r)" +SRC_DIR="${SRC_DIR:-/usr/local/src}" +MODULE_REPO="https://github.com/amnezia-vpn/amneziawg-linux-kernel-module.git" +TOOLS_REPO="https://github.com/amnezia-vpn/amneziawg-tools.git" + +# Pinned to tested releases and verified by commit, since this runs as root. +# Module and tools must share the 3.x series: 1.0 tools send u32 H1-H4 and +# `awg setconf` then fails with "Invalid argument". +MODULE_TAG="v3.1.20260906" +MODULE_COMMIT="4569c4c67f3a57414969260cafbbd04694fbaae0" +TOOLS_TAG="v3.1.20260812" +TOOLS_COMMIT="ee0f0a9aa34ff0a0da4b3433b9512781cfe02843" + +log "Kernel: $KERNEL" +[ -d "/lib/modules/$KERNEL/build" ] || die "kernel headers missing; install linux-headers-$KERNEL" + +log "Installing build dependencies" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq dkms build-essential git libmnl-dev pkg-config nftables + +# A clang-built kernel makes DKMS invoke the module build with LLVM=1, which +# fails outright when only gcc is present. /proc/version names the compiler. +if grep -qi clang /proc/version; then + log "Kernel was built with clang; installing the LLVM toolchain to match" + apt-get install -y -qq clang lld llvm +fi + +log "Module $MODULE_TAG, tools $TOOLS_TAG" + +mkdir -p "$SRC_DIR" +cd "$SRC_DIR" +rm -rf amneziawg-linux-kernel-module amneziawg-tools +git clone -q --depth 1 --branch "$MODULE_TAG" "$MODULE_REPO" +git clone -q --depth 1 --branch "$TOOLS_TAG" "$TOOLS_REPO" +[ "$(git -C amneziawg-linux-kernel-module rev-parse HEAD)" = "$MODULE_COMMIT" ] || die "module $MODULE_TAG is not commit $MODULE_COMMIT" +[ "$(git -C amneziawg-tools rev-parse HEAD)" = "$TOOLS_COMMIT" ] || die "tools $TOOLS_TAG is not commit $TOOLS_COMMIT" + +MODULE_SRC="$SRC_DIR/amneziawg-linux-kernel-module/src" + +log "Building the module via DKMS" +cd "$MODULE_SRC" +rm -rf /usr/src/amneziawg-1.0.0 +make dkms-install +dkms status -m amneziawg -v 1.0.0 | grep -q . || dkms add -m amneziawg -v 1.0.0 +dkms build -m amneziawg -v 1.0.0 +dkms install -m amneziawg -v 1.0.0 --force + +log "Loading the module" +modprobe amneziawg +lsmod | grep -q '^amneziawg' || die "module did not load" + +log "Building amneziawg-tools" +cd "$SRC_DIR/amneziawg-tools/src" +make +make install + +log "Done" +printf ' module: %s\n' "$(dkms status -m amneziawg)" +printf ' tools: %s\n' "$(awg --version)" +printf '\nEnable "Kernel mode" on the AmneziaWG inbound in the panel to use it.\n' +printf 'DKMS rebuilds the module on kernel upgrades; Secure Boot hosts must sign it.\n' diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index 7a5a9f526..3a5517955 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -128,6 +128,11 @@ func run(root, outDir string) error { Path: resolveRel(root, "internal/amneziawg"), StructAllow: setOf("ServerSettings"), }, + { + Path: resolveRel(root, "internal/amneziawgkern"), + StructAllow: setOf("Readiness", "Blocker"), + AliasAllow: setOf("BlockerCode"), + }, { Path: resolveRel(root, "internal/tuic"), StructAllow: setOf("TuicServerSettings", "TuicClientSettings"),