mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 02:37:14 +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",
|
||||
"nullable": true
|
||||
},
|
||||
"routeOutboundTag": {
|
||||
"description": "Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing",
|
||||
"type": "string"
|
||||
},
|
||||
"routeThroughXray": {
|
||||
"description": "AmneziaWG: TPROXY this peer's traffic into Xray",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||
"type": "string"
|
||||
@@ -1223,6 +1231,12 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"routeOutboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"routeThroughXray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1269,6 +1283,8 @@
|
||||
"publicKey",
|
||||
"reset",
|
||||
"reverse",
|
||||
"routeOutboundTag",
|
||||
"routeThroughXray",
|
||||
"secret",
|
||||
"security",
|
||||
"subId",
|
||||
|
||||
@@ -252,6 +252,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"publicKey": "",
|
||||
"reset": 0,
|
||||
"reverse": null,
|
||||
"routeOutboundTag": "",
|
||||
"routeThroughXray": false,
|
||||
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||
"security": "",
|
||||
"subId": "",
|
||||
@@ -286,6 +288,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"publicKey": "",
|
||||
"reset": 0,
|
||||
"reverse": null,
|
||||
"routeOutboundTag": "",
|
||||
"routeThroughXray": false,
|
||||
"secret": "",
|
||||
"security": "",
|
||||
"subId": "",
|
||||
|
||||
@@ -1069,6 +1069,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "VLESS simple reverse proxy settings",
|
||||
"nullable": true
|
||||
},
|
||||
"routeOutboundTag": {
|
||||
"description": "Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing",
|
||||
"type": "string"
|
||||
},
|
||||
"routeThroughXray": {
|
||||
"description": "AmneziaWG: TPROXY this peer's traffic into Xray",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||
"type": "string"
|
||||
@@ -1197,6 +1205,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"routeOutboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"routeThroughXray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1243,6 +1257,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"publicKey",
|
||||
"reset",
|
||||
"reverse",
|
||||
"routeOutboundTag",
|
||||
"routeThroughXray",
|
||||
"secret",
|
||||
"security",
|
||||
"subId",
|
||||
|
||||
@@ -261,6 +261,8 @@ export interface Client {
|
||||
publicKey?: string;
|
||||
reset: number;
|
||||
reverse?: ClientReverse | null;
|
||||
routeOutboundTag?: string;
|
||||
routeThroughXray?: boolean;
|
||||
secret?: string;
|
||||
security: string;
|
||||
subId: string;
|
||||
@@ -297,6 +299,8 @@ export interface ClientRecord {
|
||||
publicKey: string;
|
||||
reset: number;
|
||||
reverse: unknown;
|
||||
routeOutboundTag: string;
|
||||
routeThroughXray: boolean;
|
||||
secret: string;
|
||||
security: string;
|
||||
subId: string;
|
||||
|
||||
@@ -279,6 +279,8 @@ export const ClientSchema = z.object({
|
||||
publicKey: z.string().optional(),
|
||||
reset: z.number().int(),
|
||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||
routeOutboundTag: z.string().optional(),
|
||||
routeThroughXray: z.boolean().optional(),
|
||||
secret: z.string().optional(),
|
||||
security: z.string(),
|
||||
subId: z.string(),
|
||||
@@ -317,6 +319,8 @@ export const ClientRecordSchema = z.object({
|
||||
publicKey: z.string(),
|
||||
reset: z.number().int(),
|
||||
reverse: z.unknown(),
|
||||
routeOutboundTag: z.string(),
|
||||
routeThroughXray: z.boolean(),
|
||||
secret: z.string(),
|
||||
security: z.string(),
|
||||
subId: z.string(),
|
||||
|
||||
@@ -33,6 +33,7 @@ import { FormField } from '@/components/form/rhf';
|
||||
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
@@ -103,6 +104,8 @@ type Values = ClientFormValues & {
|
||||
wgPreSharedKey: string;
|
||||
wgAllowedIPs: string;
|
||||
awgForwardedPorts: string;
|
||||
awgRouteThroughXray: boolean;
|
||||
awgRouteOutboundTag: string;
|
||||
secret: string;
|
||||
adTag: string;
|
||||
};
|
||||
@@ -133,6 +136,8 @@ const EMPTY: Values = {
|
||||
wgPreSharedKey: '',
|
||||
wgAllowedIPs: '',
|
||||
awgForwardedPorts: '',
|
||||
awgRouteThroughXray: false,
|
||||
awgRouteOutboundTag: '',
|
||||
secret: '',
|
||||
adTag: '',
|
||||
};
|
||||
@@ -193,6 +198,8 @@ export default function ClientFormModal({
|
||||
const subId = useWatch({ control: methods.control, name: 'subId' });
|
||||
const auth = useWatch({ control: methods.control, name: 'auth' });
|
||||
const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
|
||||
const awgRouteThroughXray = useWatch({ control: methods.control, name: 'awgRouteThroughXray' });
|
||||
const { data: outboundTags } = useOutboundTags();
|
||||
const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
|
||||
const {
|
||||
fields: externalLinkFields,
|
||||
@@ -246,6 +253,8 @@ export default function ClientFormModal({
|
||||
wgPreSharedKey: client.preSharedKey || '',
|
||||
wgAllowedIPs: client.allowedIPs || '',
|
||||
awgForwardedPorts: client.forwardedPorts || '',
|
||||
awgRouteThroughXray: !!client.routeThroughXray,
|
||||
awgRouteOutboundTag: client.routeOutboundTag || '',
|
||||
secret: client.secret || '',
|
||||
adTag: client.adTag || '',
|
||||
};
|
||||
@@ -561,10 +570,13 @@ export default function ClientFormModal({
|
||||
if (allowedIPs.length > 0) {
|
||||
clientPayload.allowedIPs = allowedIPs;
|
||||
}
|
||||
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
|
||||
// has no host-level iptables layer to hang per-client DNAT off of.
|
||||
// Port-forwarding and RouteViaXray have no WireGuard equivalent —
|
||||
// Xray-native WireGuard has no host-level iptables layer to hang
|
||||
// per-client DNAT/TPROXY off of.
|
||||
if (showAmneziawg) {
|
||||
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" />
|
||||
</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 && (
|
||||
|
||||
@@ -38,6 +38,8 @@ export const ClientRecordSchema = z.object({
|
||||
preSharedKey: z.string().optional(),
|
||||
keepAlive: z.number().optional(),
|
||||
forwardedPorts: z.string().optional(),
|
||||
routeThroughXray: z.boolean().optional(),
|
||||
routeOutboundTag: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
adTag: z.string().optional(),
|
||||
createdAt: z.number().optional(),
|
||||
|
||||
@@ -10,9 +10,12 @@ const optionalClearedInt = (schema: z.ZodNumber) =>
|
||||
// WireguardClientSchema — the panel's generic ClientRecord already has those
|
||||
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
|
||||
// bulk operations, the QR modal and subscriptions all work unmodified — plus
|
||||
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
|
||||
// inbound has no host-level iptables layer to hang per-client DNAT off of).
|
||||
// Keys are optional on the wire — the backend generates them when absent.
|
||||
// two AmneziaWG-only additions: forwardedPorts (WireGuard's Xray-native
|
||||
// inbound has no host-level iptables layer to hang per-client DNAT off of)
|
||||
// and routeThroughXray/routeOutboundTag (TPROXYs this peer's traffic into a
|
||||
// shared Xray bridge instead of NAT'ing it straight out — see
|
||||
// internal/amneziawg's EgressPort). Keys are optional on the wire — the
|
||||
// backend generates them when absent.
|
||||
export const AmneziawgClientSchema = z.object({
|
||||
privateKey: z.string().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
@@ -20,6 +23,8 @@ export const AmneziawgClientSchema = z.object({
|
||||
allowedIPs: z.array(z.string()).default([]),
|
||||
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
||||
forwardedPorts: z.string().default(''),
|
||||
routeThroughXray: z.boolean().default(false),
|
||||
routeOutboundTag: z.string().default(''),
|
||||
email: z.string().min(1),
|
||||
limitIp: z.number().int().min(0).default(0),
|
||||
totalGB: z.number().int().min(0).default(0),
|
||||
|
||||
+15
@@ -194,6 +194,19 @@ enable_ipv6_forwarding() {
|
||||
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)
|
||||
# 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
|
||||
@@ -212,6 +225,7 @@ install_amneziawg() {
|
||||
modprobe amneziawg 2>/dev/null || true
|
||||
install_ndppd
|
||||
enable_ipv6_forwarding
|
||||
enable_tproxy_support
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -284,6 +298,7 @@ install_amneziawg() {
|
||||
fi
|
||||
|
||||
enable_ipv6_forwarding
|
||||
enable_tproxy_support
|
||||
}
|
||||
|
||||
gen_random_string() {
|
||||
|
||||
@@ -53,11 +53,13 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, Peer{
|
||||
Email: c.Email,
|
||||
PublicKey: c.PublicKey,
|
||||
PresharedKey: c.PreSharedKey,
|
||||
AllowedIPs: c.AllowedIPs,
|
||||
ForwardedPorts: c.ForwardedPorts,
|
||||
Email: c.Email,
|
||||
PublicKey: c.PublicKey,
|
||||
PresharedKey: c.PreSharedKey,
|
||||
AllowedIPs: c.AllowedIPs,
|
||||
ForwardedPorts: c.ForwardedPorts,
|
||||
RouteThroughXray: c.RouteThroughXray,
|
||||
RouteOutboundTag: c.RouteOutboundTag,
|
||||
})
|
||||
}
|
||||
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
|
||||
// re-addressed — all of which `awg syncconf` applies in place. Deliberately
|
||||
// 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.
|
||||
func (inst Instance) peersFingerprint() string {
|
||||
pairs := make([]string, 0, len(inst.Peers))
|
||||
@@ -154,18 +156,20 @@ func (inst Instance) peersFingerprint() string {
|
||||
return strings.Join(pairs, "|")
|
||||
}
|
||||
|
||||
// portForwardFingerprint identifies the per-peer forwarded-ports set. It is
|
||||
// checked separately from peersFingerprint because DNAT/FORWARD rules only
|
||||
// live in PostUp/PostDown, which `awg syncconf` never re-runs — a
|
||||
// ForwardedPorts-only change must force a full interface bounce
|
||||
// (ensureRestart) to actually take effect, unlike a key/address-only change.
|
||||
func (inst Instance) portForwardFingerprint() string {
|
||||
// hostRulesFingerprint identifies per-peer state that only ever takes effect
|
||||
// through PostUp/PostDown shell rules — forwarded ports and RouteThroughXray/
|
||||
// RouteOutboundTag — rather than the WireGuard peer table itself. It is
|
||||
// checked separately from peersFingerprint because `awg syncconf` never
|
||||
// re-runs PostUp/PostDown, so a change here must force a full interface
|
||||
// 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))
|
||||
for _, p := range inst.Peers {
|
||||
if p.ForwardedPorts == "" {
|
||||
if p.ForwardedPorts == "" && !p.RouteThroughXray {
|
||||
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)
|
||||
return strings.Join(pairs, "|")
|
||||
@@ -183,7 +187,7 @@ type managed struct {
|
||||
inst Instance
|
||||
structuralFP string
|
||||
peersFP string
|
||||
portFwdFP string
|
||||
hostRulesFP string
|
||||
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
|
||||
// managed interface. A structural change, a forwarded-ports change (its
|
||||
// iptables rules only live in PostUp/PostDown), or a down interface all
|
||||
// force a restart; a peers-only change (keys/addresses) is a candidate for
|
||||
// an in-place `syncconf`; identical fingerprints on an up interface need
|
||||
// nothing.
|
||||
func ensureActionFor(up bool, curStructFP, curPortFwdFP, curPeersFP, newStructFP, newPortFwdFP, newPeersFP string) ensureAction {
|
||||
if !up || curStructFP != newStructFP || curPortFwdFP != newPortFwdFP {
|
||||
// managed interface. A structural change, a host-rules change (forwarded
|
||||
// ports or RouteThroughXray/RouteOutboundTag — their iptables rules only
|
||||
// live in PostUp/PostDown), or a down interface all force a restart; a
|
||||
// peers-only change (keys/addresses) is a candidate for an in-place
|
||||
// `syncconf`; identical fingerprints on an up interface need nothing.
|
||||
func ensureActionFor(up bool, curStructFP, curHostRulesFP, curPeersFP, newStructFP, newHostRulesFP, newPeersFP string) ensureAction {
|
||||
if !up || curStructFP != newStructFP || curHostRulesFP != newHostRulesFP {
|
||||
return ensureRestart
|
||||
}
|
||||
if curPeersFP != newPeersFP {
|
||||
@@ -243,13 +247,13 @@ func (m *Manager) Ensure(inst Instance) error {
|
||||
|
||||
func (m *Manager) ensureLocked(inst Instance) error {
|
||||
structFP := inst.structuralFingerprint()
|
||||
portFwdFP := inst.portForwardFingerprint()
|
||||
hostRulesFP := inst.hostRulesFingerprint()
|
||||
peersFP := inst.peersFingerprint()
|
||||
|
||||
cur, exists := m.ifaces[inst.Id]
|
||||
action := ensureRestart
|
||||
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 {
|
||||
@@ -280,7 +284,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
||||
if exists {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -505,8 +509,10 @@ func hOrDefault(v, def string) string {
|
||||
// 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
|
||||
// directly reachable on the LAN without NAT66. Also emits DNAT+FORWARD rules
|
||||
// for each enabled peer with a non-empty ForwardedPorts spec. RouteViaXray is
|
||||
// a later phase (see project TODO).
|
||||
// for each enabled peer with a non-empty ForwardedPorts spec, and — for each
|
||||
// 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) {
|
||||
iface := inst.InterfaceName
|
||||
up := []string{
|
||||
@@ -553,7 +559,7 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
|
||||
if p.ForwardedPorts == "" {
|
||||
continue
|
||||
}
|
||||
clientIP := firstIPv4(p.AllowedIPs)
|
||||
clientIP := FirstIPv4(p.AllowedIPs)
|
||||
if clientIP == "" {
|
||||
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)...)
|
||||
}
|
||||
|
||||
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")
|
||||
return strings.Join(up, "; "), strings.Join(down, "; ")
|
||||
}
|
||||
@@ -591,9 +625,12 @@ func firstIPv6(allowedIPs []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
|
||||
// or "" if none — used as the DNAT target for a peer's forwarded ports.
|
||||
func firstIPv4(allowedIPs []string) string {
|
||||
// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
|
||||
// or "" if none — used as the DNAT target for a peer's forwarded ports and,
|
||||
// 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 {
|
||||
if prefix, err := netip.ParsePrefix(a); err == nil {
|
||||
if prefix.Addr().Is4() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package amneziawg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -179,21 +180,22 @@ func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
|
||||
|
||||
func TestEnsureActionFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
up bool
|
||||
curStruct, curPortFwd, curPeers string
|
||||
newStruct, newPortFwd, newPeers string
|
||||
want ensureAction
|
||||
name string
|
||||
up bool
|
||||
curStruct, curHostRules, curPeers string
|
||||
newStruct, newHostRules, newPeers string
|
||||
want ensureAction
|
||||
}{
|
||||
{"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},
|
||||
{"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},
|
||||
{"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop},
|
||||
}
|
||||
for _, c := range cases {
|
||||
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 {
|
||||
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) {
|
||||
inst := baseInstance()
|
||||
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")
|
||||
// DNAT'd to this peer's tunnel address. Empty means no port-forwarding.
|
||||
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
|
||||
|
||||
@@ -797,62 +797,66 @@ type ClientReverse struct {
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
PrivateKey string `json:"privateKey,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
AllowedIPs []string `json:"allowedIPs,omitempty"`
|
||||
PreSharedKey string `json:"preSharedKey,omitempty"`
|
||||
KeepAlive int `json:"keepAlive,omitempty"`
|
||||
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
|
||||
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
||||
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
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
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
PrivateKey string `json:"privateKey,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
AllowedIPs []string `json:"allowedIPs,omitempty"`
|
||||
PreSharedKey string `json:"preSharedKey,omitempty"`
|
||||
KeepAlive int `json:"keepAlive,omitempty"`
|
||||
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
|
||||
RouteOutboundTag string `json:"routeOutboundTag,omitempty"` // Xray outbound/balancer tag this peer's TPROXY'd traffic routes to; empty uses Xray's default routing
|
||||
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
||||
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
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 {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||
RouteThroughXray bool `json:"routeThroughXray" gorm:"column:wg_route_through_xray;default:false"`
|
||||
RouteOutboundTag string `json:"routeOutboundTag" gorm:"column:wg_route_outbound_tag"`
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
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" }
|
||||
@@ -1018,14 +1022,16 @@ func (c *Client) ToRecord() *ClientRecord {
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
|
||||
PrivateKey: c.PrivateKey,
|
||||
PublicKey: c.PublicKey,
|
||||
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
||||
PreSharedKey: c.PreSharedKey,
|
||||
KeepAlive: c.KeepAlive,
|
||||
ForwardedPorts: c.ForwardedPorts,
|
||||
Secret: c.Secret,
|
||||
AdTag: c.AdTag,
|
||||
PrivateKey: c.PrivateKey,
|
||||
PublicKey: c.PublicKey,
|
||||
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
||||
PreSharedKey: c.PreSharedKey,
|
||||
KeepAlive: c.KeepAlive,
|
||||
ForwardedPorts: c.ForwardedPorts,
|
||||
RouteThroughXray: c.RouteThroughXray,
|
||||
RouteOutboundTag: c.RouteOutboundTag,
|
||||
Secret: c.Secret,
|
||||
AdTag: c.AdTag,
|
||||
}
|
||||
if c.Reverse != nil {
|
||||
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||
@@ -1072,14 +1078,16 @@ func (r *ClientRecord) ToClient() *Client {
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
|
||||
PrivateKey: r.PrivateKey,
|
||||
PublicKey: r.PublicKey,
|
||||
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
||||
PreSharedKey: r.PreSharedKey,
|
||||
KeepAlive: r.KeepAlive,
|
||||
ForwardedPorts: r.ForwardedPorts,
|
||||
Secret: r.Secret,
|
||||
AdTag: r.AdTag,
|
||||
PrivateKey: r.PrivateKey,
|
||||
PublicKey: r.PublicKey,
|
||||
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
||||
PreSharedKey: r.PreSharedKey,
|
||||
KeepAlive: r.KeepAlive,
|
||||
ForwardedPorts: r.ForwardedPorts,
|
||||
RouteThroughXray: r.RouteThroughXray,
|
||||
RouteOutboundTag: r.RouteOutboundTag,
|
||||
Secret: r.Secret,
|
||||
AdTag: r.AdTag,
|
||||
}
|
||||
if r.Reverse != "" {
|
||||
var rev ClientReverse
|
||||
@@ -1256,6 +1264,18 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
||||
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 incomingNewer || existing.Comment == "" {
|
||||
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -327,6 +328,15 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
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
|
||||
// the subscription merge so subscription outbound tags are valid targets.
|
||||
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
|
||||
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
||||
// template so that manually configured outbounds are never overwritten.
|
||||
|
||||
@@ -3,8 +3,10 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"amneziaWgForwardedPorts": "Forwarded Ports",
|
||||
"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",
|
||||
"mtprotoSecret": "MTProto secret",
|
||||
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
|
||||
|
||||
@@ -922,6 +922,11 @@
|
||||
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
|
||||
"amneziaWgForwardedPorts": "Проброс портов",
|
||||
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
|
||||
"amneziaWgRouteThroughXray": "Маршрутизировать через Xray",
|
||||
"amneziaWgRouteThroughXrayHint": "Направлять трафик этого клиента через Xray вместо прямого выхода через сетевой интерфейс сервера.",
|
||||
"amneziaWgRouteOutboundTag": "Исходящий",
|
||||
"amneziaWgRouteOutboundTagHint": "Через какой исходящий (outbound) или балансировщик Xray выходит трафик этого клиента. Оставьте пустым, чтобы использовать маршрутизацию Xray по умолчанию.",
|
||||
"amneziaWgRouteOutboundTagPlaceholder": "Выберите исходящий",
|
||||
"amneziaWgConfig": "Конфиг AmneziaWG",
|
||||
"mtprotoSecret": "Секрет MTProto",
|
||||
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
|
||||
|
||||
Reference in New Issue
Block a user