fix(amneziawg): make RouteViaXray an inbound-level option too

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 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 19:01:31 +03:00
parent d1b77b2aa4
commit 909feefd1d
15 changed files with 278 additions and 38 deletions
+2
View File
@@ -670,6 +670,8 @@ export const EXAMPLES: Record<string, unknown> = {
"primaryDns": "",
"privateKey": "",
"publicKey": "",
"routeOutboundTag": "",
"routeThroughXray": false,
"s1": 0,
"s2": 0,
"s3": 0,
+7
View File
@@ -2852,6 +2852,13 @@ export const SCHEMAS: Record<string, unknown> = {
"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"
},
+2
View File
@@ -653,6 +653,8 @@ export interface ServerSettings {
primaryDns?: string;
privateKey: string;
publicKey: string;
routeOutboundTag?: string;
routeThroughXray?: boolean;
s1: number;
s2: number;
s3: number;
+2
View File
@@ -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(),
@@ -298,6 +298,8 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
ipv6Enabled: false,
ipv6Subnet: '',
ipv6ExternalInterface: '',
routeThroughXray: false,
routeOutboundTag: '',
jc: 5,
jmin: 10,
jmax: 50,
@@ -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 (
<>
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
@@ -68,6 +73,28 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou
>
<Input placeholder="eth0" />
</FormField>
<FormField
name={['settings', 'server', 'routeThroughXray']}
label={t('pages.xray.amneziawg.routeThroughXray')}
tooltip={t('pages.xray.amneziawg.routeThroughXrayHint')}
valueProp="checked"
>
<Switch />
</FormField>
{routeThroughXray && (
<FormField
name={['settings', 'server', 'routeOutboundTag']}
label={t('pages.xray.amneziawg.routeOutboundTag')}
tooltip={t('pages.xray.amneziawg.routeOutboundTagHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.xray.amneziawg.routeOutboundTagPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
/>
</FormField>
)}
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
{t('pages.xray.amneziawg.regenerateObfuscation')}
@@ -57,6 +57,8 @@ export const AmneziawgServerSchema = z.object({
ipv6Enabled: z.boolean().default(false),
ipv6Subnet: z.string().default(''),
ipv6ExternalInterface: z.string().default(''),
routeThroughXray: z.boolean().default(false),
routeOutboundTag: z.string().default(''),
jc: z.number().int().min(0).default(5),
jmin: z.number().int().min(0).default(10),
jmax: z.number().int().min(0).default(50),