From 909feefd1d83920f6a5bd3a51039c1877f4ec890 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 25 Jul 2026 19:01:31 +0300 Subject: [PATCH] fix(amneziawg): make RouteViaXray an inbound-level option too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RouteThroughXray/RouteOutboundTag were client-only, but the more common case is "route this whole AmneziaWG server's traffic through Xray", not configuring every peer individually. Add the same pair to ServerSettings (inbound-level) while keeping the per-client fields as an override — matching how ExternalInterface/IPv6Enabled already work at the server level next to per-client settings like ForwardedPorts. Effective per-peer decision (computed once, in InstanceFromInbound, not duplicated at each consumer): - routed = client.RouteThroughXray || server.RouteThroughXray - outbound tag = client's own if set, else the server's default This means a peer can be routed by the inbound-wide default with no config of its own, opt in on its own even when the default is off, or keep the default's on/off but pick a different outbound than everyone else. internal/web/service/xray.go's injectAmneziawgEgress now calls amneziawg.InstanceFromInbound instead of re-parsing InboundSettings and reading model.Client fields directly — the same effective-routing computation the kernel-side TPROXY rules use, so the two independent reconcile loops (Xray-config generation and the AWG manager) can never quietly disagree about which peers are actually routed. Frontend: Switch + conditional outbound Select added to the AWG inbound form (mirroring the client-form version and mtproto's own UI), plus the inbound-defaults.ts default-object fix that's bitten this project's CI before (Phase 2a) whenever ServerSettings gains a new required-shaped field. Test fixtures in xray_config_inject_test.go needed a real Server block and PublicKey once injectAmneziawgEgress started requiring a usable InstanceFromInbound result — both were implicit fixture gaps, not behavior the old tests were actually asserting. Co-Authored-By: Claude Sonnet 5 --- frontend/public/openapi.json | 7 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 7 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/lib/xray/inbound-defaults.ts | 2 + .../inbounds/form/protocols/amneziawg.tsx | 29 ++++- .../schemas/protocols/inbound/amneziawg.ts | 2 + internal/amneziawg/manager.go | 21 ++-- internal/amneziawg/manager_test.go | 73 +++++++++++++ internal/amneziawg/types.go | 36 +++++-- internal/web/service/xray.go | 22 ++-- .../web/service/xray_config_inject_test.go | 101 +++++++++++++++--- internal/web/translation/en-US.json | 5 + internal/web/translation/ru-RU.json | 5 + 15 files changed, 278 insertions(+), 38 deletions(-) diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 7a1c88991..d93045714 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2878,6 +2878,13 @@ "publicKey": { "type": "string" }, + "routeOutboundTag": { + "type": "string" + }, + "routeThroughXray": { + "description": "RouteThroughXray, when true, is the inbound-wide default: every peer\nTPROXYs into Xray unless it explicitly turns its own RouteThroughXray\noff... except a plain bool can't distinguish \"peer left it unset\" from\n\"peer explicitly opted out\", so in practice this ORs with each peer's\nown flag (see Peer.RouteThroughXray) — turning this on routes every\npeer, turning it off still lets individual peers opt in on their own.\nRouteOutboundTag is the default outbound/balancer tag used when a\nrouted peer didn't set its own; empty means Xray's default routing.", + "type": "boolean" + }, "s1": { "type": "integer" }, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 6807b6146..084b0a387 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -670,6 +670,8 @@ export const EXAMPLES: Record = { "primaryDns": "", "privateKey": "", "publicKey": "", + "routeOutboundTag": "", + "routeThroughXray": false, "s1": 0, "s2": 0, "s3": 0, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 05abce170..a7caa22cc 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2852,6 +2852,13 @@ export const SCHEMAS: Record = { "publicKey": { "type": "string" }, + "routeOutboundTag": { + "type": "string" + }, + "routeThroughXray": { + "description": "RouteThroughXray, when true, is the inbound-wide default: every peer\nTPROXYs into Xray unless it explicitly turns its own RouteThroughXray\noff... except a plain bool can't distinguish \"peer left it unset\" from\n\"peer explicitly opted out\", so in practice this ORs with each peer's\nown flag (see Peer.RouteThroughXray) — turning this on routes every\npeer, turning it off still lets individual peers opt in on their own.\nRouteOutboundTag is the default outbound/balancer tag used when a\nrouted peer didn't set its own; empty means Xray's default routing.", + "type": "boolean" + }, "s1": { "type": "integer" }, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 88a409f46..ff71907f1 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -653,6 +653,8 @@ export interface ServerSettings { primaryDns?: string; privateKey: string; publicKey: string; + routeOutboundTag?: string; + routeThroughXray?: boolean; s1: number; s2: number; s3: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 257e81374..fac01f57c 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -692,6 +692,8 @@ export const ServerSettingsSchema = z.object({ primaryDns: z.string().optional(), privateKey: z.string(), publicKey: z.string(), + routeOutboundTag: z.string().optional(), + routeThroughXray: z.boolean().optional(), s1: z.number().int(), s2: z.number().int(), s3: z.number().int(), diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 0c5fabc0b..e34b72921 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -298,6 +298,8 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting ipv6Enabled: false, ipv6Subnet: '', ipv6ExternalInterface: '', + routeThroughXray: false, + routeOutboundTag: '', jc: 5, jmin: 10, jmax: 50, diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx index 76da588e6..b88bf8a0a 100644 --- a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, InputNumber, Space, Switch } from 'antd'; +import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { ReloadOutlined } from '@ant-design/icons'; +import { useFormContext, useWatch } from 'react-hook-form'; import { FormField } from '@/components/form/rhf'; +import { useOutboundTags } from '@/api/queries/useOutboundTags'; interface AmneziawgFieldsProps { awgPubKey: string; @@ -12,6 +14,9 @@ interface AmneziawgFieldsProps { export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInboundAwgObfuscation }: AmneziawgFieldsProps) { const { t } = useTranslation(); + const { control } = useFormContext(); + const routeThroughXray = useWatch({ control, name: 'settings.server.routeThroughXray' }) as boolean | undefined; + const { data: outboundTags } = useOutboundTags(); return ( <> @@ -68,6 +73,28 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou > + + + + {routeThroughXray && ( + +