feat(amneziawg): make the Xray TPROXY bridge a per-inbound opt-in

Addresses Finding 10 from the automated PR review: an always-on TPROXY
bridge makes every AmneziaWG tunnel hard-depend on Xray being up (all
traffic, including DNS, drops whenever Xray restarts), and forces a full
awg-quick down+up bounce on any client add/remove/re-IP, permanently
losing the syncconf fast path.

Adds ServerSettings.RouteThroughXray (off by default):

- defaultPostUpDown only emits the TPROXY/policy-route rules when it's
  on; a plain AmneziaWG tunnel now has zero Xray dependency out of the
  box.
- structuralFingerprint covers it (toggling it changes whether PostUp/
  PostDown contain any TPROXY rules at all -- structural, not a
  per-peer host-rule). hostRulesFingerprint's IPv4 tracking is now
  itself conditional on RouteThroughXray (and IPv6 tracking on
  IPv6Enabled), so an instance that never uses either keeps the
  syncconf fast path for a plain peer re-IP.
- injectAmneziawgEgress only creates a bridge for inbounds that opted
  in; checkAmneziawgEgressConflict (the Finding-7 fix) now parses each
  candidate through InstanceFromInbound so a non-routed inbound's port
  is correctly never treated as reserved.
- New inbound-level Switch in the AmneziaWG form; the actual outbound
  decision is still made entirely through the panel's stock Routing
  page, same as before -- only whether the bridge exists at all is now
  a choice.

Translation keys added to all 13 locales in the same commit this time,
not backfilled later (see Finding 9's lesson).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-26 00:39:32 +03:00
parent c41f97cf86
commit 71dc453970
28 changed files with 257 additions and 85 deletions
+4
View File
@@ -2862,6 +2862,10 @@
"publicKey": {
"type": "string"
},
"routeThroughXray": {
"description": "RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see\nInstance.RouteThroughXray for what that means. Off by default.",
"type": "boolean"
},
"s1": {
"type": "integer"
},
+1
View File
@@ -666,6 +666,7 @@ export const EXAMPLES: Record<string, unknown> = {
"primaryDns": "",
"privateKey": "",
"publicKey": "",
"routeThroughXray": false,
"s1": 0,
"s2": 0,
"s3": 0,
+4
View File
@@ -2836,6 +2836,10 @@ export const SCHEMAS: Record<string, unknown> = {
"publicKey": {
"type": "string"
},
"routeThroughXray": {
"description": "RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see\nInstance.RouteThroughXray for what that means. Off by default.",
"type": "boolean"
},
"s1": {
"type": "integer"
},
+1
View File
@@ -649,6 +649,7 @@ export interface ServerSettings {
primaryDns?: string;
privateKey: string;
publicKey: string;
routeThroughXray?: boolean;
s1: number;
s2: number;
s3: number;
+1
View File
@@ -688,6 +688,7 @@ export const ServerSettingsSchema = z.object({
primaryDns: z.string().optional(),
privateKey: z.string(),
publicKey: z.string(),
routeThroughXray: z.boolean().optional(),
s1: z.number().int(),
s2: z.number().int(),
s3: z.number().int(),
@@ -298,6 +298,7 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
ipv6Enabled: false,
ipv6Subnet: '',
ipv6ExternalInterface: '',
routeThroughXray: false,
jc: 5,
jmin: 10,
jmax: 50,
@@ -68,6 +68,14 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou
>
<Input placeholder="eth0" />
</FormField>
<FormField
name={['settings', 'server', 'routeThroughXray']}
label={t('pages.xray.amneziawg.routeThroughXray')}
extra={t('pages.xray.amneziawg.routeThroughXrayHint')}
valueProp="checked"
>
<Switch />
</FormField>
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
{t('pages.xray.amneziawg.regenerateObfuscation')}
@@ -52,6 +52,7 @@ export const AmneziawgServerSchema = z.object({
ipv6Enabled: z.boolean().default(false),
ipv6Subnet: z.string().default(''),
ipv6ExternalInterface: z.string().default(''),
routeThroughXray: z.boolean().default(false),
jc: z.number().int().min(0).default(5),
jmin: z.number().int().min(0).default(10),
jmax: z.number().int().min(0).default(50),
+63 -53
View File
@@ -85,6 +85,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
ExternalInterface: server.ExternalInterface,
IPv6Enabled: server.IPv6Enabled,
IPv6ExternalInterface: server.IPv6ExternalInterface,
RouteThroughXray: server.RouteThroughXray,
}, true
}
@@ -136,6 +137,7 @@ func (inst Instance) structuralFingerprint() string {
inst.ExternalInterface,
strconv.FormatBool(inst.IPv6Enabled),
inst.IPv6ExternalInterface,
strconv.FormatBool(inst.RouteThroughXray),
}
return strings.Join(parts, "|")
}
@@ -157,26 +159,30 @@ func (inst Instance) peersFingerprint() string {
}
// hostRulesFingerprint identifies per-peer state that only ever takes effect
// through PostUp/PostDown shell rules — forwarded ports, the peer's IPv6
// address (its NDP-proxy PostUp/PostDown entry), and (unconditionally, for
// every peer with a usable IPv4 address) the TPROXY rule into this
// instance's own Xray bridge — 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-only change that syncconf can apply in place. Since the TPROXY rule is
// now tied to every peer's mere presence (there's no more per-peer opt-in
// flag), every peer is included unconditionally: adding, removing, or
// re-addressing a peer now also forces a bounce, the same way a
// ForwardedPorts-only change always did. The IPv6 address must be included
// here too: without it, a peer that only changes its IPv6 AllowedIPs entry
// still matches on FirstIPv4 alone, so ensureActionFor would pick the
// syncconf reload path — which never re-runs PostUp — leaving that peer's
// NDP-proxy entry pointed at its old, now-wrong address.
// through PostUp/PostDown shell rules — forwarded ports; when
// RouteThroughXray is on, every peer's IPv4 address (the TPROXY rule into
// this instance's own Xray bridge is keyed on it); and when IPv6 is enabled,
// the peer's IPv6 address (its NDP-proxy PostUp/PostDown entry) — 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-only change that syncconf can apply in
// place. The IPv4/IPv6 components are each included only when the feature
// that actually reads them is on: including them unconditionally would force
// a full bounce on every peer add/remove/re-IP even for an instance whose
// PostUp/PostDown text never changes as a result, permanently losing the
// syncconf fast path for no reason.
func (inst Instance) hostRulesFingerprint() string {
pairs := make([]string, 0, len(inst.Peers))
for _, p := range inst.Peers {
pairs = append(pairs, fmt.Sprintf("%s=fwd:%s;ip:%s;ip6:%s", p.Email, p.ForwardedPorts, FirstIPv4(p.AllowedIPs), firstIPv6(p.AllowedIPs)))
v := fmt.Sprintf("%s=fwd:%s", p.Email, p.ForwardedPorts)
if inst.RouteThroughXray {
v += ";ip:" + FirstIPv4(p.AllowedIPs)
}
if inst.IPv6Enabled {
v += ";ip6:" + firstIPv6(p.AllowedIPs)
}
pairs = append(pairs, v)
}
slices.Sort(pairs)
return strings.Join(pairs, "|")
@@ -612,15 +618,16 @@ 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, and —
// unconditionally, for every peer — a mangle-table TPROXY rule redirecting
// that peer's traffic into this instance's own Xray bridge (see
// EgressPortForInbound), plus the one-time policy route TPROXY needs to
// deliver it there. There is no per-peer or per-inbound opt-in: the bridge
// is always present by default, and it is entirely up to the admin's own
// Xray Routing rules (targeting this inbound's own tag, which
// injectAmneziawgEgress reuses for the bridge) whether that traffic ever
// actually goes anywhere beyond Xray's default routing.
// for each enabled peer with a non-empty ForwardedPorts spec, and — only
// when the instance has RouteThroughXray enabled — a mangle-table TPROXY
// rule redirecting every peer's traffic into this instance's own Xray
// bridge (see EgressPortForInbound), plus the one-time policy route TPROXY
// needs to deliver it there. RouteThroughXray is off by default: a plain
// AmneziaWG tunnel has no Xray dependency at all unless the admin opts in.
// When it is on, it is entirely up to the admin's own Xray Routing rules
// (targeting this inbound's own tag, which injectAmneziawgEgress reuses for
// the bridge) whether that traffic ever actually goes anywhere beyond
// Xray's default routing.
func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
iface := inst.InterfaceName
up := []string{
@@ -675,34 +682,37 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
down = append(down, portForwardLines("-D", ext, iface, clientIP, p.Email, p.ForwardedPorts)...)
}
egressPort := EgressPortForInbound(inst.Id)
anyPeerTproxied := false
for _, p := range inst.Peers {
clientIP := FirstIPv4(p.AllowedIPs)
if clientIP == "" {
continue
if inst.RouteThroughXray {
egressPort := EgressPortForInbound(inst.Id)
anyPeerTproxied := false
for _, p := range inst.Peers {
clientIP := FirstIPv4(p.AllowedIPs)
if clientIP == "" {
continue
}
up = append(up, routeEgressLines("-A", iface, clientIP, p.Email, egressPort)...)
down = append(down, routeEgressLines("-D", iface, clientIP, p.Email, egressPort)...)
anyPeerTproxied = true
}
if anyPeerTproxied {
// The fwmark->table->local-everywhere policy route is what lets TPROXY
// deliver a peer's packets to this instance's own 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 must find
// it already in place, not race to remove what the first still needs.
// "ip rule add" is not itself idempotent (a second call inserts a
// duplicate rather than deduplicating), and hostRulesFingerprint keys
// on every peer's presence/IP when RouteThroughXray is on, so PostUp
// re-runs on any client add/remove/re-IP — without the existence
// check below, "ip rule show" would accumulate one duplicate entry
// per bounce forever.
up = append(up,
fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d' || ip rule add fwmark %#x lookup %d", EgressFwmark, EgressTable, EgressFwmark, EgressTable),
fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable),
)
}
up = append(up, routeEgressLines("-A", iface, clientIP, p.Email, egressPort)...)
down = append(down, routeEgressLines("-D", iface, clientIP, p.Email, egressPort)...)
anyPeerTproxied = true
}
if anyPeerTproxied {
// The fwmark->table->local-everywhere policy route is what lets TPROXY
// deliver a peer's packets to this instance's own 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 must find
// it already in place, not race to remove what the first still needs.
// "ip rule add" is not itself idempotent (a second call inserts a
// duplicate rather than deduplicating), and hostRulesFingerprint keys
// on every peer's presence/IP, so PostUp re-runs on any client
// add/remove/re-IP — without the existence check below, "ip rule
// show" would accumulate one duplicate entry per bounce forever.
up = append(up,
fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d' || ip rule add fwmark %#x lookup %d", EgressFwmark, EgressTable, 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")
+57 -16
View File
@@ -171,6 +171,12 @@ func TestStructuralFingerprintStableAndSensitive(t *testing.T) {
if e.structuralFingerprint() == f.structuralFingerprint() {
t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op")
}
g := baseInstance()
g.RouteThroughXray = true
if a.structuralFingerprint() == g.structuralFingerprint() {
t.Fatal("toggling RouteThroughXray must change the structural fingerprint -- it changes whether PostUp/PostDown contain any TPROXY rules at all")
}
}
func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
@@ -225,9 +231,6 @@ func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
t.Fatal("identical instances must produce the same host-rules fingerprint")
}
if a.hostRulesFingerprint() == "" {
t.Fatal("every peer always gets a TPROXY rule now, so the fingerprint must never be empty when peers exist")
}
forwarded := baseInstance()
forwarded.Peers[0].ForwardedPorts = "80,443"
@@ -235,22 +238,47 @@ func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
}
reIPed := baseInstance()
reIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if a.hostRulesFingerprint() == reIPed.hostRulesFingerprint() {
t.Fatal("changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP")
}
fewer := baseInstance()
fewer.Peers = fewer.Peers[:1]
if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() {
t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer TPROXY rule is needed")
t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer peer entry exists regardless of what's tracked per peer")
}
// RouteThroughXray off (baseInstance's default): no TPROXY rule depends
// on a peer's IPv4 address, so re-IPing one must NOT force a bounce --
// this is the whole point of making the bridge opt-in: an instance that
// never uses it keeps the syncconf fast path for a plain re-IP.
reIPedNoRoute := baseInstance()
reIPedNoRoute.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if a.hostRulesFingerprint() != reIPedNoRoute.hostRulesFingerprint() {
t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint")
}
// RouteThroughXray on: now the TPROXY rule really is keyed on the IP.
routed := baseInstance()
routed.RouteThroughXray = true
routedReIPed := baseInstance()
routedReIPed.RouteThroughXray = true
routedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if routed.hostRulesFingerprint() == routedReIPed.hostRulesFingerprint() {
t.Fatal("with RouteThroughXray on, changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP")
}
// IPv6Enabled off (baseInstance's default): no NDP-proxy entry depends
// on a peer's IPv6 address either, so adding one must not force a bounce.
ip6AddedNoIPv6 := baseInstance()
ip6AddedNoIPv6.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
if a.hostRulesFingerprint() != ip6AddedNoIPv6.hostRulesFingerprint() {
t.Fatal("with IPv6Enabled off, adding a peer's IPv6 address must NOT change the host-rules fingerprint")
}
ip6Base := baseInstance()
ip6Base.IPv6Enabled = true
ip6Added := baseInstance()
ip6Added.IPv6Enabled = true
ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
if a.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() {
t.Fatal("adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp")
if ip6Base.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() {
t.Fatal("with IPv6Enabled on, adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp")
}
}
@@ -315,8 +343,21 @@ func TestEgressPortForInbound(t *testing.T) {
}
}
func TestDefaultPostUpDownEmitsTproxyForEveryPeer(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x, no opt-in flag exists anymore
func TestDefaultPostUpDownOmitsTproxyWhenRouteThroughXrayOff(t *testing.T) {
inst := baseInstance() // RouteThroughXray defaults to false
up, down := defaultPostUpDown(inst, "eth0")
if strings.Contains(up, "TPROXY") || strings.Contains(up, "ip rule add fwmark") {
t.Errorf("RouteThroughXray off must emit no TPROXY/policy-route lines in PostUp, got:\n%s", up)
}
if strings.Contains(down, "TPROXY") {
t.Errorf("RouteThroughXray off must emit no TPROXY lines in PostDown, got:\n%s", down)
}
}
func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x
inst.RouteThroughXray = true
up, down := defaultPostUpDown(inst, "eth0")
wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id))
@@ -335,12 +376,12 @@ func TestDefaultPostUpDownEmitsTproxyForEveryPeer(t *testing.T) {
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")
}
// Both peers always get TPROXY'd now, no opt-in: 2 peers * 2 protocols.
// Both peers get TPROXY'd once opted in: 2 peers * 2 protocols.
if got := strings.Count(up, "TPROXY"); got != 4 {
t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up)
}
none := Instance{Id: 2, InterfaceName: "awg2"} // no peers at all
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
upNone, _ := defaultPostUpDown(none, "eth0")
if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") {
t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone)
+16
View File
@@ -68,6 +68,18 @@ type Instance struct {
// entries specifically; empty means reuse ExternalInterface.
IPv6Enabled bool
IPv6ExternalInterface string
// RouteThroughXray gates the entire TPROXY-into-Xray bridge (see
// EgressPortForInbound / injectAmneziawgEgress) for this instance: off by
// default, so a plain AmneziaWG tunnel never depends on Xray being up at
// all. Turning it on makes every peer's traffic TPROXY'd into this
// instance's own loopback Xray bridge, tagged with the inbound's own
// tag; the actual routing decision from there is left entirely to the
// panel's stock Routing page (pick this inbound's tag as source, an
// outbound, and optionally a peer's IP), exactly like routing any other
// protocol -- only whether the bridge exists at all is a per-inbound
// choice.
RouteThroughXray bool
}
// ServerSettings is the "server" block of an AmneziaWG inbound's Settings
@@ -101,6 +113,10 @@ type ServerSettings struct {
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
// RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see
// Instance.RouteThroughXray for what that means. Off by default.
RouteThroughXray bool `json:"routeThroughXray,omitempty"`
// Obfuscation20's fields, repeated flat (not embedded) rather than
// nested under their own key: encoding/json would happily inline an
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
+10 -3
View File
@@ -231,9 +231,12 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
// checkAmneziawgEgressConflict reports whether inbound's own port collides
// with an existing, enabled local AmneziaWG inbound's automatic Xray bridge
// port. ignoreId excludes one inbound id from the AmneziaWG candidates, the
// same way the general DB-backed conflict query above excludes the inbound
// being edited from matching itself.
// port. Only inbounds that actually have RouteThroughXray on ever get a
// bridge (see injectAmneziawgEgress); the others' "reserved" port isn't
// really reserved, so they must not be flagged. ignoreId excludes one
// inbound id from the AmneziaWG candidates, the same way the general
// DB-backed conflict query above excludes the inbound being edited from
// matching itself.
func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
db := database.GetDB()
var candidates []*model.Inbound
@@ -245,6 +248,10 @@ func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ig
return nil, err
}
for _, c := range candidates {
inst, ok := amneziawg.InstanceFromInbound(c)
if !ok || !inst.RouteThroughXray {
continue
}
if amneziawg.EgressPortForInbound(c.Id) != inbound.Port {
continue
}
+34 -2
View File
@@ -731,13 +731,19 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
}
}
// amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings
// blob with one qualifying, enabled peer and RouteThroughXray on -- the
// shape that actually makes injectAmneziawgEgress (and therefore
// checkAmneziawgEgressConflict) create a bridge at all.
const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
// An enabled AmneziaWG inbound's automatic Xray bridge (injectAmneziawgEgress)
// is a synthetic loopback dokodemo-door inbound, not a database row, so
// checkPortConflict needs its own check to catch a collision -- exactly the
// same shape of problem as the reserved API port above.
func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
@@ -769,7 +775,7 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
// 127.0.0.1 on the local panel's own Xray.
func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
@@ -813,6 +819,32 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T
}
}
// An enabled AmneziaWG inbound with RouteThroughXray off never gets a bridge
// injected either (injectAmneziawgEgress requires it), so its port isn't
// reserved -- an inbound created with the default settings, not just an
// explicitly disabled one, must not block anything.
func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-bridge",
Listen: "0.0.0.0",
Port: bridgePort,
Protocol: model.VLESS,
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("an AmneziaWG inbound with RouteThroughXray off must not reserve its bridge port; got=%v err=%v", got, err)
}
}
// An unrelated port never conflicts with the bridge.
func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.T) {
setupConflictDB(t)
+13 -10
View File
@@ -645,18 +645,21 @@ const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedi
// socket.
const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}`
// injectAmneziawgEgress gives every enabled AmneziaWG inbound with at least
// one qualifying peer its own loopback dokodemo-door bridge — tagged with
// that inbound's own real tag, so it's already selectable in the panel's
// stock Routing page's inbound-tag picker, exactly the way an mtproto
// inbound's own bridge already is (see injectMtprotoEgress): the picker's
// tag list comes from InboundService.GetInboundTags(), a plain,
// injectAmneziawgEgress gives every enabled, RouteThroughXray-opted-in
// AmneziaWG inbound with at least one qualifying peer its own loopback
// dokodemo-door bridge — tagged with that inbound's own real tag, so it's
// already selectable in the panel's stock Routing page's inbound-tag
// picker, exactly the way an mtproto inbound's own bridge already is (see
// injectMtprotoEgress): the picker's tag list comes from
// InboundService.GetInboundTags(), a plain,
// protocol-blind SELECT over every inbound row's tag, so reusing a real
// inbound's own tag needs no dedicated UI plumbing at all.
//
// Every peer's traffic always lands on the bridge — internal/amneziawg's
// defaultPostUpDown TPROXYs it there unconditionally, there is no per-peer
// or per-inbound opt-in flag — but this function never generates a routing
// RouteThroughXray is a per-inbound opt-in, off by default: when it's off,
// no bridge is created at all and the tunnel has no Xray dependency
// whatsoever. When it's on, every peer's traffic lands on the bridge —
// internal/amneziawg's defaultPostUpDown TPROXYs it there, there is no
// further per-peer opt-in — but this function never generates a routing
// rule of its own. Whether that traffic goes anywhere beyond Xray's default
// routing is entirely up to whatever rules the admin adds through that same
// stock Routing page (inboundTag + an optional sourceIP to target one
@@ -680,7 +683,7 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
continue
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
if !ok || !inst.RouteThroughXray {
continue
}
hasQualifyingPeer := false
@@ -561,7 +561,7 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
}
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: true}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
@@ -643,6 +643,22 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) {
}
}
func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) {
cfg := egressTestConfig()
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} // RouteThroughXray left false
settings, _ := json.Marshal(amneziawg.InboundSettings{
Server: &server,
Clients: []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
},
})
inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("an inbound with RouteThroughXray off must never get a bridge, got %+v", cfg.InboundConfigs)
}
}
func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) {
cfg := egressTestConfig()
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "مثل fd86:ea04:1115::/64. مطلوب عند تفعيل IPv6.",
"ipv6ExternalInterface": "الواجهة الخارجية لـ IPv6",
"ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم لإدخالات وكيل NDP. اتركها فارغة لاستخدام الواجهة الخارجية.",
"routeThroughXray": "التوجيه عبر Xray",
"routeThroughXrayHint": "معطّل افتراضيًا: لا يعتمد النفق على Xray إطلاقًا. عند التفعيل، يُعاد توجيه ترافيك كل عميل إلى جسر Xray الخاص بهذا الاتصال الوارد، ويُختار الاتصال الصادر الفعلي من صفحة التوجيه تمامًا مثل أي بروتوكول آخر.",
"obfuscation": "معاملات التمويه",
"regenerateObfuscation": "إعادة التوليد",
"jc": "Jc (عدد الحزم العشوائية)",
+2
View File
@@ -1940,6 +1940,8 @@
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
"ipv6ExternalInterface": "IPv6 External Interface",
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
"routeThroughXray": "Route through Xray",
"routeThroughXrayHint": "Off by default: the tunnel has no dependency on Xray at all. When on, every client's traffic is redirected into this inbound's own Xray bridge, and the actual outbound is chosen from the Routing page like any other protocol.",
"obfuscation": "Obfuscation parameters",
"regenerateObfuscation": "Regenerate",
"jc": "Jc (junk packet count)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "p. ej. fd86:ea04:1115::/64. Obligatorio cuando IPv6 está habilitado.",
"ipv6ExternalInterface": "Interfaz externa IPv6",
"ipv6ExternalInterfaceHint": "Interfaz de red del host para las entradas de proxy NDP. Déjalo vacío para reutilizar la interfaz externa.",
"routeThroughXray": "Enrutar a través de Xray",
"routeThroughXrayHint": "Desactivado de forma predeterminada: el túnel no depende de Xray en absoluto. Al activarlo, el tráfico de cada cliente se redirige al puente Xray propio de esta entrada, y la salida real se elige desde la página de Enrutamiento igual que con cualquier otro protocolo.",
"obfuscation": "Parámetros de ofuscación",
"regenerateObfuscation": "Regenerar",
"jc": "Jc (cantidad de paquetes basura)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "مثلاً fd86:ea04:1115::/64. هنگام فعال بودن IPv6 الزامی است.",
"ipv6ExternalInterface": "رابط خارجی IPv6",
"ipv6ExternalInterfaceHint": "رابط شبکه میزبان برای ورودی‌های پراکسی NDP. برای استفاده از رابط خارجی خالی بگذارید.",
"routeThroughXray": "مسیریابی از طریق Xray",
"routeThroughXrayHint": "به‌طور پیش‌فرض خاموش است: تونل هیچ وابستگی‌ای به Xray ندارد. با روشن‌کردن آن، ترافیک هر کلاینت به پل Xray مخصوص همین اینباند هدایت می‌شود و مسیر خروجی واقعی از صفحه مسیریابی، دقیقاً مثل هر پروتکل دیگر، انتخاب می‌شود.",
"obfuscation": "پارامترهای مبهم‌سازی",
"regenerateObfuscation": "بازتولید",
"jc": "Jc (تعداد بسته‌های زباله)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "mis. fd86:ea04:1115::/64. Wajib diisi saat IPv6 diaktifkan.",
"ipv6ExternalInterface": "NIC Eksternal IPv6",
"ipv6ExternalInterfaceHint": "NIC host untuk entri proxy NDP. Biarkan kosong untuk menggunakan NIC Eksternal.",
"routeThroughXray": "Rutekan melalui Xray",
"routeThroughXrayHint": "Nonaktif secara default: tunnel tidak bergantung pada Xray sama sekali. Jika diaktifkan, trafik setiap klien dialihkan ke bridge Xray milik inbound ini sendiri, dan outbound sebenarnya dipilih dari halaman Routing seperti protokol lainnya.",
"obfuscation": "Parameter obfuskasi",
"regenerateObfuscation": "Buat ulang",
"jc": "Jc (jumlah paket sampah)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "例: fd86:ea04:1115::/64。IPv6有効時は必須。",
"ipv6ExternalInterface": "IPv6外部NIC",
"ipv6ExternalInterfaceHint": "NDPプロキシエントリに使用するホストのNIC。空欄で外部NICを使用。",
"routeThroughXray": "Xray経由でルーティング",
"routeThroughXrayHint": "デフォルトではオフです。オフの場合トンネルはXrayに一切依存しません。オンにすると、各クライアントのトラフィックはこのインバウンド専用のXrayブリッジへリダイレクトされ、実際のアウトバウンドは他のプロトコルと同様にルーティングページから選択します。",
"obfuscation": "難読化パラメータ",
"regenerateObfuscation": "再生成",
"jc": "Jc(ジャンクパケット数)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "ex. fd86:ea04:1115::/64. Obrigatório quando o IPv6 está ativado.",
"ipv6ExternalInterface": "Interface externa IPv6",
"ipv6ExternalInterfaceHint": "Interface de rede do host para as entradas de proxy NDP. Deixe vazio para reutilizar a interface externa.",
"routeThroughXray": "Rotear pelo Xray",
"routeThroughXrayHint": "Desativado por padrão: o túnel não depende do Xray de forma alguma. Quando ativado, o tráfego de cada cliente é redirecionado para a própria ponte Xray desta entrada, e a saída real é escolhida na página de Roteamento, como em qualquer outro protocolo.",
"obfuscation": "Parâmetros de ofuscação",
"regenerateObfuscation": "Regenerar",
"jc": "Jc (quantidade de pacotes de lixo)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
"routeThroughXray": "Маршрутизировать через Xray",
"routeThroughXrayHint": "По умолчанию выключено: туннель никак не зависит от Xray. Если включить, трафик каждого клиента перенаправляется в собственный мост Xray этого входящего соединения, а фактический исходящий выбирается на странице «Маршрутизация», как и для любого другого протокола.",
"obfuscation": "Параметры обфускации",
"regenerateObfuscation": "Сгенерировать заново",
"jc": "Jc (кол-во мусорных пакетов)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "örn. fd86:ea04:1115::/64. IPv6 etkinken zorunludur.",
"ipv6ExternalInterface": "IPv6 Harici Arayüzü",
"ipv6ExternalInterfaceHint": "NDP proxy girişleri için sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.",
"routeThroughXray": "Xray üzerinden yönlendir",
"routeThroughXrayHint": "Varsayılan olarak kapalıdır: tünelin Xray'e hiçbir bağımlılığı yoktur. Açıldığında, her istemcinin trafiği bu gelen bağlantıya ait Xray köprüsüne yönlendirilir ve gerçek giden bağlantı, diğer tüm protokollerde olduğu gibi Yönlendirme sayfasından seçilir.",
"obfuscation": "Gizleme parametreleri",
"regenerateObfuscation": "Yeniden oluştur",
"jc": "Jc (gereksiz paket sayısı)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "напр. fd86:ea04:1115::/64. Обов'язково, якщо IPv6 увімкнено.",
"ipv6ExternalInterface": "Зовнішній інтерфейс IPv6",
"ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста для записів NDP-проксі. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.",
"routeThroughXray": "Маршрутизувати через Xray",
"routeThroughXrayHint": "За замовчуванням вимкнено: тунель жодним чином не залежить від Xray. Якщо увімкнено, трафік кожного клієнта перенаправляється у власний міст Xray цього вхідного з'єднання, а фактичне вихідне з'єднання обирається на сторінці Маршрутизація, як і для будь-якого іншого протоколу.",
"obfuscation": "Параметри обфускації",
"regenerateObfuscation": "Згенерувати заново",
"jc": "Jc (кількість сміттєвих пакетів)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "vd. fd86:ea04:1115::/64. Bắt buộc khi bật IPv6.",
"ipv6ExternalInterface": "Card mạng ngoài IPv6",
"ipv6ExternalInterfaceHint": "Card mạng của host dùng cho các mục NDP proxy. Để trống để dùng lại Card mạng ngoài.",
"routeThroughXray": "Định tuyến qua Xray",
"routeThroughXrayHint": "Mặc định tắt: tunnel hoàn toàn không phụ thuộc vào Xray. Khi bật, lưu lượng của mỗi client sẽ được chuyển hướng vào cầu nối Xray riêng của inbound này, và outbound thực tế được chọn từ trang Định tuyến giống như mọi giao thức khác.",
"obfuscation": "Tham số làm rối (obfuscation)",
"regenerateObfuscation": "Tạo lại",
"jc": "Jc (số lượng gói rác)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。启用 IPv6 时必填。",
"ipv6ExternalInterface": "IPv6 外部网卡",
"ipv6ExternalInterfaceHint": "用于 NDP 代理条目的主机网卡。留空则使用外部网卡。",
"routeThroughXray": "通过 Xray 路由",
"routeThroughXrayHint": "默认关闭:隧道完全不依赖 Xray。开启后,每个客户端的流量都会被重定向到该入站自己的 Xray 网桥,实际的出站则和其他协议一样,在路由页面中选择。",
"obfuscation": "混淆参数",
"regenerateObfuscation": "重新生成",
"jc": "Jc(垃圾包数量)",
+2
View File
@@ -1823,6 +1823,8 @@
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。啟用 IPv6 時必填。",
"ipv6ExternalInterface": "IPv6 外部網路介面",
"ipv6ExternalInterfaceHint": "用於 NDP 代理項目的主機網路介面。留空則使用外部網路介面。",
"routeThroughXray": "透過 Xray 路由",
"routeThroughXrayHint": "預設關閉:通道完全不依賴 Xray。啟用後,每個客戶端的流量都會被重新導向到該入站自己的 Xray 橋接,實際的出站則和其他協定一樣,在路由頁面中選擇。",
"obfuscation": "混淆參數",
"regenerateObfuscation": "重新產生",
"jc": "Jc(垃圾封包數量)",