mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
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:
@@ -1095,14 +1095,6 @@
|
|||||||
"description": "VLESS simple reverse proxy settings",
|
"description": "VLESS simple reverse proxy settings",
|
||||||
"nullable": true
|
"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": {
|
"secret": {
|
||||||
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1231,12 +1223,6 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
"routeOutboundTag": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"routeThroughXray": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1283,8 +1269,6 @@
|
|||||||
"publicKey",
|
"publicKey",
|
||||||
"reset",
|
"reset",
|
||||||
"reverse",
|
"reverse",
|
||||||
"routeOutboundTag",
|
|
||||||
"routeThroughXray",
|
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
@@ -2878,13 +2862,6 @@
|
|||||||
"publicKey": {
|
"publicKey": {
|
||||||
"type": "string"
|
"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": {
|
"s1": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -252,8 +252,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"publicKey": "",
|
"publicKey": "",
|
||||||
"reset": 0,
|
"reset": 0,
|
||||||
"reverse": null,
|
"reverse": null,
|
||||||
"routeOutboundTag": "",
|
|
||||||
"routeThroughXray": false,
|
|
||||||
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||||
"security": "",
|
"security": "",
|
||||||
"subId": "",
|
"subId": "",
|
||||||
@@ -288,8 +286,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"publicKey": "",
|
"publicKey": "",
|
||||||
"reset": 0,
|
"reset": 0,
|
||||||
"reverse": null,
|
"reverse": null,
|
||||||
"routeOutboundTag": "",
|
|
||||||
"routeThroughXray": false,
|
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"security": "",
|
"security": "",
|
||||||
"subId": "",
|
"subId": "",
|
||||||
@@ -670,8 +666,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"primaryDns": "",
|
"primaryDns": "",
|
||||||
"privateKey": "",
|
"privateKey": "",
|
||||||
"publicKey": "",
|
"publicKey": "",
|
||||||
"routeOutboundTag": "",
|
|
||||||
"routeThroughXray": false,
|
|
||||||
"s1": 0,
|
"s1": 0,
|
||||||
"s2": 0,
|
"s2": 0,
|
||||||
"s3": 0,
|
"s3": 0,
|
||||||
|
|||||||
@@ -1069,14 +1069,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "VLESS simple reverse proxy settings",
|
"description": "VLESS simple reverse proxy settings",
|
||||||
"nullable": true
|
"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": {
|
"secret": {
|
||||||
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1205,12 +1197,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
"routeOutboundTag": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"routeThroughXray": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1257,8 +1243,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"publicKey",
|
"publicKey",
|
||||||
"reset",
|
"reset",
|
||||||
"reverse",
|
"reverse",
|
||||||
"routeOutboundTag",
|
|
||||||
"routeThroughXray",
|
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
@@ -2852,13 +2836,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"publicKey": {
|
"publicKey": {
|
||||||
"type": "string"
|
"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": {
|
"s1": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -261,8 +261,6 @@ export interface Client {
|
|||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
reset: number;
|
reset: number;
|
||||||
reverse?: ClientReverse | null;
|
reverse?: ClientReverse | null;
|
||||||
routeOutboundTag?: string;
|
|
||||||
routeThroughXray?: boolean;
|
|
||||||
secret?: string;
|
secret?: string;
|
||||||
security: string;
|
security: string;
|
||||||
subId: string;
|
subId: string;
|
||||||
@@ -299,8 +297,6 @@ export interface ClientRecord {
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
reset: number;
|
reset: number;
|
||||||
reverse: unknown;
|
reverse: unknown;
|
||||||
routeOutboundTag: string;
|
|
||||||
routeThroughXray: boolean;
|
|
||||||
secret: string;
|
secret: string;
|
||||||
security: string;
|
security: string;
|
||||||
subId: string;
|
subId: string;
|
||||||
@@ -653,8 +649,6 @@ export interface ServerSettings {
|
|||||||
primaryDns?: string;
|
primaryDns?: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
routeOutboundTag?: string;
|
|
||||||
routeThroughXray?: boolean;
|
|
||||||
s1: number;
|
s1: number;
|
||||||
s2: number;
|
s2: number;
|
||||||
s3: number;
|
s3: number;
|
||||||
|
|||||||
@@ -279,8 +279,6 @@ export const ClientSchema = z.object({
|
|||||||
publicKey: z.string().optional(),
|
publicKey: z.string().optional(),
|
||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||||
routeOutboundTag: z.string().optional(),
|
|
||||||
routeThroughXray: z.boolean().optional(),
|
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
security: z.string(),
|
security: z.string(),
|
||||||
subId: z.string(),
|
subId: z.string(),
|
||||||
@@ -319,8 +317,6 @@ export const ClientRecordSchema = z.object({
|
|||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
reverse: z.unknown(),
|
reverse: z.unknown(),
|
||||||
routeOutboundTag: z.string(),
|
|
||||||
routeThroughXray: z.boolean(),
|
|
||||||
secret: z.string(),
|
secret: z.string(),
|
||||||
security: z.string(),
|
security: z.string(),
|
||||||
subId: z.string(),
|
subId: z.string(),
|
||||||
@@ -692,8 +688,6 @@ export const ServerSettingsSchema = z.object({
|
|||||||
primaryDns: z.string().optional(),
|
primaryDns: z.string().optional(),
|
||||||
privateKey: z.string(),
|
privateKey: z.string(),
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
routeOutboundTag: z.string().optional(),
|
|
||||||
routeThroughXray: z.boolean().optional(),
|
|
||||||
s1: z.number().int(),
|
s1: z.number().int(),
|
||||||
s2: z.number().int(),
|
s2: z.number().int(),
|
||||||
s3: z.number().int(),
|
s3: z.number().int(),
|
||||||
|
|||||||
@@ -298,8 +298,6 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
|
|||||||
ipv6Enabled: false,
|
ipv6Enabled: false,
|
||||||
ipv6Subnet: '',
|
ipv6Subnet: '',
|
||||||
ipv6ExternalInterface: '',
|
ipv6ExternalInterface: '',
|
||||||
routeThroughXray: false,
|
|
||||||
routeOutboundTag: '',
|
|
||||||
jc: 5,
|
jc: 5,
|
||||||
jmin: 10,
|
jmin: 10,
|
||||||
jmax: 50,
|
jmax: 50,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import { FormField } from '@/components/form/rhf';
|
|||||||
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
||||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||||
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
|
||||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
@@ -104,8 +103,6 @@ type Values = ClientFormValues & {
|
|||||||
wgPreSharedKey: string;
|
wgPreSharedKey: string;
|
||||||
wgAllowedIPs: string;
|
wgAllowedIPs: string;
|
||||||
awgForwardedPorts: string;
|
awgForwardedPorts: string;
|
||||||
awgRouteThroughXray: boolean;
|
|
||||||
awgRouteOutboundTag: string;
|
|
||||||
secret: string;
|
secret: string;
|
||||||
adTag: string;
|
adTag: string;
|
||||||
};
|
};
|
||||||
@@ -136,8 +133,6 @@ const EMPTY: Values = {
|
|||||||
wgPreSharedKey: '',
|
wgPreSharedKey: '',
|
||||||
wgAllowedIPs: '',
|
wgAllowedIPs: '',
|
||||||
awgForwardedPorts: '',
|
awgForwardedPorts: '',
|
||||||
awgRouteThroughXray: false,
|
|
||||||
awgRouteOutboundTag: '',
|
|
||||||
secret: '',
|
secret: '',
|
||||||
adTag: '',
|
adTag: '',
|
||||||
};
|
};
|
||||||
@@ -198,8 +193,6 @@ export default function ClientFormModal({
|
|||||||
const subId = useWatch({ control: methods.control, name: 'subId' });
|
const subId = useWatch({ control: methods.control, name: 'subId' });
|
||||||
const auth = useWatch({ control: methods.control, name: 'auth' });
|
const auth = useWatch({ control: methods.control, name: 'auth' });
|
||||||
const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
|
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 limitIp = useWatch({ control: methods.control, name: 'limitIp' });
|
||||||
const {
|
const {
|
||||||
fields: externalLinkFields,
|
fields: externalLinkFields,
|
||||||
@@ -253,8 +246,6 @@ export default function ClientFormModal({
|
|||||||
wgPreSharedKey: client.preSharedKey || '',
|
wgPreSharedKey: client.preSharedKey || '',
|
||||||
wgAllowedIPs: client.allowedIPs || '',
|
wgAllowedIPs: client.allowedIPs || '',
|
||||||
awgForwardedPorts: client.forwardedPorts || '',
|
awgForwardedPorts: client.forwardedPorts || '',
|
||||||
awgRouteThroughXray: !!client.routeThroughXray,
|
|
||||||
awgRouteOutboundTag: client.routeOutboundTag || '',
|
|
||||||
secret: client.secret || '',
|
secret: client.secret || '',
|
||||||
adTag: client.adTag || '',
|
adTag: client.adTag || '',
|
||||||
};
|
};
|
||||||
@@ -570,13 +561,10 @@ export default function ClientFormModal({
|
|||||||
if (allowedIPs.length > 0) {
|
if (allowedIPs.length > 0) {
|
||||||
clientPayload.allowedIPs = allowedIPs;
|
clientPayload.allowedIPs = allowedIPs;
|
||||||
}
|
}
|
||||||
// Port-forwarding and RouteViaXray have no WireGuard equivalent —
|
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
|
||||||
// Xray-native WireGuard has no host-level iptables layer to hang
|
// has no host-level iptables layer to hang per-client DNAT off of.
|
||||||
// per-client DNAT/TPROXY off of.
|
|
||||||
if (showAmneziawg) {
|
if (showAmneziawg) {
|
||||||
clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
|
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" />
|
<Input placeholder="80, 443, 8000-8100" />
|
||||||
</FormField>
|
</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 && (
|
{showMtproto && (
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
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 { ReloadOutlined } from '@ant-design/icons';
|
||||||
import { useFormContext, useWatch } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
|
||||||
|
|
||||||
interface AmneziawgFieldsProps {
|
interface AmneziawgFieldsProps {
|
||||||
awgPubKey: string;
|
awgPubKey: string;
|
||||||
@@ -14,9 +12,6 @@ interface AmneziawgFieldsProps {
|
|||||||
|
|
||||||
export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInboundAwgObfuscation }: AmneziawgFieldsProps) {
|
export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInboundAwgObfuscation }: AmneziawgFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext();
|
|
||||||
const routeThroughXray = useWatch({ control, name: 'settings.server.routeThroughXray' }) as boolean | undefined;
|
|
||||||
const { data: outboundTags } = useOutboundTags();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
|
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
|
||||||
@@ -73,28 +68,6 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou
|
|||||||
>
|
>
|
||||||
<Input placeholder="eth0" />
|
<Input placeholder="eth0" />
|
||||||
</FormField>
|
</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')}>
|
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
|
||||||
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
||||||
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ export const ClientRecordSchema = z.object({
|
|||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
keepAlive: z.number().optional(),
|
keepAlive: z.number().optional(),
|
||||||
forwardedPorts: z.string().optional(),
|
forwardedPorts: z.string().optional(),
|
||||||
routeThroughXray: z.boolean().optional(),
|
|
||||||
routeOutboundTag: z.string().optional(),
|
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
adTag: z.string().optional(),
|
adTag: z.string().optional(),
|
||||||
createdAt: z.number().optional(),
|
createdAt: z.number().optional(),
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ const optionalClearedInt = (schema: z.ZodNumber) =>
|
|||||||
// WireguardClientSchema — the panel's generic ClientRecord already has those
|
// WireguardClientSchema — the panel's generic ClientRecord already has those
|
||||||
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
|
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
|
||||||
// bulk operations, the QR modal and subscriptions all work unmodified — plus
|
// bulk operations, the QR modal and subscriptions all work unmodified — plus
|
||||||
// two AmneziaWG-only additions: forwardedPorts (WireGuard's Xray-native
|
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
|
||||||
// inbound has no host-level iptables layer to hang per-client DNAT off of)
|
// inbound has no host-level iptables layer to hang per-client DNAT off of).
|
||||||
// and routeThroughXray/routeOutboundTag (TPROXYs this peer's traffic into a
|
// Keys are optional on the wire — the backend generates them when absent.
|
||||||
// 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.
|
|
||||||
export const AmneziawgClientSchema = z.object({
|
export const AmneziawgClientSchema = z.object({
|
||||||
privateKey: z.string().optional(),
|
privateKey: z.string().optional(),
|
||||||
publicKey: z.string().optional(),
|
publicKey: z.string().optional(),
|
||||||
@@ -23,8 +20,6 @@ export const AmneziawgClientSchema = z.object({
|
|||||||
allowedIPs: z.array(z.string()).default([]),
|
allowedIPs: z.array(z.string()).default([]),
|
||||||
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
||||||
forwardedPorts: z.string().default(''),
|
forwardedPorts: z.string().default(''),
|
||||||
routeThroughXray: z.boolean().default(false),
|
|
||||||
routeOutboundTag: z.string().default(''),
|
|
||||||
email: z.string().min(1),
|
email: z.string().min(1),
|
||||||
limitIp: z.number().int().min(0).default(0),
|
limitIp: z.number().int().min(0).default(0),
|
||||||
totalGB: 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),
|
ipv6Enabled: z.boolean().default(false),
|
||||||
ipv6Subnet: z.string().default(''),
|
ipv6Subnet: z.string().default(''),
|
||||||
ipv6ExternalInterface: z.string().default(''),
|
ipv6ExternalInterface: z.string().default(''),
|
||||||
routeThroughXray: z.boolean().default(false),
|
|
||||||
routeOutboundTag: z.string().default(''),
|
|
||||||
jc: z.number().int().min(0).default(5),
|
jc: z.number().int().min(0).default(5),
|
||||||
jmin: z.number().int().min(0).default(10),
|
jmin: z.number().int().min(0).default(10),
|
||||||
jmax: z.number().int().min(0).default(50),
|
jmax: z.number().int().min(0).default(50),
|
||||||
|
|||||||
@@ -52,21 +52,12 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
|
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
routeTag := c.RouteOutboundTag
|
|
||||||
if routeTag == "" {
|
|
||||||
routeTag = server.RouteOutboundTag
|
|
||||||
}
|
|
||||||
peers = append(peers, Peer{
|
peers = append(peers, Peer{
|
||||||
Email: c.Email,
|
Email: c.Email,
|
||||||
PublicKey: c.PublicKey,
|
PublicKey: c.PublicKey,
|
||||||
PresharedKey: c.PreSharedKey,
|
PresharedKey: c.PreSharedKey,
|
||||||
AllowedIPs: c.AllowedIPs,
|
AllowedIPs: c.AllowedIPs,
|
||||||
ForwardedPorts: c.ForwardedPorts,
|
ForwardedPorts: c.ForwardedPorts,
|
||||||
// Effective routing: the inbound-wide default routes every peer
|
|
||||||
// unless the peer's own flag already does; the client's own
|
|
||||||
// outbound tag wins when set, else the inbound's default tag.
|
|
||||||
RouteThroughXray: c.RouteThroughXray || server.RouteThroughXray,
|
|
||||||
RouteOutboundTag: routeTag,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(peers) == 0 {
|
if len(peers) == 0 {
|
||||||
@@ -164,19 +155,21 @@ func (inst Instance) peersFingerprint() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hostRulesFingerprint identifies per-peer state that only ever takes effect
|
// hostRulesFingerprint identifies per-peer state that only ever takes effect
|
||||||
// through PostUp/PostDown shell rules — forwarded ports and RouteThroughXray/
|
// through PostUp/PostDown shell rules — forwarded ports, and (unconditionally,
|
||||||
// RouteOutboundTag — rather than the WireGuard peer table itself. It is
|
// for every peer with a usable IPv4 address) the TPROXY rule into this
|
||||||
// checked separately from peersFingerprint because `awg syncconf` never
|
// instance's own Xray bridge — rather than the WireGuard peer table itself.
|
||||||
// re-runs PostUp/PostDown, so a change here must force a full interface
|
// It is checked separately from peersFingerprint because `awg syncconf`
|
||||||
// bounce (ensureRestart) to actually take effect, unlike a key/address-only
|
// never re-runs PostUp/PostDown, so a change here must force a full
|
||||||
// change that syncconf can apply in place.
|
// interface bounce (ensureRestart) to actually take effect, unlike a
|
||||||
|
// key-only change that syncconf can apply in place. Since the TPROXY rule is
|
||||||
|
// now tied to every peer's mere presence (there's no more per-peer opt-in
|
||||||
|
// flag), every peer is included unconditionally: adding, removing, or
|
||||||
|
// re-addressing a peer now also forces a bounce, the same way a
|
||||||
|
// ForwardedPorts-only change always did.
|
||||||
func (inst Instance) hostRulesFingerprint() string {
|
func (inst Instance) hostRulesFingerprint() string {
|
||||||
pairs := make([]string, 0, len(inst.Peers))
|
pairs := make([]string, 0, len(inst.Peers))
|
||||||
for _, p := range inst.Peers {
|
for _, p := range inst.Peers {
|
||||||
if p.ForwardedPorts == "" && !p.RouteThroughXray {
|
pairs = append(pairs, fmt.Sprintf("%s=fwd:%s;ip:%s", p.Email, p.ForwardedPorts, FirstIPv4(p.AllowedIPs)))
|
||||||
continue
|
|
||||||
}
|
|
||||||
pairs = append(pairs, fmt.Sprintf("%s=fwd:%s;route:%v,%s", p.Email, p.ForwardedPorts, p.RouteThroughXray, p.RouteOutboundTag))
|
|
||||||
}
|
}
|
||||||
slices.Sort(pairs)
|
slices.Sort(pairs)
|
||||||
return strings.Join(pairs, "|")
|
return strings.Join(pairs, "|")
|
||||||
@@ -230,10 +223,11 @@ const (
|
|||||||
|
|
||||||
// ensureActionFor decides how to apply a desired instance to the currently
|
// ensureActionFor decides how to apply a desired instance to the currently
|
||||||
// managed interface. A structural change, a host-rules change (forwarded
|
// managed interface. A structural change, a host-rules change (forwarded
|
||||||
// ports or RouteThroughXray/RouteOutboundTag — their iptables rules only
|
// ports, or simply a peer's presence/IP — its always-on TPROXY rule only
|
||||||
// live in PostUp/PostDown), or a down interface all force a restart; a
|
// lives in PostUp/PostDown), or a down interface all force a restart; a
|
||||||
// peers-only change (keys/addresses) is a candidate for an in-place
|
// peers-only change (keys only, no IP/presence change) is a candidate for
|
||||||
// `syncconf`; identical fingerprints on an up interface need nothing.
|
// an in-place `syncconf`; identical fingerprints on an up interface need
|
||||||
|
// nothing.
|
||||||
func ensureActionFor(up bool, curStructFP, curHostRulesFP, curPeersFP, newStructFP, newHostRulesFP, newPeersFP string) ensureAction {
|
func ensureActionFor(up bool, curStructFP, curHostRulesFP, curPeersFP, newStructFP, newHostRulesFP, newPeersFP string) ensureAction {
|
||||||
if !up || curStructFP != newStructFP || curHostRulesFP != newHostRulesFP {
|
if !up || curStructFP != newStructFP || curHostRulesFP != newHostRulesFP {
|
||||||
return ensureRestart
|
return ensureRestart
|
||||||
@@ -516,10 +510,15 @@ func hOrDefault(v, def string) string {
|
|||||||
// rules, proxy_ndp sysctl, and one `ip -6 neigh add proxy` entry per enabled
|
// rules, proxy_ndp sysctl, and one `ip -6 neigh add proxy` entry per enabled
|
||||||
// peer with an IPv6 address, so upstream routers see each client's IPv6 as
|
// peer with an IPv6 address, so upstream routers see each client's IPv6 as
|
||||||
// directly reachable on the LAN without NAT66. Also emits DNAT+FORWARD rules
|
// directly reachable on the LAN without NAT66. Also emits DNAT+FORWARD rules
|
||||||
// for each enabled peer with a non-empty ForwardedPorts spec, and — for each
|
// for each enabled peer with a non-empty ForwardedPorts spec, and —
|
||||||
// peer with RouteThroughXray set — a mangle-table TPROXY rule redirecting
|
// unconditionally, for every peer — a mangle-table TPROXY rule redirecting
|
||||||
// that peer's traffic into the shared Xray bridge (see EgressPort), plus the
|
// that peer's traffic into this instance's own Xray bridge (see
|
||||||
// one-time policy route TPROXY needs to deliver it there.
|
// EgressPortForInbound), plus the one-time policy route TPROXY needs to
|
||||||
|
// deliver it there. There is no per-peer or per-inbound opt-in: the bridge
|
||||||
|
// is always present by default, and it is entirely up to the admin's own
|
||||||
|
// Xray Routing rules (targeting this inbound's own tag, which
|
||||||
|
// injectAmneziawgEgress reuses for the bridge) whether that traffic ever
|
||||||
|
// actually goes anywhere beyond Xray's default routing.
|
||||||
func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
|
func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
|
||||||
iface := inst.InterfaceName
|
iface := inst.InterfaceName
|
||||||
up := []string{
|
up := []string{
|
||||||
@@ -574,28 +573,25 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
|
|||||||
down = append(down, portForwardLines("-D", ext, iface, clientIP, p.Email, p.ForwardedPorts)...)
|
down = append(down, portForwardLines("-D", ext, iface, clientIP, p.Email, p.ForwardedPorts)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
routedAny := false
|
egressPort := EgressPortForInbound(inst.Id)
|
||||||
|
anyPeerTproxied := false
|
||||||
for _, p := range inst.Peers {
|
for _, p := range inst.Peers {
|
||||||
if !p.RouteThroughXray {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
clientIP := FirstIPv4(p.AllowedIPs)
|
clientIP := FirstIPv4(p.AllowedIPs)
|
||||||
if clientIP == "" {
|
if clientIP == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
up = append(up, routeEgressLines("-A", iface, clientIP, p.Email)...)
|
up = append(up, routeEgressLines("-A", iface, clientIP, p.Email, egressPort)...)
|
||||||
down = append(down, routeEgressLines("-D", iface, clientIP, p.Email)...)
|
down = append(down, routeEgressLines("-D", iface, clientIP, p.Email, egressPort)...)
|
||||||
routedAny = true
|
anyPeerTproxied = true
|
||||||
}
|
}
|
||||||
if routedAny {
|
if anyPeerTproxied {
|
||||||
// The fwmark->table->local-everywhere policy route is what lets TPROXY
|
// The fwmark->table->local-everywhere policy route is what lets TPROXY
|
||||||
// deliver a routed peer's packets to the shared Xray bridge even though
|
// deliver a peer's packets to this instance's own Xray bridge even
|
||||||
// their destination is never one of this host's own addresses. It is
|
// though their destination is never one of this host's own addresses.
|
||||||
// system-wide, not interface-specific, so — like the IPv6-forwarding
|
// It is system-wide, not interface-specific, so — like the
|
||||||
// sysctl above — it is added idempotently here and never torn down in
|
// IPv6-forwarding sysctl above — it is added idempotently here and
|
||||||
// PostDown; a second AmneziaWG instance with its own routed peers must
|
// never torn down in PostDown; a second AmneziaWG instance must find
|
||||||
// find it already in place, not race to remove what the first still
|
// it already in place, not race to remove what the first still needs.
|
||||||
// needs.
|
|
||||||
up = append(up,
|
up = append(up,
|
||||||
fmt.Sprintf("ip rule add fwmark %#x lookup %d 2>/dev/null || true", EgressFwmark, EgressTable),
|
fmt.Sprintf("ip rule add fwmark %#x lookup %d 2>/dev/null || true", EgressFwmark, EgressTable),
|
||||||
fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable),
|
fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable),
|
||||||
|
|||||||
@@ -98,79 +98,6 @@ func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInstanceFromInboundComputesEffectiveRouting(t *testing.T) {
|
|
||||||
serverRouted := validServer()
|
|
||||||
serverRouted.RouteThroughXray = true
|
|
||||||
serverRouted.RouteOutboundTag = "warp"
|
|
||||||
|
|
||||||
t.Run("server default routes a peer with no flag of its own", func(t *testing.T) {
|
|
||||||
settings := mkInboundSettings(t, serverRouted, []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected a usable instance")
|
|
||||||
}
|
|
||||||
p := inst.Peers[0]
|
|
||||||
if !p.RouteThroughXray || p.RouteOutboundTag != "warp" {
|
|
||||||
t.Fatalf("expected the peer to inherit the server default, got %+v", p)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("client's own tag overrides the server default", func(t *testing.T) {
|
|
||||||
settings := mkInboundSettings(t, serverRouted, []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "direct"},
|
|
||||||
})
|
|
||||||
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected a usable instance")
|
|
||||||
}
|
|
||||||
p := inst.Peers[0]
|
|
||||||
if !p.RouteThroughXray || p.RouteOutboundTag != "direct" {
|
|
||||||
t.Fatalf("expected the client's own tag to win, got %+v", p)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("client can opt in on its own when the server default is off", func(t *testing.T) {
|
|
||||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
|
||||||
})
|
|
||||||
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected a usable instance")
|
|
||||||
}
|
|
||||||
var a, b Peer
|
|
||||||
for _, p := range inst.Peers {
|
|
||||||
switch p.Email {
|
|
||||||
case "a@x":
|
|
||||||
a = p
|
|
||||||
case "b@x":
|
|
||||||
b = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !a.RouteThroughXray || a.RouteOutboundTag != "direct" {
|
|
||||||
t.Fatalf("a@x opted in on its own, expected it routed to direct, got %+v", a)
|
|
||||||
}
|
|
||||||
if b.RouteThroughXray {
|
|
||||||
t.Fatalf("b@x has no flag of its own and the server default is off, expected unrouted, got %+v", b)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("neither level set means not routed", func(t *testing.T) {
|
|
||||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected a usable instance")
|
|
||||||
}
|
|
||||||
if inst.Peers[0].RouteThroughXray || inst.Peers[0].RouteOutboundTag != "" {
|
|
||||||
t.Fatalf("expected no routing at all, got %+v", inst.Peers[0])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerAddress(t *testing.T) {
|
func TestServerAddress(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
subnet string
|
subnet string
|
||||||
@@ -262,7 +189,7 @@ func TestEnsureActionFor(t *testing.T) {
|
|||||||
{"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart},
|
{"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart},
|
||||||
{"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart},
|
{"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart},
|
||||||
{"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart},
|
{"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart},
|
||||||
{"route-through-xray change forces restart", true, "s", "route:false", "p", "s", "route:true", "p", ensureRestart},
|
{"peer-ip change (its TPROXY rule) forces restart", true, "s", "ip:old", "p", "s", "ip:new", "p", ensureRestart},
|
||||||
{"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload},
|
{"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload},
|
||||||
{"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop},
|
{"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop},
|
||||||
}
|
}
|
||||||
@@ -276,14 +203,14 @@ func TestEnsureActionFor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostRulesFingerprintCoversForwardedPortsAndRouting(t *testing.T) {
|
func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
|
||||||
a := baseInstance()
|
a := baseInstance()
|
||||||
b := baseInstance()
|
b := baseInstance()
|
||||||
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
|
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
|
||||||
t.Fatal("identical instances must produce the same host-rules fingerprint")
|
t.Fatal("identical instances must produce the same host-rules fingerprint")
|
||||||
}
|
}
|
||||||
if a.hostRulesFingerprint() != "" {
|
if a.hostRulesFingerprint() == "" {
|
||||||
t.Fatal("peers with no forwarded ports and no routing must produce an empty fingerprint")
|
t.Fatal("every peer always gets a TPROXY rule now, so the fingerprint must never be empty when peers exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
forwarded := baseInstance()
|
forwarded := baseInstance()
|
||||||
@@ -292,17 +219,16 @@ func TestHostRulesFingerprintCoversForwardedPortsAndRouting(t *testing.T) {
|
|||||||
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
|
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
routed := baseInstance()
|
reIPed := baseInstance()
|
||||||
routed.Peers[0].RouteThroughXray = true
|
reIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
||||||
if a.hostRulesFingerprint() == routed.hostRulesFingerprint() {
|
if a.hostRulesFingerprint() == reIPed.hostRulesFingerprint() {
|
||||||
t.Fatal("enabling RouteThroughXray must change the host-rules fingerprint")
|
t.Fatal("changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP")
|
||||||
}
|
}
|
||||||
|
|
||||||
routedOtherTag := baseInstance()
|
fewer := baseInstance()
|
||||||
routedOtherTag.Peers[0].RouteThroughXray = true
|
fewer.Peers = fewer.Peers[:1]
|
||||||
routedOtherTag.Peers[0].RouteOutboundTag = "warp"
|
if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() {
|
||||||
if routed.hostRulesFingerprint() == routedOtherTag.hostRulesFingerprint() {
|
t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer TPROXY rule is needed")
|
||||||
t.Fatal("changing RouteOutboundTag must change the host-rules fingerprint")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +247,7 @@ func TestRouteEgressComment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouteEgressLines(t *testing.T) {
|
func TestRouteEgressLines(t *testing.T) {
|
||||||
up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x")
|
up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x", 63101)
|
||||||
if len(up) != 2 {
|
if len(up) != 2 {
|
||||||
t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up)
|
t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up)
|
||||||
}
|
}
|
||||||
@@ -333,7 +259,7 @@ func TestRouteEgressLines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
found = true
|
found = true
|
||||||
if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") ||
|
if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") ||
|
||||||
!strings.Contains(l, fmt.Sprintf("--on-port %d", EgressPort)) ||
|
!strings.Contains(l, "--on-port 63101") ||
|
||||||
!strings.Contains(l, "--on-ip 127.0.0.1") ||
|
!strings.Contains(l, "--on-ip 127.0.0.1") ||
|
||||||
!strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) ||
|
!strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) ||
|
||||||
!strings.Contains(l, "-A PREROUTING") {
|
!strings.Contains(l, "-A PREROUTING") {
|
||||||
@@ -348,27 +274,35 @@ func TestRouteEgressLines(t *testing.T) {
|
|||||||
t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0])
|
t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x")
|
down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x", 63101)
|
||||||
if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") {
|
if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") {
|
||||||
t.Fatalf("expected symmetric -D lines, got %v", down)
|
t.Fatalf("expected symmetric -D lines, got %v", down)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := routeEgressLines("-A", "awg1", "", "a@x"); got != nil {
|
if got := routeEgressLines("-A", "awg1", "", "a@x", 63101); got != nil {
|
||||||
t.Errorf("empty clientIP must yield no lines, got %v", got)
|
t.Errorf("empty clientIP must yield no lines, got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultPostUpDownEmitsTproxyOnlyForRoutedPeers(t *testing.T) {
|
func TestEgressPortForInbound(t *testing.T) {
|
||||||
inst := baseInstance()
|
if got := EgressPortForInbound(1); got != EgressBasePort+1 {
|
||||||
inst.Peers[0].RouteThroughXray = true
|
t.Errorf("EgressPortForInbound(1) = %d, want %d", got, EgressBasePort+1)
|
||||||
inst.Peers[0].RouteOutboundTag = "warp"
|
}
|
||||||
|
if EgressPortForInbound(1) == EgressPortForInbound(2) {
|
||||||
|
t.Fatal("different inbound ids must derive different ports")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPostUpDownEmitsTproxyForEveryPeer(t *testing.T) {
|
||||||
|
inst := baseInstance() // two peers, a@x and b@x, no opt-in flag exists anymore
|
||||||
up, down := defaultPostUpDown(inst, "eth0")
|
up, down := defaultPostUpDown(inst, "eth0")
|
||||||
|
|
||||||
if !strings.Contains(up, "TPROXY") || !strings.Contains(up, fmt.Sprintf("--on-port %d", EgressPort)) {
|
wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id))
|
||||||
t.Errorf("expected a TPROXY rule for the routed peer in PostUp, got:\n%s", up)
|
if !strings.Contains(up, "TPROXY") || !strings.Contains(up, wantPort) {
|
||||||
|
t.Errorf("expected TPROXY rules targeting this instance's own bridge port in PostUp, got:\n%s", up)
|
||||||
}
|
}
|
||||||
if !strings.Contains(down, "TPROXY") {
|
if !strings.Contains(down, "TPROXY") {
|
||||||
t.Errorf("expected a matching TPROXY removal in PostDown, got:\n%s", down)
|
t.Errorf("expected matching TPROXY removals in PostDown, got:\n%s", down)
|
||||||
}
|
}
|
||||||
if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) {
|
if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) {
|
||||||
t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up)
|
t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up)
|
||||||
@@ -376,16 +310,15 @@ func TestDefaultPostUpDownEmitsTproxyOnlyForRoutedPeers(t *testing.T) {
|
|||||||
if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") {
|
if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") {
|
||||||
t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it")
|
t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it")
|
||||||
}
|
}
|
||||||
// Peer b@x has no routing enabled: only the one routed peer's tcp+udp
|
// Both peers always get TPROXY'd now, no opt-in: 2 peers * 2 protocols.
|
||||||
// pair should appear.
|
if got := strings.Count(up, "TPROXY"); got != 4 {
|
||||||
if got := strings.Count(up, "TPROXY"); got != 2 {
|
t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up)
|
||||||
t.Errorf("expected exactly 2 TPROXY lines (tcp+udp for the one routed peer), got %d in:\n%s", got, up)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
none := baseInstance() // no peer opts in
|
none := Instance{Id: 2, InterfaceName: "awg2"} // no peers at all
|
||||||
upNone, _ := defaultPostUpDown(none, "eth0")
|
upNone, _ := defaultPostUpDown(none, "eth0")
|
||||||
if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") {
|
if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") {
|
||||||
t.Errorf("an instance with no routed peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone)
|
t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,25 +5,35 @@ import (
|
|||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EgressPort is the loopback port of the single Xray dokodemo-door bridge
|
// EgressBasePort is the first loopback port used for an AmneziaWG inbound's
|
||||||
// every RouteThroughXray peer's TPROXY'd traffic lands on, shared across
|
// own Xray TPROXY bridge. Every enabled inbound gets one bridge, always
|
||||||
// every AmneziaWG instance. defaultPostUpDown's TPROXY rules target it, and
|
// present by default (no opt-in flag): defaultPostUpDown's TPROXY rules
|
||||||
// internal/web/service's injectAmneziawgEgress listens on it. A single
|
// redirect every peer's traffic there unconditionally, and
|
||||||
// shared bridge — rather than one per peer — keeps this a plain constant
|
// internal/web/service's injectAmneziawgEgress creates the matching
|
||||||
// instead of state two independent reconcile loops would otherwise have to
|
// dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so
|
||||||
// agree on at runtime; per-peer distinction happens downstream, in Xray's
|
// it's already selectable in the panel's stock Routing page (the same
|
||||||
// own router, matched by each peer's TPROXY-preserved tunnel source IP (see
|
// mechanism that already makes an mtproto inbound's own bridge routable
|
||||||
// EgressTag).
|
// there — see injectMtprotoEgress). Whether — and where — that traffic
|
||||||
const EgressPort = 63100
|
// actually goes anywhere beyond Xray's default routing is entirely up to
|
||||||
|
// whatever rules the admin adds on that page; this package and
|
||||||
|
// injectAmneziawgEgress never generate a routing rule themselves.
|
||||||
|
//
|
||||||
|
// EgressPortForInbound derives each inbound's own port deterministically
|
||||||
|
// from its id, so the two independent reconcile loops (this package's
|
||||||
|
// PostUp generator and the Xray-config generator, in a different package)
|
||||||
|
// never have to agree on a runtime-negotiated value.
|
||||||
|
const EgressBasePort = 63100
|
||||||
|
|
||||||
// EgressTag is the tag of that shared bridge inbound in the generated Xray
|
// EgressPortForInbound returns the loopback port of one AmneziaWG inbound's
|
||||||
// config. Routing rules that distinguish peers match against it as their
|
// own Xray TPROXY bridge.
|
||||||
// inboundTag.
|
func EgressPortForInbound(inboundID int) int {
|
||||||
const EgressTag = "amneziawg-egress"
|
return EgressBasePort + inboundID
|
||||||
|
}
|
||||||
|
|
||||||
// EgressFwmark and EgressTable are the fwmark and policy-routing table
|
// EgressFwmark and EgressTable are the fwmark and policy-routing table
|
||||||
// TPROXY needs to deliver a routed peer's packets to a local socket even
|
// TPROXY needs to deliver a peer's packets to a local socket even though
|
||||||
// though their destination is never one of this host's own addresses.
|
// their destination is never one of this host's own addresses. Shared by
|
||||||
|
// every AmneziaWG instance's bridge — only the port differs per instance.
|
||||||
// Chosen to be distinctive; if either happens to collide with something else
|
// Chosen to be distinctive; if either happens to collide with something else
|
||||||
// already using fwmarks/routing tables on the host, change the values here —
|
// already using fwmarks/routing tables on the host, change the values here —
|
||||||
// nothing outside this package and its own PostUp/PostDown output depends on
|
// nothing outside this package and its own PostUp/PostDown output depends on
|
||||||
@@ -50,10 +60,12 @@ func routeEgressComment(email string) string {
|
|||||||
|
|
||||||
// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table
|
// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table
|
||||||
// TPROXY lines that redirect one peer's traffic — matched by its tunnel
|
// TPROXY lines that redirect one peer's traffic — matched by its tunnel
|
||||||
// source IP, arriving on tunIface — into the shared Xray bridge. Both TCP and
|
// source IP, arriving on tunIface — into that instance's own Xray bridge on
|
||||||
// UDP are covered since RouteThroughXray means "this peer's traffic", not a
|
// port. Both TCP and UDP are covered since every peer's whole traffic is
|
||||||
// specific protocol or port. Returns nil when clientIP is empty.
|
// meant to reach the bridge, not just a specific protocol or port; which
|
||||||
func routeEgressLines(action, tunIface, clientIP, email string) []string {
|
// outbound (if any) it then takes is entirely up to the admin's own Routing
|
||||||
|
// rules. Returns nil when clientIP is empty.
|
||||||
|
func routeEgressLines(action, tunIface, clientIP, email string, port int) []string {
|
||||||
clientIP = stripCIDRMask(clientIP)
|
clientIP = stripCIDRMask(clientIP)
|
||||||
if clientIP == "" {
|
if clientIP == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -63,7 +75,7 @@ func routeEgressLines(action, tunIface, clientIP, email string) []string {
|
|||||||
for _, proto := range []string{"tcp", "udp"} {
|
for _, proto := range []string{"tcp", "udp"} {
|
||||||
lines = append(lines, fmt.Sprintf(
|
lines = append(lines, fmt.Sprintf(
|
||||||
"iptables -t mangle %s PREROUTING -i %s -s %s -p %s -m comment --comment %s -j TPROXY --on-port %d --on-ip 127.0.0.1 --tproxy-mark %#x/%#x",
|
"iptables -t mangle %s PREROUTING -i %s -s %s -p %s -m comment --comment %s -j TPROXY --on-port %d --on-ip 127.0.0.1 --tproxy-mark %#x/%#x",
|
||||||
action, tunIface, clientIP, proto, comment, EgressPort, EgressFwmark, EgressFwmark,
|
action, tunIface, clientIP, proto, comment, port, EgressFwmark, EgressFwmark,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
|
|||||||
@@ -38,25 +38,6 @@ type Peer struct {
|
|||||||
// ForwardedPorts is a raw, user-supplied port list ("80, 443, 8000-8100")
|
// ForwardedPorts is a raw, user-supplied port list ("80, 443, 8000-8100")
|
||||||
// DNAT'd to this peer's tunnel address. Empty means no port-forwarding.
|
// DNAT'd to this peer's tunnel address. Empty means no port-forwarding.
|
||||||
ForwardedPorts string
|
ForwardedPorts string
|
||||||
|
|
||||||
// RouteThroughXray and RouteOutboundTag are this peer's EFFECTIVE routing
|
|
||||||
// decision — already resolved by InstanceFromInbound from the per-client
|
|
||||||
// setting OR'd with the inbound-wide ServerSettings.RouteThroughXray
|
|
||||||
// default (and the client's own RouteOutboundTag falling back to the
|
|
||||||
// server's when the client didn't set one). Callers never need to look at
|
|
||||||
// the raw client/server fields separately.
|
|
||||||
//
|
|
||||||
// When true, TPROXYs this peer's traffic (matched by its tunnel source
|
|
||||||
// IP) into the single shared loopback Xray dokodemo-door bridge (see
|
|
||||||
// EgressPort in route_egress.go) instead of letting it NAT straight out
|
|
||||||
// through ExternalInterface. All routed peers, across every AmneziaWG
|
|
||||||
// instance, share that one bridge and one fwmark/policy-route pair; the
|
|
||||||
// per-peer distinction happens downstream in Xray's own router, which the
|
|
||||||
// web service feeds a source-IP-matched rule per peer. RouteOutboundTag
|
|
||||||
// is the Xray outbound/balancer tag that rule targets; empty means
|
|
||||||
// Xray's default routing decides.
|
|
||||||
RouteThroughXray bool
|
|
||||||
RouteOutboundTag string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance is the desired runtime configuration of one AmneziaWG inbound: a
|
// Instance is the desired runtime configuration of one AmneziaWG inbound: a
|
||||||
@@ -120,17 +101,6 @@ type ServerSettings struct {
|
|||||||
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
|
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
|
||||||
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
|
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
|
||||||
|
|
||||||
// RouteThroughXray, when true, is the inbound-wide default: every peer
|
|
||||||
// TPROXYs into Xray unless it explicitly turns its own RouteThroughXray
|
|
||||||
// off... except a plain bool can't distinguish "peer left it unset" from
|
|
||||||
// "peer explicitly opted out", so in practice this ORs with each peer's
|
|
||||||
// own flag (see Peer.RouteThroughXray) — turning this on routes every
|
|
||||||
// peer, turning it off still lets individual peers opt in on their own.
|
|
||||||
// RouteOutboundTag is the default outbound/balancer tag used when a
|
|
||||||
// routed peer didn't set its own; empty means Xray's default routing.
|
|
||||||
RouteThroughXray bool `json:"routeThroughXray,omitempty"`
|
|
||||||
RouteOutboundTag string `json:"routeOutboundTag,omitempty"`
|
|
||||||
|
|
||||||
// Obfuscation20's fields, repeated flat (not embedded) rather than
|
// Obfuscation20's fields, repeated flat (not embedded) rather than
|
||||||
// nested under their own key: encoding/json would happily inline an
|
// nested under their own key: encoding/json would happily inline an
|
||||||
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
|
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
|
||||||
|
|||||||
@@ -797,66 +797,62 @@ type ClientReverse struct {
|
|||||||
|
|
||||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id,omitempty"` // Unique client identifier
|
ID string `json:"id,omitempty"` // Unique client identifier
|
||||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||||
Password string `json:"password,omitempty"` // Client password
|
Password string `json:"password,omitempty"` // Client password
|
||||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||||
PrivateKey string `json:"privateKey,omitempty"`
|
PrivateKey string `json:"privateKey,omitempty"`
|
||||||
PublicKey string `json:"publicKey,omitempty"`
|
PublicKey string `json:"publicKey,omitempty"`
|
||||||
AllowedIPs []string `json:"allowedIPs,omitempty"`
|
AllowedIPs []string `json:"allowedIPs,omitempty"`
|
||||||
PreSharedKey string `json:"preSharedKey,omitempty"`
|
PreSharedKey string `json:"preSharedKey,omitempty"`
|
||||||
KeepAlive int `json:"keepAlive,omitempty"`
|
KeepAlive int `json:"keepAlive,omitempty"`
|
||||||
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
|
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
|
||||||
RouteThroughXray bool `json:"routeThroughXray,omitempty"` // AmneziaWG: TPROXY this peer's traffic into Xray
|
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
||||||
RouteOutboundTag string `json:"routeOutboundTag,omitempty"` // Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing
|
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
||||||
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
Email string `json:"email"` // Client email identifier
|
||||||
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
Email string `json:"email"` // Client email identifier
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
||||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
Comment string `json:"comment" form:"comment"` // Client comment
|
||||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||||
Comment string `json:"comment" form:"comment"` // Client comment
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientRecord struct {
|
type ClientRecord struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Auth string `json:"auth"`
|
Auth string `json:"auth"`
|
||||||
Flow string `json:"flow"`
|
Flow string `json:"flow"`
|
||||||
Security string `json:"security"`
|
Security string `json:"security"`
|
||||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||||
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
||||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||||
RouteThroughXray bool `json:"routeThroughXray" gorm:"column:wg_route_through_xray;default:false"`
|
Secret string `json:"secret" gorm:"column:secret"`
|
||||||
RouteOutboundTag string `json:"routeOutboundTag" gorm:"column:wg_route_outbound_tag"`
|
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||||
Secret string `json:"secret" gorm:"column:secret"`
|
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||||
Enable bool `json:"enable" gorm:"default:true"`
|
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
Comment string `json:"comment"`
|
||||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
Reset int `json:"reset" gorm:"default:0"`
|
||||||
Comment string `json:"comment"`
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
Reset int `json:"reset" gorm:"default:0"`
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ClientRecord) TableName() string { return "clients" }
|
func (ClientRecord) TableName() string { return "clients" }
|
||||||
@@ -1022,16 +1018,14 @@ func (c *Client) ToRecord() *ClientRecord {
|
|||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
UpdatedAt: c.UpdatedAt,
|
UpdatedAt: c.UpdatedAt,
|
||||||
|
|
||||||
PrivateKey: c.PrivateKey,
|
PrivateKey: c.PrivateKey,
|
||||||
PublicKey: c.PublicKey,
|
PublicKey: c.PublicKey,
|
||||||
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
||||||
PreSharedKey: c.PreSharedKey,
|
PreSharedKey: c.PreSharedKey,
|
||||||
KeepAlive: c.KeepAlive,
|
KeepAlive: c.KeepAlive,
|
||||||
ForwardedPorts: c.ForwardedPorts,
|
ForwardedPorts: c.ForwardedPorts,
|
||||||
RouteThroughXray: c.RouteThroughXray,
|
Secret: c.Secret,
|
||||||
RouteOutboundTag: c.RouteOutboundTag,
|
AdTag: c.AdTag,
|
||||||
Secret: c.Secret,
|
|
||||||
AdTag: c.AdTag,
|
|
||||||
}
|
}
|
||||||
if c.Reverse != nil {
|
if c.Reverse != nil {
|
||||||
if b, err := json.Marshal(c.Reverse); err == nil {
|
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||||
@@ -1078,16 +1072,14 @@ func (r *ClientRecord) ToClient() *Client {
|
|||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
UpdatedAt: r.UpdatedAt,
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
|
||||||
PrivateKey: r.PrivateKey,
|
PrivateKey: r.PrivateKey,
|
||||||
PublicKey: r.PublicKey,
|
PublicKey: r.PublicKey,
|
||||||
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
||||||
PreSharedKey: r.PreSharedKey,
|
PreSharedKey: r.PreSharedKey,
|
||||||
KeepAlive: r.KeepAlive,
|
KeepAlive: r.KeepAlive,
|
||||||
ForwardedPorts: r.ForwardedPorts,
|
ForwardedPorts: r.ForwardedPorts,
|
||||||
RouteThroughXray: r.RouteThroughXray,
|
Secret: r.Secret,
|
||||||
RouteOutboundTag: r.RouteOutboundTag,
|
AdTag: r.AdTag,
|
||||||
Secret: r.Secret,
|
|
||||||
AdTag: r.AdTag,
|
|
||||||
}
|
}
|
||||||
if r.Reverse != "" {
|
if r.Reverse != "" {
|
||||||
var rev ClientReverse
|
var rev ClientReverse
|
||||||
@@ -1264,18 +1256,6 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
|||||||
existing.ForwardedPorts = incoming.ForwardedPorts
|
existing.ForwardedPorts = incoming.ForwardedPorts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if existing.RouteThroughXray != incoming.RouteThroughXray && incoming.RouteThroughXray {
|
|
||||||
if incomingNewer || !existing.RouteThroughXray {
|
|
||||||
keep("routeThroughXray", existing.RouteThroughXray, incoming.RouteThroughXray, true)
|
|
||||||
existing.RouteThroughXray = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if existing.RouteOutboundTag != incoming.RouteOutboundTag && incoming.RouteOutboundTag != "" {
|
|
||||||
if incomingNewer || existing.RouteOutboundTag == "" {
|
|
||||||
keep("routeOutboundTag", existing.RouteOutboundTag, incoming.RouteOutboundTag, incoming.RouteOutboundTag)
|
|
||||||
existing.RouteOutboundTag = incoming.RouteOutboundTag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if existing.Comment != incoming.Comment && incoming.Comment != "" {
|
if existing.Comment != incoming.Comment && incoming.Comment != "" {
|
||||||
if incomingNewer || existing.Comment == "" {
|
if incomingNewer || existing.Comment == "" {
|
||||||
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
||||||
|
|||||||
@@ -645,35 +645,36 @@ const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedi
|
|||||||
// socket.
|
// socket.
|
||||||
const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}`
|
const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}`
|
||||||
|
|
||||||
// amneziawgRouteRule is one routed AmneziaWG peer — gathered from every
|
// injectAmneziawgEgress gives every enabled AmneziaWG inbound with at least
|
||||||
// enabled AmneziaWG inbound's client list — that injectAmneziawgEgress turns
|
// one qualifying peer its own loopback dokodemo-door bridge — tagged with
|
||||||
// into a source-matched routing rule against the shared bridge.
|
// that inbound's own real tag, so it's already selectable in the panel's
|
||||||
type amneziawgRouteRule struct {
|
// stock Routing page's inbound-tag picker, exactly the way an mtproto
|
||||||
sourceIP string
|
// inbound's own bridge already is (see injectMtprotoEgress): the picker's
|
||||||
outboundTag string
|
// tag list comes from InboundService.GetInboundTags(), a plain,
|
||||||
}
|
// protocol-blind SELECT over every inbound row's tag, so reusing a real
|
||||||
|
// inbound's own tag needs no dedicated UI plumbing at all.
|
||||||
// injectAmneziawgEgress wires every effectively-routed AmneziaWG peer, across
|
|
||||||
// every enabled AmneziaWG inbound, into the generated config through one
|
|
||||||
// loopback dokodemo-door bridge shared by all of them (tag
|
|
||||||
// amneziawg.EgressTag, port amneziawg.EgressPort) rather than one bridge per
|
|
||||||
// peer: the TPROXY rule that redirects a peer's traffic there is per-peer
|
|
||||||
// (see internal/amneziawg's defaultPostUpDown), but distinguishing which peer
|
|
||||||
// a given connection came from — and picking its own outbound — happens
|
|
||||||
// here, in Xray's own router, matched against the TPROXY-preserved source
|
|
||||||
// IP. Reuses amneziawg.InstanceFromInbound for the peer list (rather than
|
|
||||||
// re-parsing Settings itself) specifically so "is this peer routed, and to
|
|
||||||
// which outbound" is computed in exactly one place — the same place the
|
|
||||||
// kernel-side TPROXY rules read it from — and can never quietly diverge
|
|
||||||
// between the two independent reconcile loops.
|
|
||||||
//
|
//
|
||||||
// Mirrors injectMtprotoEgress/injectPanelEgress: an invalid or missing
|
// Every peer's traffic always lands on the bridge — internal/amneziawg's
|
||||||
// outbound target skips that one peer's rule, not the whole bridge; the
|
// defaultPostUpDown TPROXYs it there unconditionally, there is no per-peer
|
||||||
// bridge itself is skipped entirely when no peer needs it or its tag is
|
// or per-inbound opt-in flag — but this function never generates a routing
|
||||||
// already taken by a real inbound. Generated state is hot-appliable and
|
// rule of its own. Whether that traffic goes anywhere beyond Xray's default
|
||||||
// never modifies the stored template or restarts the core.
|
// routing is entirely up to whatever rules the admin adds through that same
|
||||||
|
// stock Routing page (inboundTag + an optional sourceIP to target one
|
||||||
|
// specific peer + outboundTag, exactly like routing any other protocol).
|
||||||
|
//
|
||||||
|
// An inbound is skipped, individually, when its own tag is already taken by
|
||||||
|
// another config entry — mirroring injectMtprotoEgress/injectPanelEgress's
|
||||||
|
// own defensive check, even though a real collision shouldn't be possible
|
||||||
|
// (inbound tags are unique, and the main GenXrayInboundConfig loop already
|
||||||
|
// excludes mtproto/amneziawg inbounds from ever claiming their own tag
|
||||||
|
// there). Generated state is hot-appliable and never modifies the stored
|
||||||
|
// template or restarts the core.
|
||||||
func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||||
var rules []amneziawgRouteRule
|
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
|
||||||
|
for i := range cfg.InboundConfigs {
|
||||||
|
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
|
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
|
||||||
continue
|
continue
|
||||||
@@ -682,80 +683,30 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
hasQualifyingPeer := false
|
||||||
for _, p := range inst.Peers {
|
for _, p := range inst.Peers {
|
||||||
if !p.RouteThroughXray {
|
if amneziawg.FirstIPv4(p.AllowedIPs) != "" {
|
||||||
continue
|
hasQualifyingPeer = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
sourceIP := amneziawg.FirstIPv4(p.AllowedIPs)
|
|
||||||
if sourceIP == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: p.RouteOutboundTag})
|
|
||||||
}
|
}
|
||||||
}
|
if !hasQualifyingPeer {
|
||||||
if len(rules) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range cfg.InboundConfigs {
|
|
||||||
if cfg.InboundConfigs[i].Tag == amneziawg.EgressTag {
|
|
||||||
logger.Warning("amneziawg egress: inbound tag [", amneziawg.EgressTag, "] already present in generated config, skipping bridge")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
routing := map[string]any{}
|
|
||||||
if len(cfg.RouterConfig) > 0 {
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
logger.Warning("amneziawg egress: routing section is unparsable, skipping injection:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
existingRules, _ := routing["rules"].([]any)
|
|
||||||
newRules := make([]any, 0, len(rules))
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.outboundTag == "" {
|
|
||||||
// No chosen outbound: the peer's traffic still lands on the
|
|
||||||
// bridge (it's already TPROXY'd there at the kernel level) but
|
|
||||||
// with no rule of its own it falls through to whatever the rest
|
|
||||||
// of the router decides, matching injectMtprotoEgress's
|
|
||||||
// no-outbound-selected behavior.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !routingTargetExists(routing, cfg.OutboundConfigs, r.outboundTag) {
|
if _, taken := existingTags[inbound.Tag]; taken {
|
||||||
logger.Warning("amneziawg egress: target tag [", r.outboundTag, "] not found, skipping rule for [", r.sourceIP, "]")
|
logger.Warning("amneziawg egress: inbound tag [", inbound.Tag, "] already present in generated config, skipping its bridge")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rule := map[string]any{
|
existingTags[inbound.Tag] = struct{}{}
|
||||||
"type": "field",
|
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||||
"inboundTag": []any{amneziawg.EgressTag},
|
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||||
"source": []any{r.sourceIP + "/32"},
|
Port: amneziawg.EgressPortForInbound(inbound.Id),
|
||||||
}
|
Protocol: "dokodemo-door",
|
||||||
if routingTagIsBalancer(routing, r.outboundTag) {
|
Settings: json_util.RawMessage(amneziawgEgressDokodemoSettings),
|
||||||
rule["balancerTag"] = r.outboundTag
|
StreamSettings: json_util.RawMessage(amneziawgEgressStreamSettings),
|
||||||
} else {
|
Tag: inbound.Tag,
|
||||||
rule["outboundTag"] = r.outboundTag
|
})
|
||||||
}
|
|
||||||
newRules = append(newRules, rule)
|
|
||||||
}
|
}
|
||||||
if len(newRules) > 0 {
|
|
||||||
routing["rules"] = append(newRules, existingRules...)
|
|
||||||
newRouting, err := json.Marshal(routing)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("amneziawg egress: failed to rebuild routing section, skipping injection:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(newRouting)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
|
||||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
|
||||||
Port: amneziawg.EgressPort,
|
|
||||||
Protocol: "dokodemo-door",
|
|
||||||
Settings: json_util.RawMessage(amneziawgEgressDokodemoSettings),
|
|
||||||
StreamSettings: json_util.RawMessage(amneziawgEgressStreamSettings),
|
|
||||||
Tag: amneziawg.EgressTag,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeSubscriptionOutbounds appends the subscription outbounds to the
|
// mergeSubscriptionOutbounds appends the subscription outbounds to the
|
||||||
|
|||||||
@@ -561,49 +561,25 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
||||||
return amneziawgInboundWithServer(id, tag, amneziawg.ServerSettings{}, clients)
|
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
|
||||||
}
|
|
||||||
|
|
||||||
// amneziawgInboundWithServer lets a test set inbound-wide fields (namely
|
|
||||||
// RouteThroughXray/RouteOutboundTag) that InstanceFromInbound now needs —
|
|
||||||
// injectAmneziawgEgress computes each peer's EFFECTIVE routing decision via
|
|
||||||
// InstanceFromInbound rather than reading model.Client fields raw, so a
|
|
||||||
// server block (even a minimal valid one) is required for any of these
|
|
||||||
// tests to see peers at all.
|
|
||||||
func amneziawgInboundWithServer(id int, tag string, server amneziawg.ServerSettings, clients []model.Client) *model.Inbound {
|
|
||||||
if server.SubnetIP == "" {
|
|
||||||
server.SubnetIP = "10.8.1.0"
|
|
||||||
}
|
|
||||||
if server.SubnetCIDR == 0 {
|
|
||||||
server.SubnetCIDR = 24
|
|
||||||
}
|
|
||||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
||||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
||||||
}
|
}
|
||||||
|
|
||||||
type amneziawgRouting struct {
|
func TestInjectAmneziawgEgress_CreatesBridgeTaggedWithInboundsOwnTag(t *testing.T) {
|
||||||
Rules []struct {
|
|
||||||
InboundTag []string `json:"inboundTag"`
|
|
||||||
OutboundTag string `json:"outboundTag"`
|
|
||||||
BalancerTag string `json:"balancerTag"`
|
|
||||||
Source []string `json:"source"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
} `json:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
before := string(cfg.RouterConfig)
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
inbound := amneziawgInbound(7, "awg-7", []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
if len(cfg.InboundConfigs) != 2 {
|
||||||
t.Fatalf("expected the shared bridge to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
t.Fatalf("expected the bridge to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
||||||
}
|
}
|
||||||
ib := cfg.InboundConfigs[1]
|
ib := cfg.InboundConfigs[1]
|
||||||
if ib.Tag != amneziawg.EgressTag || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPort {
|
if ib.Tag != "awg-7" || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPortForInbound(7) {
|
||||||
t.Fatalf("unexpected bridge inbound: %+v", ib)
|
t.Fatalf("bridge must reuse the inbound's own tag (so it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
|
||||||
}
|
}
|
||||||
if string(ib.Listen) != `"127.0.0.1"` {
|
if string(ib.Listen) != `"127.0.0.1"` {
|
||||||
t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
|
t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
|
||||||
@@ -614,94 +590,45 @@ func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
|
|||||||
if !strings.Contains(string(ib.Settings), `"followRedirect":true`) {
|
if !strings.Contains(string(ib.Settings), `"followRedirect":true`) {
|
||||||
t.Fatalf("bridge must set followRedirect, got %s", ib.Settings)
|
t.Fatalf("bridge must set followRedirect, got %s", ib.Settings)
|
||||||
}
|
}
|
||||||
|
// No auto-generated routing rule: it's entirely up to the admin's own
|
||||||
var routing amneziawgRouting
|
// Routing-page rules, same as any other protocol's inbound tag.
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 {
|
|
||||||
t.Fatalf("expected the egress rule prepended to the existing rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
first := routing.Rules[0]
|
|
||||||
if first.Type != "field" || first.OutboundTag != "warp" ||
|
|
||||||
len(first.InboundTag) != 1 || first.InboundTag[0] != amneziawg.EgressTag ||
|
|
||||||
len(first.Source) != 1 || first.Source[0] != "10.8.1.2/32" {
|
|
||||||
t.Fatalf("egress rule must bind the shared bridge tag + peer source IP to the outbound, got %+v", first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_MultiplePeersDifferentOutbounds(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("expected exactly one shared bridge regardless of peer count, got %d inbounds", len(cfg.InboundConfigs))
|
|
||||||
}
|
|
||||||
var routing amneziawgRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 3 { // 2 egress rules + the 1 pre-existing rule
|
|
||||||
t.Fatalf("expected one rule per routed peer, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
bySource := map[string]string{}
|
|
||||||
for _, r := range routing.Rules {
|
|
||||||
if len(r.Source) == 1 {
|
|
||||||
bySource[r.Source[0]] = r.OutboundTag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bySource["10.8.1.2/32"] != "warp" || bySource["10.8.1.3/32"] != "direct" {
|
|
||||||
t.Fatalf("each peer must route to its own outbound, got %+v", bySource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_NoOutboundLeavesRouting(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("bridge must still be appended without an outbound, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
if string(cfg.RouterConfig) != before {
|
||||||
t.Fatalf("no outbound selected means no rule change, got %s", cfg.RouterConfig)
|
t.Fatalf("injectAmneziawgEgress must never touch the routing section, got %s", cfg.RouterConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_BalancerTag(t *testing.T) {
|
func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) {
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "lb"},
|
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
|
||||||
|
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2})
|
||||||
|
|
||||||
var routing amneziawgRouting
|
if len(cfg.InboundConfigs) != 3 {
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
t.Fatalf("expected one bridge per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
|
byTag := map[string]int{}
|
||||||
t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
|
for _, ib := range cfg.InboundConfigs[1:] {
|
||||||
|
byTag[ib.Tag] = ib.Port
|
||||||
|
}
|
||||||
|
if byTag["awg-1"] != amneziawg.EgressPortForInbound(1) || byTag["awg-2"] != amneziawg.EgressPortForInbound(2) {
|
||||||
|
t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_Disabled(t *testing.T) {
|
func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
client model.Client
|
client model.Client
|
||||||
enable bool
|
enable bool
|
||||||
}{
|
}{
|
||||||
{"client disabled", model.Client{Email: "a@x", Enable: false, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
{"client disabled", model.Client{Email: "a@x", Enable: false, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
||||||
{"RouteThroughXray off", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "warp"}, true},
|
{"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
||||||
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true},
|
||||||
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, false},
|
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -716,148 +643,44 @@ func TestInjectAmneziawgEgress_Disabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
|
func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
|
||||||
|
nodeID := 5
|
||||||
|
nodeHosted := amneziawgInbound(2, "awg-2", []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
|
})
|
||||||
|
nodeHosted.NodeID = &nodeID
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{vless, nodeHosted})
|
||||||
|
if len(cfg.InboundConfigs) != 1 {
|
||||||
|
t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a bridge, got %+v", cfg.InboundConfigs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) {
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||||
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: amneziawg.EgressTag})
|
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
|
||||||
before := string(cfg.RouterConfig)
|
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
|
||||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
|
||||||
t.Fatal("a real inbound already owning the shared bridge tag must make injection a no-op")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_MissingTargetSkipsOnlyThatPeer(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "removed-subscription-outbound"},
|
|
||||||
})
|
})
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2})
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
// Started with 2 (api + the colliding vless entry); only awg-2's bridge
|
||||||
t.Fatalf("the bridge must still be created for the peer with a valid target, got %+v", cfg.InboundConfigs)
|
// should have been added, awg-1's skipped since its tag is taken.
|
||||||
|
if len(cfg.InboundConfigs) != 3 {
|
||||||
|
t.Fatalf("expected only the non-colliding inbound's bridge to be added, got %+v", cfg.InboundConfigs)
|
||||||
}
|
}
|
||||||
var routing amneziawgRouting
|
found := false
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
for _, ib := range cfg.InboundConfigs {
|
||||||
t.Fatal(err)
|
if ib.Tag == "awg-2" && ib.Protocol == "dokodemo-door" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(routing.Rules) != 2 { // only a@x's rule + the 1 pre-existing rule
|
if !found {
|
||||||
t.Fatalf("only the peer with a valid target should get a rule, got %+v", routing.Rules)
|
t.Fatal("awg-2's bridge must still be created despite awg-1's tag collision")
|
||||||
}
|
|
||||||
if routing.Rules[0].Source[0] != "10.8.1.2/32" {
|
|
||||||
t.Fatalf("expected a@x's rule, got %+v", routing.Rules[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_BadOutboundsSkipsRulesKeepsBridge(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
|
||||||
before := string(cfg.RouterConfig)
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("unparsable outbounds must still expose the bridge (other peers may not need routing), got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != before {
|
|
||||||
t.Fatalf("unparsable outbounds means no target can resolve, so no rule change, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_BadRoutingSkipsEverything(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
|
||||||
t.Fatalf("unparsable routing must not expose the bridge either, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
if string(cfg.RouterConfig) != `{not json` {
|
|
||||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_NoRoutingSection(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
cfg.RouterConfig = nil
|
|
||||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var routing amneziawgRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
|
|
||||||
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_ServerLevelRoutesPeerWithNoOwnFlag(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgInboundWithServer(1, "awg-1",
|
|
||||||
amneziawg.ServerSettings{RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
[]model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 2 {
|
|
||||||
t.Fatalf("the inbound-wide default must route a peer with no client-level flag of its own, got %+v", cfg.InboundConfigs)
|
|
||||||
}
|
|
||||||
var routing amneziawgRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" || routing.Rules[0].Source[0] != "10.8.1.2/32" {
|
|
||||||
t.Fatalf("expected a rule routing the peer to the inbound's default outbound, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_ClientTagOverridesServerTag(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgInboundWithServer(1, "awg-1",
|
|
||||||
amneziawg.ServerSettings{RouteThroughXray: true, RouteOutboundTag: "warp"},
|
|
||||||
[]model.Client{
|
|
||||||
// Routed by the server-wide default, but picks its own outbound.
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "direct"},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var routing amneziawgRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "direct" {
|
|
||||||
t.Fatalf("a client's own outbound tag must win over the inbound's default, got %+v", routing.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectAmneziawgEgress_ServerLevelOffClientCanStillOptIn(t *testing.T) {
|
|
||||||
cfg := egressTestConfig()
|
|
||||||
inbound := amneziawgInboundWithServer(1, "awg-1",
|
|
||||||
amneziawg.ServerSettings{}, // server-wide routing off
|
|
||||||
[]model.Client{
|
|
||||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
|
|
||||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}},
|
|
||||||
})
|
|
||||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
|
||||||
|
|
||||||
var routing amneziawgRouting
|
|
||||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routing.Rules) != 2 || routing.Rules[0].Source[0] != "10.8.1.2/32" {
|
|
||||||
t.Fatalf("a@x must be routed on its own opt-in even with the inbound default off, got %+v", routing.Rules)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -922,11 +922,6 @@
|
|||||||
"amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
|
"amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
|
||||||
"amneziaWgForwardedPorts": "Forwarded Ports",
|
"amneziaWgForwardedPorts": "Forwarded Ports",
|
||||||
"amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
|
"amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
|
||||||
"amneziaWgRouteThroughXray": "Route via Xray",
|
|
||||||
"amneziaWgRouteThroughXrayHint": "Send this client's traffic through Xray instead of straight out the server's network interface.",
|
|
||||||
"amneziaWgRouteOutboundTag": "Outbound",
|
|
||||||
"amneziaWgRouteOutboundTagHint": "Which Xray outbound (or balancer) this client's traffic exits through. Leave empty to use Xray's default routing.",
|
|
||||||
"amneziaWgRouteOutboundTagPlaceholder": "Select an outbound",
|
|
||||||
"amneziaWgConfig": "AmneziaWG config",
|
"amneziaWgConfig": "AmneziaWG config",
|
||||||
"mtprotoSecret": "MTProto secret",
|
"mtprotoSecret": "MTProto secret",
|
||||||
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
|
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
|
||||||
@@ -1945,11 +1940,6 @@
|
|||||||
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
|
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
|
||||||
"ipv6ExternalInterface": "IPv6 External Interface",
|
"ipv6ExternalInterface": "IPv6 External Interface",
|
||||||
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
|
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
|
||||||
"routeThroughXray": "Route via Xray (all clients)",
|
|
||||||
"routeThroughXrayHint": "Send every client's traffic through Xray by default instead of straight out the server's network interface. A client can still enable this on its own even when it's off here.",
|
|
||||||
"routeOutboundTag": "Default Outbound",
|
|
||||||
"routeOutboundTagHint": "Default Xray outbound (or balancer) for clients routed through Xray. A client's own outbound choice overrides this. Leave empty to use Xray's default routing.",
|
|
||||||
"routeOutboundTagPlaceholder": "Select an outbound",
|
|
||||||
"obfuscation": "Obfuscation parameters",
|
"obfuscation": "Obfuscation parameters",
|
||||||
"regenerateObfuscation": "Regenerate",
|
"regenerateObfuscation": "Regenerate",
|
||||||
"jc": "Jc (junk packet count)",
|
"jc": "Jc (junk packet count)",
|
||||||
|
|||||||
@@ -922,11 +922,6 @@
|
|||||||
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
|
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
|
||||||
"amneziaWgForwardedPorts": "Проброс портов",
|
"amneziaWgForwardedPorts": "Проброс портов",
|
||||||
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
|
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
|
||||||
"amneziaWgRouteThroughXray": "Маршрутизировать через Xray",
|
|
||||||
"amneziaWgRouteThroughXrayHint": "Направлять трафик этого клиента через Xray вместо прямого выхода через сетевой интерфейс сервера.",
|
|
||||||
"amneziaWgRouteOutboundTag": "Исходящий",
|
|
||||||
"amneziaWgRouteOutboundTagHint": "Через какой исходящий (outbound) или балансировщик Xray выходит трафик этого клиента. Оставьте пустым, чтобы использовать маршрутизацию Xray по умолчанию.",
|
|
||||||
"amneziaWgRouteOutboundTagPlaceholder": "Выберите исходящий",
|
|
||||||
"amneziaWgConfig": "Конфиг AmneziaWG",
|
"amneziaWgConfig": "Конфиг AmneziaWG",
|
||||||
"mtprotoSecret": "Секрет MTProto",
|
"mtprotoSecret": "Секрет MTProto",
|
||||||
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
|
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
|
||||||
@@ -1828,11 +1823,6 @@
|
|||||||
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
|
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
|
||||||
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
|
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
|
||||||
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
|
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
|
||||||
"routeThroughXray": "Маршрутизировать через Xray (все клиенты)",
|
|
||||||
"routeThroughXrayHint": "Направлять трафик всех клиентов через Xray по умолчанию вместо прямого выхода через сетевой интерфейс сервера. Клиент может включить это индивидуально, даже если здесь выключено.",
|
|
||||||
"routeOutboundTag": "Исходящий по умолчанию",
|
|
||||||
"routeOutboundTagHint": "Исходящий (outbound) или балансировщик Xray по умолчанию для клиентов, маршрутизируемых через Xray. Собственный выбор клиента имеет приоритет. Оставьте пустым для маршрутизации Xray по умолчанию.",
|
|
||||||
"routeOutboundTagPlaceholder": "Выберите исходящий",
|
|
||||||
"obfuscation": "Параметры обфускации",
|
"obfuscation": "Параметры обфускации",
|
||||||
"regenerateObfuscation": "Сгенерировать заново",
|
"regenerateObfuscation": "Сгенерировать заново",
|
||||||
"jc": "Jc (кол-во мусорных пакетов)",
|
"jc": "Jc (кол-во мусорных пакетов)",
|
||||||
|
|||||||
Reference in New Issue
Block a user