From d1b77b2aa41e4916be4c5acc3aee6a66319f4105 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 25 Jul 2026 18:34:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(amneziawg):=20Phase=202c=20=E2=80=94=20Rou?= =?UTF-8?q?teViaXray=20(TPROXY=20into=20Xray)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-client toggle (RouteThroughXray + RouteOutboundTag) that TPROXYs a peer's traffic into Xray instead of NAT'ing it straight out the host's network interface, so it can egress through any configured Xray outbound (or balancer) — a VLESS/proxy chain, WARP, etc. Discovered mid-design that internal/mtproto already solved the "let a native sidecar's traffic egress through Xray" problem once, via routeThroughXray/routeXrayPort/outboundTag + injectMtprotoEgress: a loopback bridge inbound plus a routing rule. AmneziaWG can't reuse it directly — mtg is a userspace process that dials *out* through a local SOCKS proxy, while AmneziaWG is a kernel tunnel interface with no process of its own to redirect. The Xray-side shape carries over almost exactly, the kernel-side plumbing is new: - internal/amneziawg/route_egress.go: EgressPort/EgressTag/EgressFwmark/ EgressTable are one shared constant set, not one bridge per peer. Every routed peer, across every AmneziaWG instance, TPROXYs into the *same* loopback dokodemo-door bridge; the per-peer distinction happens downstream, in Xray's own router, matched against each peer's TPROXY-preserved source IP (Xray's field-rule `source` matcher — a capability the router already had). This avoids two independent reconcile loops (the AWG manager and the Xray-config generator) ever having to agree on a dynamically-picked port for each peer. - manager.go's defaultPostUpDown emits a per-peer mangle-table TPROXY rule (matched by tunnel source IP) for each opted-in peer, plus the fwmark->table->local-everywhere policy route TPROXY needs to deliver those packets to the bridge. That policy route is system-wide, not interface-specific, so — like the existing IPv6-forwarding sysctl — it's added idempotently and never torn down in PostDown; a second AmneziaWG instance with its own routed peers must find it already in place, not race to remove what the first still needs. - The existing portForwardFingerprint became hostRulesFingerprint, covering both ForwardedPorts and RouteThroughXray/RouteOutboundTag: both only ever take effect through PostUp/PostDown, which `awg syncconf` never re-runs, so either one changing must force the same full interface bounce. - internal/web/service/xray.go's new injectAmneziawgEgress mirrors injectMtprotoEgress/injectPanelEgress's safety rules, adapted for one bridge serving many peers: an invalid or missing outbound target skips only that one peer's rule (not the whole bridge, since other peers may still need it), while the bridge itself is skipped entirely when nothing needs it or its tag is already taken by a real inbound. Frontend: a Switch + conditional outbound Select on the client form (showAmneziawg only), mirroring mtproto's own routeThroughXray UI and reusing its useOutboundTags hook. install.sh now modprobes the mainline TPROXY modules (xt_TPROXY, nf_tproxy_ipv4/ipv6) alongside the existing AmneziaWG setup — ordinary upstream kernel modules, no DKMS/PPA needed unlike the AmneziaWG module itself. Co-Authored-By: Claude Sonnet 5 --- frontend/public/openapi.json | 16 ++ frontend/src/generated/examples.ts | 4 + frontend/src/generated/schemas.ts | 16 ++ frontend/src/generated/types.ts | 4 + frontend/src/generated/zod.ts | 4 + .../src/pages/clients/ClientFormModal.tsx | 40 ++- frontend/src/schemas/client.ts | 2 + .../schemas/protocols/inbound/amneziawg.ts | 11 +- install.sh | 15 ++ internal/amneziawg/manager.go | 99 +++++--- internal/amneziawg/manager_test.go | 127 +++++++++- internal/amneziawg/route_egress.go | 70 ++++++ internal/amneziawg/types.go | 12 + internal/database/model/model.go | 158 ++++++------ internal/web/service/xray.go | 131 ++++++++++ .../web/service/xray_config_inject_test.go | 229 ++++++++++++++++++ internal/web/translation/en-US.json | 5 + internal/web/translation/ru-RU.json | 5 + 18 files changed, 837 insertions(+), 111 deletions(-) create mode 100644 internal/amneziawg/route_egress.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 9bcb3a494..7a1c88991 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1095,6 +1095,14 @@ "description": "VLESS simple reverse proxy settings", "nullable": true }, + "routeOutboundTag": { + "description": "Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing", + "type": "string" + }, + "routeThroughXray": { + "description": "AmneziaWG: TPROXY this peer's traffic into Xray", + "type": "boolean" + }, "secret": { "example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "type": "string" @@ -1223,6 +1231,12 @@ "type": "integer" }, "reverse": {}, + "routeOutboundTag": { + "type": "string" + }, + "routeThroughXray": { + "type": "boolean" + }, "secret": { "type": "string" }, @@ -1269,6 +1283,8 @@ "publicKey", "reset", "reverse", + "routeOutboundTag", + "routeThroughXray", "secret", "security", "subId", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 0d0391323..6807b6146 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -252,6 +252,8 @@ export const EXAMPLES: Record = { "publicKey": "", "reset": 0, "reverse": null, + "routeOutboundTag": "", + "routeThroughXray": false, "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "security": "", "subId": "", @@ -286,6 +288,8 @@ export const EXAMPLES: Record = { "publicKey": "", "reset": 0, "reverse": null, + "routeOutboundTag": "", + "routeThroughXray": false, "secret": "", "security": "", "subId": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index b55c533f8..05abce170 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1069,6 +1069,14 @@ export const SCHEMAS: Record = { "description": "VLESS simple reverse proxy settings", "nullable": true }, + "routeOutboundTag": { + "description": "Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing", + "type": "string" + }, + "routeThroughXray": { + "description": "AmneziaWG: TPROXY this peer's traffic into Xray", + "type": "boolean" + }, "secret": { "example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "type": "string" @@ -1197,6 +1205,12 @@ export const SCHEMAS: Record = { "type": "integer" }, "reverse": {}, + "routeOutboundTag": { + "type": "string" + }, + "routeThroughXray": { + "type": "boolean" + }, "secret": { "type": "string" }, @@ -1243,6 +1257,8 @@ export const SCHEMAS: Record = { "publicKey", "reset", "reverse", + "routeOutboundTag", + "routeThroughXray", "secret", "security", "subId", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index dd06334ed..88a409f46 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -261,6 +261,8 @@ export interface Client { publicKey?: string; reset: number; reverse?: ClientReverse | null; + routeOutboundTag?: string; + routeThroughXray?: boolean; secret?: string; security: string; subId: string; @@ -297,6 +299,8 @@ export interface ClientRecord { publicKey: string; reset: number; reverse: unknown; + routeOutboundTag: string; + routeThroughXray: boolean; secret: string; security: string; subId: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index a54af8da7..257e81374 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -279,6 +279,8 @@ export const ClientSchema = z.object({ publicKey: z.string().optional(), reset: z.number().int(), reverse: z.lazy(() => ClientReverseSchema).nullable().optional(), + routeOutboundTag: z.string().optional(), + routeThroughXray: z.boolean().optional(), secret: z.string().optional(), security: z.string(), subId: z.string(), @@ -317,6 +319,8 @@ export const ClientRecordSchema = z.object({ publicKey: z.string(), reset: z.number().int(), reverse: z.unknown(), + routeOutboundTag: z.string(), + routeThroughXray: z.boolean(), secret: z.string(), security: z.string(), subId: z.string(), diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 5a05522fb..a912957c4 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -33,6 +33,7 @@ import { FormField } from '@/components/form/rhf'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients'; import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery'; +import { useOutboundTags } from '@/api/queries/useOutboundTags'; import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client'; const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); @@ -103,6 +104,8 @@ type Values = ClientFormValues & { wgPreSharedKey: string; wgAllowedIPs: string; awgForwardedPorts: string; + awgRouteThroughXray: boolean; + awgRouteOutboundTag: string; secret: string; adTag: string; }; @@ -133,6 +136,8 @@ const EMPTY: Values = { wgPreSharedKey: '', wgAllowedIPs: '', awgForwardedPorts: '', + awgRouteThroughXray: false, + awgRouteOutboundTag: '', secret: '', adTag: '', }; @@ -193,6 +198,8 @@ export default function ClientFormModal({ const subId = useWatch({ control: methods.control, name: 'subId' }); const auth = useWatch({ control: methods.control, name: 'auth' }); const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' }); + const awgRouteThroughXray = useWatch({ control: methods.control, name: 'awgRouteThroughXray' }); + const { data: outboundTags } = useOutboundTags(); const limitIp = useWatch({ control: methods.control, name: 'limitIp' }); const { fields: externalLinkFields, @@ -246,6 +253,8 @@ export default function ClientFormModal({ wgPreSharedKey: client.preSharedKey || '', wgAllowedIPs: client.allowedIPs || '', awgForwardedPorts: client.forwardedPorts || '', + awgRouteThroughXray: !!client.routeThroughXray, + awgRouteOutboundTag: client.routeOutboundTag || '', secret: client.secret || '', adTag: client.adTag || '', }; @@ -561,10 +570,13 @@ export default function ClientFormModal({ if (allowedIPs.length > 0) { clientPayload.allowedIPs = allowedIPs; } - // Port-forwarding has no WireGuard equivalent — Xray-native WireGuard - // has no host-level iptables layer to hang per-client DNAT off of. + // Port-forwarding and RouteViaXray have no WireGuard equivalent — + // Xray-native WireGuard has no host-level iptables layer to hang + // per-client DNAT/TPROXY off of. if (showAmneziawg) { clientPayload.forwardedPorts = values.awgForwardedPorts.trim(); + clientPayload.routeThroughXray = values.awgRouteThroughXray; + clientPayload.routeOutboundTag = values.awgRouteThroughXray ? values.awgRouteOutboundTag.trim() : ''; } } @@ -917,6 +929,30 @@ export default function ClientFormModal({ )} + {showAmneziawg && ( + + + + )} + {showAmneziawg && awgRouteThroughXray && ( + +