From fef9a4b20a2dd48065b19754f448fc3cd8d6863a Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Mon, 27 Jul 2026 22:33:09 +0300 Subject: [PATCH] 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 -j ACCEPT" alongside the existing shared policy route, so this works regardless of which firewall manager owns the rest of the INPUT chain. --- internal/amneziawg/manager.go | 18 ++++++++++++++++++ internal/amneziawg/manager_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go index c2a55a00c..aea9ddc71 100644 --- a/internal/amneziawg/manager.go +++ b/internal/amneziawg/manager.go @@ -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), ) } } diff --git a/internal/amneziawg/manager_test.go b/internal/amneziawg/manager_test.go index 816a354d2..98cfc3749 100644 --- a/internal/amneziawg/manager_test.go +++ b/internal/amneziawg/manager_test.go @@ -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"