refactor(amneziawg): route via Xray through the stock Routing page, not custom toggles

Simplifies RouteViaXray after realizing the panel already has everything
needed: the Routing page already lets an admin pick a source inbound tag
and a target outbound (plus, if they want it, a specific source IP) for
any protocol. Bolting a parallel routeThroughXray/routeOutboundTag pair
onto both the client and inbound forms duplicated that mechanism instead
of using it.

Removed entirely: Client/ClientRecord/ServerSettings/Peer's
RouteThroughXray + RouteOutboundTag fields, the effective-routing OR/
fallback logic in InstanceFromInbound, and the Switch+Select UI on both
forms. Nothing configures "route via Xray" as a setting anymore.

In its place, every enabled AmneziaWG inbound now gets its own Xray
TPROXY bridge unconditionally, by default, no toggle:

- internal/amneziawg: every peer's traffic is always TPROXY'd into that
  instance's own bridge (defaultPostUpDown, port derived from the
  inbound's id via EgressPortForInbound so the kernel side and the
  Xray-config side never need to negotiate a runtime value). Since the
  TPROXY rule is now tied to a peer's mere presence rather than an
  opt-in flag, hostRulesFingerprint now covers every peer unconditionally
  (add/remove/re-IP forces a restart, the same way ForwardedPorts always
  did) instead of skipping peers with nothing to opt into.
- internal/web/service/xray.go's injectAmneziawgEgress creates one
  dokodemo-door bridge per qualifying inbound, tagged with that inbound's
  own real tag — the same trick injectMtprotoEgress already uses (reusing
  a real inbound's tag), which is why it's already selectable in the
  panel's Routing page: InboundService.GetInboundTags() is a plain,
  protocol-blind SELECT over every inbound row's tag, no dedicated UI
  plumbing needed. The function never generates a routing rule itself
  anymore — where (if anywhere) that traffic goes is entirely up to
  whatever rules the admin adds through the existing Routing UI.

Frontend: no new UI at all. Tests rewritten to match — one bridge per
inbound with its own tag/port, no rule generation, no opt-in gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 22:37:08 +03:00
parent 909feefd1d
commit db8253421a
19 changed files with 289 additions and 782 deletions
-6
View File
@@ -252,8 +252,6 @@ export const EXAMPLES: Record<string, unknown> = {
"publicKey": "",
"reset": 0,
"reverse": null,
"routeOutboundTag": "",
"routeThroughXray": false,
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
"security": "",
"subId": "",
@@ -288,8 +286,6 @@ export const EXAMPLES: Record<string, unknown> = {
"publicKey": "",
"reset": 0,
"reverse": null,
"routeOutboundTag": "",
"routeThroughXray": false,
"secret": "",
"security": "",
"subId": "",
@@ -670,8 +666,6 @@ export const EXAMPLES: Record<string, unknown> = {
"primaryDns": "",
"privateKey": "",
"publicKey": "",
"routeOutboundTag": "",
"routeThroughXray": false,
"s1": 0,
"s2": 0,
"s3": 0,
-23
View File
@@ -1069,14 +1069,6 @@ export const SCHEMAS: Record<string, unknown> = {
"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"
@@ -1205,12 +1197,6 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer"
},
"reverse": {},
"routeOutboundTag": {
"type": "string"
},
"routeThroughXray": {
"type": "boolean"
},
"secret": {
"type": "string"
},
@@ -1257,8 +1243,6 @@ export const SCHEMAS: Record<string, unknown> = {
"publicKey",
"reset",
"reverse",
"routeOutboundTag",
"routeThroughXray",
"secret",
"security",
"subId",
@@ -2852,13 +2836,6 @@ 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"
},
-6
View File
@@ -261,8 +261,6 @@ export interface Client {
publicKey?: string;
reset: number;
reverse?: ClientReverse | null;
routeOutboundTag?: string;
routeThroughXray?: boolean;
secret?: string;
security: string;
subId: string;
@@ -299,8 +297,6 @@ export interface ClientRecord {
publicKey: string;
reset: number;
reverse: unknown;
routeOutboundTag: string;
routeThroughXray: boolean;
secret: string;
security: string;
subId: string;
@@ -653,8 +649,6 @@ export interface ServerSettings {
primaryDns?: string;
privateKey: string;
publicKey: string;
routeOutboundTag?: string;
routeThroughXray?: boolean;
s1: number;
s2: number;
s3: number;
-6
View File
@@ -279,8 +279,6 @@ 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(),
@@ -319,8 +317,6 @@ 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(),
@@ -692,8 +688,6 @@ 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,8 +298,6 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
ipv6Enabled: false,
ipv6Subnet: '',
ipv6ExternalInterface: '',
routeThroughXray: false,
routeOutboundTag: '',
jc: 5,
jmin: 10,
jmax: 50,
+2 -38
View File
@@ -33,7 +33,6 @@ 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);
@@ -104,8 +103,6 @@ type Values = ClientFormValues & {
wgPreSharedKey: string;
wgAllowedIPs: string;
awgForwardedPorts: string;
awgRouteThroughXray: boolean;
awgRouteOutboundTag: string;
secret: string;
adTag: string;
};
@@ -136,8 +133,6 @@ const EMPTY: Values = {
wgPreSharedKey: '',
wgAllowedIPs: '',
awgForwardedPorts: '',
awgRouteThroughXray: false,
awgRouteOutboundTag: '',
secret: '',
adTag: '',
};
@@ -198,8 +193,6 @@ 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,
@@ -253,8 +246,6 @@ 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 || '',
};
@@ -570,13 +561,10 @@ export default function ClientFormModal({
if (allowedIPs.length > 0) {
clientPayload.allowedIPs = allowedIPs;
}
// 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.
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
// has no host-level iptables layer to hang per-client DNAT off of.
if (showAmneziawg) {
clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
clientPayload.routeThroughXray = values.awgRouteThroughXray;
clientPayload.routeOutboundTag = values.awgRouteThroughXray ? values.awgRouteOutboundTag.trim() : '';
}
}
@@ -929,30 +917,6 @@ export default function ClientFormModal({
<Input placeholder="80, 443, 8000-8100" />
</FormField>
)}
{showAmneziawg && (
<FormField
name="awgRouteThroughXray"
label={t('pages.clients.amneziaWgRouteThroughXray')}
tooltip={t('pages.clients.amneziaWgRouteThroughXrayHint')}
valueProp="checked"
>
<Switch />
</FormField>
)}
{showAmneziawg && awgRouteThroughXray && (
<FormField
name="awgRouteOutboundTag"
label={t('pages.clients.amneziaWgRouteOutboundTag')}
tooltip={t('pages.clients.amneziaWgRouteOutboundTagHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.clients.amneziaWgRouteOutboundTagPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
/>
</FormField>
)}
</>
)}
{showMtproto && (
@@ -1,10 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { Button, Form, Input, InputNumber, 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;
@@ -14,9 +12,6 @@ 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')}>
@@ -73,28 +68,6 @@ 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')}
-2
View File
@@ -38,8 +38,6 @@ export const ClientRecordSchema = z.object({
preSharedKey: z.string().optional(),
keepAlive: z.number().optional(),
forwardedPorts: z.string().optional(),
routeThroughXray: z.boolean().optional(),
routeOutboundTag: z.string().optional(),
secret: z.string().optional(),
adTag: z.string().optional(),
createdAt: z.number().optional(),
@@ -10,12 +10,9 @@ const optionalClearedInt = (schema: z.ZodNumber) =>
// WireguardClientSchema — the panel's generic ClientRecord already has those
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
// bulk operations, the QR modal and subscriptions all work unmodified — plus
// two AmneziaWG-only additions: forwardedPorts (WireGuard's Xray-native
// inbound has no host-level iptables layer to hang per-client DNAT off of)
// and routeThroughXray/routeOutboundTag (TPROXYs this peer's traffic into a
// shared Xray bridge instead of NAT'ing it straight out — see
// internal/amneziawg's EgressPort). Keys are optional on the wire — the
// backend generates them when absent.
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
// inbound has no host-level iptables layer to hang per-client DNAT off of).
// Keys are optional on the wire — the backend generates them when absent.
export const AmneziawgClientSchema = z.object({
privateKey: z.string().optional(),
publicKey: z.string().optional(),
@@ -23,8 +20,6 @@ export const AmneziawgClientSchema = z.object({
allowedIPs: z.array(z.string()).default([]),
keepAlive: optionalClearedInt(z.number().int().min(0)),
forwardedPorts: z.string().default(''),
routeThroughXray: z.boolean().default(false),
routeOutboundTag: z.string().default(''),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
totalGB: z.number().int().min(0).default(0),
@@ -57,8 +52,6 @@ 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),