fix(amneziawg): allow TPROXY-marked traffic through a default-deny INPUT chain

TPROXY never rewrites a packet's own destination address, only the routing
decision. A default-deny firewall whose INPUT chain sanity-checks "is this
destination actually local" (UFW's ufw-not-local, via addrtype --dst-type
LOCAL, is a concrete example) silently drops the redirected packet before
Xray's socket ever sees it -- RouteThroughXray looked fully configured
(TPROXY rule present and counting, Xray listening with IP_TRANSPARENT set)
yet every peer's traffic vanished with no trace on either side.

Adds an idempotent, never-torn-down "iptables -I INPUT 1 -m mark --mark
<fwmark> -j ACCEPT" alongside the existing shared policy route, so this
works regardless of which firewall manager owns the rest of the INPUT chain.
This commit is contained in:
Kuzz007
2026-07-27 22:33:09 +03:00
parent bdee0a2068
commit fef9a4b20a
2 changed files with 47 additions and 0 deletions
+18
View File
@@ -734,9 +734,27 @@ func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
// 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.
//
// TPROXY never rewrites the packet's own destination address — only
// the routing decision changes, via the fwmark+table trick above — so
// by the time this packet reaches the host's own INPUT chain, its
// destination still looks like some remote address (e.g. 8.8.8.8),
// never this host's own. A default-deny firewall whose INPUT chain
// sanity-checks "is this destination actually local" (UFW's
// ufw-not-local, using addrtype --dst-type LOCAL, is exactly this) can
// never see it as legitimate and silently drops it before Xray's
// socket ever sees a single byte — TPROXY's own counters keep
// incrementing the whole time, making this look like a Xray-side bug
// even though Xray never gets the chance to fail. The fix is the same
// shape as the policy route above: an idempotent, never-torn-down,
// system-wide accept for this fwmark, inserted at the very front of
// the base INPUT chain so it runs before any such sanity check,
// regardless of which firewall manager (ufw, firewalld, bare
// iptables) owns the rest of that chain.
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),
fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark, EgressFwmark),
)
}
}
+29
View File
@@ -401,6 +401,35 @@ func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testi
}
}
// TPROXY never rewrites a packet's own destination address -- only the
// routing decision changes -- so a default-deny INPUT chain that sanity-checks
// "is this destination actually local" (e.g. UFW's ufw-not-local, via
// addrtype --dst-type LOCAL) drops it before Xray's socket ever sees it, even
// though TPROXY's own mangle-table counters keep incrementing the whole time.
// This was a real, hard-to-diagnose production outage: RouteThroughXray
// looked fully configured (TPROXY rule present, Xray socket listening with
// IP_TRANSPARENT set) yet every peer's traffic silently vanished.
func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x
inst.RouteThroughXray = true
up, down := defaultPostUpDown(inst, "eth0")
wantCheck := fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT", EgressFwmark)
wantInsert := fmt.Sprintf("iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark)
if !strings.Contains(up, wantCheck) || !strings.Contains(up, wantInsert) {
t.Errorf("expected an idempotent INPUT accept for the shared fwmark in PostUp, got:\n%s", up)
}
if strings.Contains(down, "-m mark --mark") {
t.Error("the shared INPUT accept must never be removed in PostDown -- other instances may still need it, same as the policy route")
}
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
upNone, _ := defaultPostUpDown(none, "eth0")
if strings.Contains(upNone, "-m mark --mark") {
t.Errorf("an instance with no peers must not emit the INPUT accept either, got:\n%s", upNone)
}
}
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
inst := baseInstance()
inst.ExternalInterface = "eth0"