mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-09 11:48:18 +00:00
feat(amneziawg): Phase 2c — RouteViaXray (TPROXY into Xray)
Per-client toggle (RouteThroughXray + RouteOutboundTag) that TPROXYs a peer's traffic into Xray instead of NAT'ing it straight out the host's network interface, so it can egress through any configured Xray outbound (or balancer) — a VLESS/proxy chain, WARP, etc. Discovered mid-design that internal/mtproto already solved the "let a native sidecar's traffic egress through Xray" problem once, via routeThroughXray/routeXrayPort/outboundTag + injectMtprotoEgress: a loopback bridge inbound plus a routing rule. AmneziaWG can't reuse it directly — mtg is a userspace process that dials *out* through a local SOCKS proxy, while AmneziaWG is a kernel tunnel interface with no process of its own to redirect. The Xray-side shape carries over almost exactly, the kernel-side plumbing is new: - internal/amneziawg/route_egress.go: EgressPort/EgressTag/EgressFwmark/ EgressTable are one shared constant set, not one bridge per peer. Every routed peer, across every AmneziaWG instance, TPROXYs into the *same* loopback dokodemo-door bridge; the per-peer distinction happens downstream, in Xray's own router, matched against each peer's TPROXY-preserved source IP (Xray's field-rule `source` matcher — a capability the router already had). This avoids two independent reconcile loops (the AWG manager and the Xray-config generator) ever having to agree on a dynamically-picked port for each peer. - manager.go's defaultPostUpDown emits a per-peer mangle-table TPROXY rule (matched by tunnel source IP) for each opted-in peer, plus the fwmark->table->local-everywhere policy route TPROXY needs to deliver those packets to the bridge. That policy route is system-wide, not interface-specific, so — like the existing IPv6-forwarding sysctl — it's added idempotently and never torn down in PostDown; a second AmneziaWG instance with its own routed peers must find it already in place, not race to remove what the first still needs. - The existing portForwardFingerprint became hostRulesFingerprint, covering both ForwardedPorts and RouteThroughXray/RouteOutboundTag: both only ever take effect through PostUp/PostDown, which `awg syncconf` never re-runs, so either one changing must force the same full interface bounce. - internal/web/service/xray.go's new injectAmneziawgEgress mirrors injectMtprotoEgress/injectPanelEgress's safety rules, adapted for one bridge serving many peers: an invalid or missing outbound target skips only that one peer's rule (not the whole bridge, since other peers may still need it), while the bridge itself is skipped entirely when nothing needs it or its tag is already taken by a real inbound. Frontend: a Switch + conditional outbound Select on the client form (showAmneziawg only), mirroring mtproto's own routeThroughXray UI and reusing its useOutboundTags hook. install.sh now modprobes the mainline TPROXY modules (xt_TPROXY, nf_tproxy_ipv4/ipv6) alongside the existing AmneziaWG setup — ordinary upstream kernel modules, no DKMS/PPA needed unlike the AmneziaWG module itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1095,6 +1095,14 @@
|
|||||||
"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"
|
||||||
@@ -1223,6 +1231,12 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
|
"routeOutboundTag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"routeThroughXray": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1269,6 +1283,8 @@
|
|||||||
"publicKey",
|
"publicKey",
|
||||||
"reset",
|
"reset",
|
||||||
"reverse",
|
"reverse",
|
||||||
|
"routeOutboundTag",
|
||||||
|
"routeThroughXray",
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ 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": "",
|
||||||
@@ -286,6 +288,8 @@ 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": "",
|
||||||
|
|||||||
@@ -1069,6 +1069,14 @@ 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"
|
||||||
@@ -1197,6 +1205,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
|
"routeOutboundTag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"routeThroughXray": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1243,6 +1257,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"publicKey",
|
"publicKey",
|
||||||
"reset",
|
"reset",
|
||||||
"reverse",
|
"reverse",
|
||||||
|
"routeOutboundTag",
|
||||||
|
"routeThroughXray",
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ 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;
|
||||||
@@ -297,6 +299,8 @@ 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;
|
||||||
|
|||||||
@@ -279,6 +279,8 @@ 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(),
|
||||||
@@ -317,6 +319,8 @@ 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(),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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);
|
||||||
@@ -103,6 +104,8 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -133,6 +136,8 @@ const EMPTY: Values = {
|
|||||||
wgPreSharedKey: '',
|
wgPreSharedKey: '',
|
||||||
wgAllowedIPs: '',
|
wgAllowedIPs: '',
|
||||||
awgForwardedPorts: '',
|
awgForwardedPorts: '',
|
||||||
|
awgRouteThroughXray: false,
|
||||||
|
awgRouteOutboundTag: '',
|
||||||
secret: '',
|
secret: '',
|
||||||
adTag: '',
|
adTag: '',
|
||||||
};
|
};
|
||||||
@@ -193,6 +198,8 @@ 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,
|
||||||
@@ -246,6 +253,8 @@ 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 || '',
|
||||||
};
|
};
|
||||||
@@ -561,10 +570,13 @@ export default function ClientFormModal({
|
|||||||
if (allowedIPs.length > 0) {
|
if (allowedIPs.length > 0) {
|
||||||
clientPayload.allowedIPs = allowedIPs;
|
clientPayload.allowedIPs = allowedIPs;
|
||||||
}
|
}
|
||||||
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
|
// Port-forwarding and RouteViaXray have no WireGuard equivalent —
|
||||||
// has no host-level iptables layer to hang per-client DNAT off of.
|
// Xray-native WireGuard has no host-level iptables layer to hang
|
||||||
|
// 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() : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +929,30 @@ 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 && (
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ 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,9 +10,12 @@ 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
|
||||||
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
|
// two AmneziaWG-only additions: 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)
|
||||||
// Keys are optional on the wire — the backend generates them when absent.
|
// and routeThroughXray/routeOutboundTag (TPROXYs this peer's traffic into a
|
||||||
|
// shared Xray bridge instead of NAT'ing it straight out — see
|
||||||
|
// internal/amneziawg's EgressPort). Keys are optional on the wire — the
|
||||||
|
// backend generates them when absent.
|
||||||
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(),
|
||||||
@@ -20,6 +23,8 @@ 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),
|
||||||
|
|||||||
+15
@@ -194,6 +194,19 @@ enable_ipv6_forwarding() {
|
|||||||
sysctl -p >/dev/null 2>&1 || true
|
sysctl -p >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Loads the mainline TPROXY kernel modules, used by AmneziaWG's optional
|
||||||
|
# per-client "route via Xray" toggle (see internal/amneziawg's EgressPort and
|
||||||
|
# defaultPostUpDown's `-j TPROXY` rules). Unlike the AmneziaWG module itself,
|
||||||
|
# these are standard upstream modules present on any modern distro kernel —
|
||||||
|
# no DKMS/PPA needed, just loading them. Best-effort: a panel without them
|
||||||
|
# still works fine, that one toggle just won't redirect traffic until
|
||||||
|
# they're available.
|
||||||
|
enable_tproxy_support() {
|
||||||
|
modprobe xt_TPROXY 2>/dev/null || true
|
||||||
|
modprobe nf_tproxy_ipv4 2>/dev/null || true
|
||||||
|
modprobe nf_tproxy_ipv6 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
# Installs the AmneziaWG DKMS kernel module + amneziawg-tools (awg/awg-quick)
|
# Installs the AmneziaWG DKMS kernel module + amneziawg-tools (awg/awg-quick)
|
||||||
# so an AmneziaWG inbound created in the panel can actually bring up an
|
# so an AmneziaWG inbound created in the panel can actually bring up an
|
||||||
# interface. Best-effort and never fatal to the overall x-ui install: the
|
# interface. Best-effort and never fatal to the overall x-ui install: the
|
||||||
@@ -212,6 +225,7 @@ install_amneziawg() {
|
|||||||
modprobe amneziawg 2>/dev/null || true
|
modprobe amneziawg 2>/dev/null || true
|
||||||
install_ndppd
|
install_ndppd
|
||||||
enable_ipv6_forwarding
|
enable_ipv6_forwarding
|
||||||
|
enable_tproxy_support
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -284,6 +298,7 @@ install_amneziawg() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
enable_ipv6_forwarding
|
enable_ipv6_forwarding
|
||||||
|
enable_tproxy_support
|
||||||
}
|
}
|
||||||
|
|
||||||
gen_random_string() {
|
gen_random_string() {
|
||||||
|
|||||||
@@ -53,11 +53,13 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
RouteThroughXray: c.RouteThroughXray,
|
||||||
|
RouteOutboundTag: c.RouteOutboundTag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(peers) == 0 {
|
if len(peers) == 0 {
|
||||||
@@ -143,7 +145,7 @@ func (inst Instance) structuralFingerprint() string {
|
|||||||
// change. It moves whenever a peer is added, removed, disabled, re-keyed, or
|
// change. It moves whenever a peer is added, removed, disabled, re-keyed, or
|
||||||
// re-addressed — all of which `awg syncconf` applies in place. Deliberately
|
// re-addressed — all of which `awg syncconf` applies in place. Deliberately
|
||||||
// excludes ForwardedPorts: those live in PostUp/PostDown, not the WireGuard
|
// excludes ForwardedPorts: those live in PostUp/PostDown, not the WireGuard
|
||||||
// peer table, so a ports-only change needs portForwardFingerprint's full
|
// peer table, so a ports-only change needs hostRulesFingerprint's full
|
||||||
// bounce instead of a syncconf reload.
|
// bounce instead of a syncconf reload.
|
||||||
func (inst Instance) peersFingerprint() string {
|
func (inst Instance) peersFingerprint() string {
|
||||||
pairs := make([]string, 0, len(inst.Peers))
|
pairs := make([]string, 0, len(inst.Peers))
|
||||||
@@ -154,18 +156,20 @@ func (inst Instance) peersFingerprint() string {
|
|||||||
return strings.Join(pairs, "|")
|
return strings.Join(pairs, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
// portForwardFingerprint identifies the per-peer forwarded-ports set. It is
|
// hostRulesFingerprint identifies per-peer state that only ever takes effect
|
||||||
// checked separately from peersFingerprint because DNAT/FORWARD rules only
|
// through PostUp/PostDown shell rules — forwarded ports and RouteThroughXray/
|
||||||
// live in PostUp/PostDown, which `awg syncconf` never re-runs — a
|
// RouteOutboundTag — rather than the WireGuard peer table itself. It is
|
||||||
// ForwardedPorts-only change must force a full interface bounce
|
// checked separately from peersFingerprint because `awg syncconf` never
|
||||||
// (ensureRestart) to actually take effect, unlike a key/address-only change.
|
// re-runs PostUp/PostDown, so a change here must force a full interface
|
||||||
func (inst Instance) portForwardFingerprint() string {
|
// bounce (ensureRestart) to actually take effect, unlike a key/address-only
|
||||||
|
// change that syncconf can apply in place.
|
||||||
|
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 == "" {
|
if p.ForwardedPorts == "" && !p.RouteThroughXray {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pairs = append(pairs, fmt.Sprintf("%s=%s", p.Email, p.ForwardedPorts))
|
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, "|")
|
||||||
@@ -183,7 +187,7 @@ type managed struct {
|
|||||||
inst Instance
|
inst Instance
|
||||||
structuralFP string
|
structuralFP string
|
||||||
peersFP string
|
peersFP string
|
||||||
portFwdFP string
|
hostRulesFP string
|
||||||
last map[string]peerCounters // keyed by peer public key
|
last map[string]peerCounters // keyed by peer public key
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,13 +222,13 @@ 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 forwarded-ports change (its
|
// managed interface. A structural change, a host-rules change (forwarded
|
||||||
// iptables rules only live in PostUp/PostDown), or a down interface all
|
// ports or RouteThroughXray/RouteOutboundTag — their iptables rules only
|
||||||
// force a restart; a peers-only change (keys/addresses) is a candidate for
|
// live in PostUp/PostDown), or a down interface all force a restart; a
|
||||||
// an in-place `syncconf`; identical fingerprints on an up interface need
|
// peers-only change (keys/addresses) is a candidate for an in-place
|
||||||
// nothing.
|
// `syncconf`; identical fingerprints on an up interface need nothing.
|
||||||
func ensureActionFor(up bool, curStructFP, curPortFwdFP, curPeersFP, newStructFP, newPortFwdFP, newPeersFP string) ensureAction {
|
func ensureActionFor(up bool, curStructFP, curHostRulesFP, curPeersFP, newStructFP, newHostRulesFP, newPeersFP string) ensureAction {
|
||||||
if !up || curStructFP != newStructFP || curPortFwdFP != newPortFwdFP {
|
if !up || curStructFP != newStructFP || curHostRulesFP != newHostRulesFP {
|
||||||
return ensureRestart
|
return ensureRestart
|
||||||
}
|
}
|
||||||
if curPeersFP != newPeersFP {
|
if curPeersFP != newPeersFP {
|
||||||
@@ -243,13 +247,13 @@ func (m *Manager) Ensure(inst Instance) error {
|
|||||||
|
|
||||||
func (m *Manager) ensureLocked(inst Instance) error {
|
func (m *Manager) ensureLocked(inst Instance) error {
|
||||||
structFP := inst.structuralFingerprint()
|
structFP := inst.structuralFingerprint()
|
||||||
portFwdFP := inst.portForwardFingerprint()
|
hostRulesFP := inst.hostRulesFingerprint()
|
||||||
peersFP := inst.peersFingerprint()
|
peersFP := inst.peersFingerprint()
|
||||||
|
|
||||||
cur, exists := m.ifaces[inst.Id]
|
cur, exists := m.ifaces[inst.Id]
|
||||||
action := ensureRestart
|
action := ensureRestart
|
||||||
if exists {
|
if exists {
|
||||||
action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.portFwdFP, cur.peersFP, structFP, portFwdFP, peersFP)
|
action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.hostRulesFP, cur.peersFP, structFP, hostRulesFP, peersFP)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
@@ -280,7 +284,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
|||||||
if exists {
|
if exists {
|
||||||
last = cur.last
|
last = cur.last
|
||||||
}
|
}
|
||||||
m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, portFwdFP: portFwdFP, peersFP: peersFP, last: last}
|
m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, hostRulesFP: hostRulesFP, peersFP: peersFP, last: last}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,8 +509,10 @@ 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. RouteViaXray is
|
// for each enabled peer with a non-empty ForwardedPorts spec, and — for each
|
||||||
// a later phase (see project TODO).
|
// peer with RouteThroughXray set — a mangle-table TPROXY rule redirecting
|
||||||
|
// that peer's traffic into the shared Xray bridge (see EgressPort), plus the
|
||||||
|
// one-time policy route TPROXY needs to deliver it there.
|
||||||
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{
|
||||||
@@ -553,7 +559,7 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
|
|||||||
if p.ForwardedPorts == "" {
|
if p.ForwardedPorts == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
clientIP := firstIPv4(p.AllowedIPs)
|
clientIP := FirstIPv4(p.AllowedIPs)
|
||||||
if clientIP == "" {
|
if clientIP == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -561,6 +567,34 @@ 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
|
||||||
|
for _, p := range inst.Peers {
|
||||||
|
if !p.RouteThroughXray {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clientIP := FirstIPv4(p.AllowedIPs)
|
||||||
|
if clientIP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
up = append(up, routeEgressLines("-A", iface, clientIP, p.Email)...)
|
||||||
|
down = append(down, routeEgressLines("-D", iface, clientIP, p.Email)...)
|
||||||
|
routedAny = true
|
||||||
|
}
|
||||||
|
if routedAny {
|
||||||
|
// The fwmark->table->local-everywhere policy route is what lets TPROXY
|
||||||
|
// deliver a routed peer's packets to the shared Xray bridge even though
|
||||||
|
// their destination is never one of this host's own addresses. It is
|
||||||
|
// system-wide, not interface-specific, so — like the IPv6-forwarding
|
||||||
|
// sysctl above — it is added idempotently here and never torn down in
|
||||||
|
// PostDown; a second AmneziaWG instance with its own routed peers must
|
||||||
|
// find it already in place, not race to remove what the first still
|
||||||
|
// needs.
|
||||||
|
up = append(up,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
up = append(up, "sysctl -w net.ipv4.ip_forward=1")
|
up = append(up, "sysctl -w net.ipv4.ip_forward=1")
|
||||||
return strings.Join(up, "; "), strings.Join(down, "; ")
|
return strings.Join(up, "; "), strings.Join(down, "; ")
|
||||||
}
|
}
|
||||||
@@ -591,9 +625,12 @@ func firstIPv6(allowedIPs []string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
|
// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
|
||||||
// or "" if none — used as the DNAT target for a peer's forwarded ports.
|
// or "" if none — used as the DNAT target for a peer's forwarded ports and,
|
||||||
func firstIPv4(allowedIPs []string) string {
|
// by internal/web/service's injectAmneziawgEgress, as the source-IP match for
|
||||||
|
// a routed peer's Xray rule. Exported so both packages derive a peer's
|
||||||
|
// tunnel IPv4 address the exact same way.
|
||||||
|
func FirstIPv4(allowedIPs []string) string {
|
||||||
for _, a := range allowedIPs {
|
for _, a := range allowedIPs {
|
||||||
if prefix, err := netip.ParsePrefix(a); err == nil {
|
if prefix, err := netip.ParsePrefix(a); err == nil {
|
||||||
if prefix.Addr().Is4() {
|
if prefix.Addr().Is4() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package amneziawg
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -179,21 +180,22 @@ func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
|
|||||||
|
|
||||||
func TestEnsureActionFor(t *testing.T) {
|
func TestEnsureActionFor(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
up bool
|
up bool
|
||||||
curStruct, curPortFwd, curPeers string
|
curStruct, curHostRules, curPeers string
|
||||||
newStruct, newPortFwd, newPeers string
|
newStruct, newHostRules, newPeers string
|
||||||
want ensureAction
|
want ensureAction
|
||||||
}{
|
}{
|
||||||
{"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},
|
||||||
{"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},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
got := ensureActionFor(c.up, c.curStruct, c.curPortFwd, c.curPeers, c.newStruct, c.newPortFwd, c.newPeers)
|
got := ensureActionFor(c.up, c.curStruct, c.curHostRules, c.curPeers, c.newStruct, c.newHostRules, c.newPeers)
|
||||||
if got != c.want {
|
if got != c.want {
|
||||||
t.Errorf("ensureActionFor() = %v, want %v", got, c.want)
|
t.Errorf("ensureActionFor() = %v, want %v", got, c.want)
|
||||||
}
|
}
|
||||||
@@ -201,6 +203,119 @@ func TestEnsureActionFor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostRulesFingerprintCoversForwardedPortsAndRouting(t *testing.T) {
|
||||||
|
a := baseInstance()
|
||||||
|
b := baseInstance()
|
||||||
|
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
|
||||||
|
t.Fatal("identical instances must produce the same host-rules fingerprint")
|
||||||
|
}
|
||||||
|
if a.hostRulesFingerprint() != "" {
|
||||||
|
t.Fatal("peers with no forwarded ports and no routing must produce an empty fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarded := baseInstance()
|
||||||
|
forwarded.Peers[0].ForwardedPorts = "80,443"
|
||||||
|
if a.hostRulesFingerprint() == forwarded.hostRulesFingerprint() {
|
||||||
|
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
routed := baseInstance()
|
||||||
|
routed.Peers[0].RouteThroughXray = true
|
||||||
|
if a.hostRulesFingerprint() == routed.hostRulesFingerprint() {
|
||||||
|
t.Fatal("enabling RouteThroughXray must change the host-rules fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
routedOtherTag := baseInstance()
|
||||||
|
routedOtherTag.Peers[0].RouteThroughXray = true
|
||||||
|
routedOtherTag.Peers[0].RouteOutboundTag = "warp"
|
||||||
|
if routed.hostRulesFingerprint() == routedOtherTag.hostRulesFingerprint() {
|
||||||
|
t.Fatal("changing RouteOutboundTag must change the host-rules fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteEgressComment(t *testing.T) {
|
||||||
|
if got := routeEgressComment(""); got != "awg-route" {
|
||||||
|
t.Errorf("empty email must fall back to awg-route, got %q", got)
|
||||||
|
}
|
||||||
|
a := routeEgressComment("a@x")
|
||||||
|
b := routeEgressComment("b@x")
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("different emails must produce different comment tags")
|
||||||
|
}
|
||||||
|
if a != routeEgressComment("a@x") {
|
||||||
|
t.Fatal("the same email must always produce the same comment tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteEgressLines(t *testing.T) {
|
||||||
|
up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x")
|
||||||
|
if len(up) != 2 {
|
||||||
|
t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up)
|
||||||
|
}
|
||||||
|
for _, proto := range []string{"tcp", "udp"} {
|
||||||
|
found := false
|
||||||
|
for _, l := range up {
|
||||||
|
if !strings.Contains(l, "-p "+proto) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
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-ip 127.0.0.1") ||
|
||||||
|
!strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) ||
|
||||||
|
!strings.Contains(l, "-A PREROUTING") {
|
||||||
|
t.Errorf("%s line missing expected fields: %s", proto, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing a %s TPROXY line in %v", proto, up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(up[0], "10.8.1.2/32") {
|
||||||
|
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")
|
||||||
|
if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") {
|
||||||
|
t.Fatalf("expected symmetric -D lines, got %v", down)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := routeEgressLines("-A", "awg1", "", "a@x"); got != nil {
|
||||||
|
t.Errorf("empty clientIP must yield no lines, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPostUpDownEmitsTproxyOnlyForRoutedPeers(t *testing.T) {
|
||||||
|
inst := baseInstance()
|
||||||
|
inst.Peers[0].RouteThroughXray = true
|
||||||
|
inst.Peers[0].RouteOutboundTag = "warp"
|
||||||
|
up, down := defaultPostUpDown(inst, "eth0")
|
||||||
|
|
||||||
|
if !strings.Contains(up, "TPROXY") || !strings.Contains(up, fmt.Sprintf("--on-port %d", EgressPort)) {
|
||||||
|
t.Errorf("expected a TPROXY rule for the routed peer in PostUp, got:\n%s", up)
|
||||||
|
}
|
||||||
|
if !strings.Contains(down, "TPROXY") {
|
||||||
|
t.Errorf("expected a matching TPROXY removal in PostDown, got:\n%s", down)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
// Peer b@x has no routing enabled: only the one routed peer's tcp+udp
|
||||||
|
// pair should appear.
|
||||||
|
if got := strings.Count(up, "TPROXY"); got != 2 {
|
||||||
|
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
|
||||||
|
upNone, _ := defaultPostUpDown(none, "eth0")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
|
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
|
||||||
inst := baseInstance()
|
inst := baseInstance()
|
||||||
inst.ExternalInterface = "eth0"
|
inst.ExternalInterface = "eth0"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package amneziawg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EgressPort is the loopback port of the single Xray dokodemo-door bridge
|
||||||
|
// every RouteThroughXray peer's TPROXY'd traffic lands on, shared across
|
||||||
|
// every AmneziaWG instance. defaultPostUpDown's TPROXY rules target it, and
|
||||||
|
// internal/web/service's injectAmneziawgEgress listens on it. A single
|
||||||
|
// shared bridge — rather than one per peer — keeps this a plain constant
|
||||||
|
// instead of state two independent reconcile loops would otherwise have to
|
||||||
|
// agree on at runtime; per-peer distinction happens downstream, in Xray's
|
||||||
|
// own router, matched by each peer's TPROXY-preserved tunnel source IP (see
|
||||||
|
// EgressTag).
|
||||||
|
const EgressPort = 63100
|
||||||
|
|
||||||
|
// EgressTag is the tag of that shared bridge inbound in the generated Xray
|
||||||
|
// config. Routing rules that distinguish peers match against it as their
|
||||||
|
// inboundTag.
|
||||||
|
const EgressTag = "amneziawg-egress"
|
||||||
|
|
||||||
|
// EgressFwmark and EgressTable are the fwmark and policy-routing table
|
||||||
|
// TPROXY needs to deliver a routed peer's packets to a local socket even
|
||||||
|
// though their destination is never one of this host's own addresses.
|
||||||
|
// Chosen to be distinctive; if either happens to collide with something else
|
||||||
|
// already using fwmarks/routing tables on the host, change the values here —
|
||||||
|
// nothing outside this package and its own PostUp/PostDown output depends on
|
||||||
|
// the actual numbers.
|
||||||
|
const (
|
||||||
|
EgressFwmark = 0x2377
|
||||||
|
EgressTable = 87
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeEgressComment returns a short, shell-safe iptables comment tag for one
|
||||||
|
// peer's TPROXY rule, so PostDown removes exactly what PostUp added
|
||||||
|
// regardless of ordering. Derived from a hash of the peer's email for the
|
||||||
|
// same reason portForwardComment is: email is admin/API-supplied free text
|
||||||
|
// that ends up embedded in a shell-executed PostUp/PostDown line, and a hash
|
||||||
|
// can never carry a shell metacharacter through.
|
||||||
|
func routeEgressComment(email string) string {
|
||||||
|
if email == "" {
|
||||||
|
return "awg-route"
|
||||||
|
}
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(email))
|
||||||
|
return fmt.Sprintf("awg-route-%08x", h.Sum32())
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table
|
||||||
|
// 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
|
||||||
|
// UDP are covered since RouteThroughXray means "this peer's traffic", not a
|
||||||
|
// specific protocol or port. Returns nil when clientIP is empty.
|
||||||
|
func routeEgressLines(action, tunIface, clientIP, email string) []string {
|
||||||
|
clientIP = stripCIDRMask(clientIP)
|
||||||
|
if clientIP == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
comment := routeEgressComment(email)
|
||||||
|
lines := make([]string, 0, 2)
|
||||||
|
for _, proto := range []string{"tcp", "udp"} {
|
||||||
|
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",
|
||||||
|
action, tunIface, clientIP, proto, comment, EgressPort, EgressFwmark, EgressFwmark,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
@@ -38,6 +38,18 @@ 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, when true, TPROXYs this peer's traffic (matched by its
|
||||||
|
// tunnel source IP) into the single shared loopback Xray dokodemo-door
|
||||||
|
// bridge (see amneziawgEgressPort in internal/web/service/xray.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
|
||||||
|
|||||||
@@ -797,62 +797,66 @@ 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"
|
||||||
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
RouteThroughXray bool `json:"routeThroughXray,omitempty"` // AmneziaWG: TPROXY this peer's traffic into Xray
|
||||||
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
RouteOutboundTag string `json:"routeOutboundTag,omitempty"` // Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing
|
||||||
Email string `json:"email"` // Client email identifier
|
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
||||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
Email string `json:"email"` // Client email identifier
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||||
Comment string `json:"comment" form:"comment"` // Client comment
|
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
Comment string `json:"comment" form:"comment"` // Client comment
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||||
|
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"`
|
||||||
Secret string `json:"secret" gorm:"column:secret"`
|
RouteThroughXray bool `json:"routeThroughXray" gorm:"column:wg_route_through_xray;default:false"`
|
||||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
RouteOutboundTag string `json:"routeOutboundTag" gorm:"column:wg_route_outbound_tag"`
|
||||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
Secret string `json:"secret" gorm:"column:secret"`
|
||||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||||
Enable bool `json:"enable" gorm:"default:true"`
|
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
Comment string `json:"comment"`
|
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||||
Reset int `json:"reset" gorm:"default:0"`
|
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
Comment string `json:"comment"`
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
Reset int `json:"reset" gorm:"default:0"`
|
||||||
|
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" }
|
||||||
@@ -1018,14 +1022,16 @@ 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,
|
||||||
Secret: c.Secret,
|
RouteThroughXray: c.RouteThroughXray,
|
||||||
AdTag: c.AdTag,
|
RouteOutboundTag: c.RouteOutboundTag,
|
||||||
|
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 {
|
||||||
@@ -1072,14 +1078,16 @@ 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,
|
||||||
Secret: r.Secret,
|
RouteThroughXray: r.RouteThroughXray,
|
||||||
AdTag: r.AdTag,
|
RouteOutboundTag: r.RouteOutboundTag,
|
||||||
|
Secret: r.Secret,
|
||||||
|
AdTag: r.AdTag,
|
||||||
}
|
}
|
||||||
if r.Reverse != "" {
|
if r.Reverse != "" {
|
||||||
var rev ClientReverse
|
var rev ClientReverse
|
||||||
@@ -1256,6 +1264,18 @@ 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)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
@@ -327,6 +328,15 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
injectMtprotoEgress(xrayConfig, inbound)
|
injectMtprotoEgress(xrayConfig, inbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route opted-in AmneziaWG peers through the core's router. Unlike mtg,
|
||||||
|
// AmneziaWG has no sidecar process of its own making outbound connections
|
||||||
|
// to dial through a bridge — it's a kernel tunnel interface, so the host
|
||||||
|
// side (internal/amneziawg's defaultPostUpDown) TPROXYs each opted-in
|
||||||
|
// peer's traffic to one loopback bridge shared by every AmneziaWG
|
||||||
|
// instance; this call is what creates that bridge and, per peer, the
|
||||||
|
// routing rule matching its preserved source IP to its chosen outbound.
|
||||||
|
injectAmneziawgEgress(xrayConfig, inbounds)
|
||||||
|
|
||||||
// Wire the panel's own HTTP traffic through the configured outbound, after
|
// Wire the panel's own HTTP traffic through the configured outbound, after
|
||||||
// the subscription merge so subscription outbound tags are valid targets.
|
// the subscription merge so subscription outbound tags are valid targets.
|
||||||
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
|
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
|
||||||
@@ -621,6 +631,127 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// amneziawgEgressDokodemoSettings is the dokodemo-door settings block for the
|
||||||
|
// shared AmneziaWG TPROXY bridge: accept both TCP and UDP, and (per this
|
||||||
|
// fork's existing "Tunnel" protocol convention — see
|
||||||
|
// frontend/src/lib/xray/inbound-tag.ts) use followRedirect mode so the
|
||||||
|
// destination comes from the TPROXY-preserved original address rather than a
|
||||||
|
// fixed port/address pair.
|
||||||
|
const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedirect":true}`
|
||||||
|
|
||||||
|
// amneziawgEgressStreamSettings turns the bridge's listening socket into a
|
||||||
|
// TPROXY target, matching internal/amneziawg's iptables `-j TPROXY` rules —
|
||||||
|
// without this, the kernel-redirected packets never reach a listening
|
||||||
|
// socket.
|
||||||
|
const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}`
|
||||||
|
|
||||||
|
// amneziawgRouteRule is one routed AmneziaWG peer — gathered from every
|
||||||
|
// enabled AmneziaWG inbound's client list — that injectAmneziawgEgress turns
|
||||||
|
// into a source-matched routing rule against the shared bridge.
|
||||||
|
type amneziawgRouteRule struct {
|
||||||
|
sourceIP string
|
||||||
|
outboundTag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectAmneziawgEgress wires every RouteThroughXray 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. Mirrors injectMtprotoEgress/injectPanelEgress: an invalid or missing
|
||||||
|
// outbound target skips that one peer's rule, not the whole bridge; the
|
||||||
|
// bridge itself is skipped entirely when no peer needs it or its tag is
|
||||||
|
// already taken by a real inbound. Generated state is hot-appliable and
|
||||||
|
// never modifies the stored template or restarts the core.
|
||||||
|
func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||||
|
var rules []amneziawgRouteRule
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var parsed amneziawg.InboundSettings
|
||||||
|
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range parsed.Clients {
|
||||||
|
if !c.Enable || !c.RouteThroughXray {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sourceIP := amneziawg.FirstIPv4(c.AllowedIPs)
|
||||||
|
if sourceIP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: c.RouteOutboundTag})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if !routingTargetExists(routing, cfg.OutboundConfigs, r.outboundTag) {
|
||||||
|
logger.Warning("amneziawg egress: target tag [", r.outboundTag, "] not found, skipping rule for [", r.sourceIP, "]")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule := map[string]any{
|
||||||
|
"type": "field",
|
||||||
|
"inboundTag": []any{amneziawg.EgressTag},
|
||||||
|
"source": []any{r.sourceIP + "/32"},
|
||||||
|
}
|
||||||
|
if routingTagIsBalancer(routing, r.outboundTag) {
|
||||||
|
rule["balancerTag"] = r.outboundTag
|
||||||
|
} else {
|
||||||
|
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
|
||||||
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
||||||
// template so that manually configured outbounds are never overwritten.
|
// template so that manually configured outbounds are never overwritten.
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package service
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
@@ -557,3 +559,230 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
|
|||||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
||||||
|
settings, _ := json.Marshal(amneziawg.InboundSettings{Clients: clients})
|
||||||
|
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type amneziawgRouting struct {
|
||||||
|
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()
|
||||||
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
|
if len(cfg.InboundConfigs) != 2 {
|
||||||
|
t.Fatalf("expected the shared bridge to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
||||||
|
}
|
||||||
|
ib := cfg.InboundConfigs[1]
|
||||||
|
if ib.Tag != amneziawg.EgressTag || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPort {
|
||||||
|
t.Fatalf("unexpected bridge inbound: %+v", ib)
|
||||||
|
}
|
||||||
|
if string(ib.Listen) != `"127.0.0.1"` {
|
||||||
|
t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(ib.StreamSettings), `"tproxy":"tproxy"`) {
|
||||||
|
t.Fatalf("bridge must set sockopt.tproxy, got %s", ib.StreamSettings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(ib.Settings), `"followRedirect":true`) {
|
||||||
|
t.Fatalf("bridge must set followRedirect, got %s", ib.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
var routing amneziawgRouting
|
||||||
|
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, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
|
{Email: "b@x", Enable: true, 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, 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 {
|
||||||
|
t.Fatalf("no outbound selected means no rule change, got %s", cfg.RouterConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_BalancerTag(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
|
||||||
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "lb"},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
|
var routing amneziawgRouting
|
||||||
|
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
|
||||||
|
t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_Disabled(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
client model.Client
|
||||||
|
enable bool
|
||||||
|
}{
|
||||||
|
{"client disabled", model.Client{Email: "a@x", Enable: false, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
||||||
|
{"RouteThroughXray off", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "warp"}, true},
|
||||||
|
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
|
||||||
|
{"inbound disabled", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client})
|
||||||
|
inbound.Enable = c.enable
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
if len(cfg.InboundConfigs) != 1 {
|
||||||
|
t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
|
||||||
|
cfg := egressTestConfig()
|
||||||
|
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||||
|
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: amneziawg.EgressTag})
|
||||||
|
before := string(cfg.RouterConfig)
|
||||||
|
inbound := amneziawgInbound(1, "awg-1", []model.Client{
|
||||||
|
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
||||||
|
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, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
|
||||||
|
{Email: "b@x", Enable: true, AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "removed-subscription-outbound"},
|
||||||
|
})
|
||||||
|
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||||
|
|
||||||
|
if len(cfg.InboundConfigs) != 2 {
|
||||||
|
t.Fatalf("the bridge must still be created for the peer with a valid target, got %+v", cfg.InboundConfigs)
|
||||||
|
}
|
||||||
|
var routing amneziawgRouting
|
||||||
|
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routing.Rules) != 2 { // only a@x's rule + the 1 pre-existing rule
|
||||||
|
t.Fatalf("only the peer with a valid target should get a rule, got %+v", routing.Rules)
|
||||||
|
}
|
||||||
|
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, 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, 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, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -922,6 +922,11 @@
|
|||||||
"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.",
|
||||||
|
|||||||
@@ -922,6 +922,11 @@
|
|||||||
"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 клиента. Перегенерируйте, чтобы сменить.",
|
||||||
|
|||||||
Reference in New Issue
Block a user