netcfg: add -connmark flag for DNAT reply routing

When VPN traffic is DNAT'd to local namespaces/VMs, reply packets have
a different source IP (namespace veth) so the policy route's
"from <VPN_IP>" rule doesn't match. CONNMARK marks all connections
arriving on the VPN interface and restores the mark on reply packets,
routing them back through the tunnel via fwmark rule.

New flag: -connmark (requires -policy-route-table)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Git Sagar 2026-06-07 01:00:43 +05:30
parent 857733863c
commit 51824b830e
5 changed files with 51 additions and 7 deletions

View file

@ -21,6 +21,7 @@ type Options struct {
AcceptStaticRoutes bool
AcceptDNS bool
PolicyRouteTable int
ConnMark bool
}
// ConfigureTAP sets the IP address, routes, and DNS on a TAP interface from a DHCP lease.
@ -94,11 +95,16 @@ func ReconfigureTAP(ifname string, lease *dhcp.Lease, acceptDefaultGW, acceptSta
// ConfigurePolicyRoute sets up policy routing so packets from the VPN IP are routed
// back through the VPN gateway. Needed when the VPN server forwards ports to the
// client — without it, reply packets use the default route instead of the VPN tunnel.
func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int) func() {
//
// When connmark is true, also sets up CONNMARK rules so that DNAT'd connections
// (e.g. port forwards to namespaces/VMs) have their reply traffic routed back
// through the tunnel.
func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int, connmark bool) func() {
t := fmt.Sprintf("%d", table)
clientIP := lease.ClientIP.String()
gw := lease.Gateway.String()
// Policy route: packets from VPN IP use VPN gateway
runQuiet("ip", "rule", "del", "table", t)
run("ip", "route", "replace", "default", "via", gw, "dev", ifname, "table", t)
if err := run("ip", "rule", "add", "from", clientIP, "table", t); err != nil {
@ -107,9 +113,34 @@ func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int) func() {
log.Printf("policy route: from %s via %s dev %s table %s", clientIP, gw, ifname, t)
}
if connmark {
mark := t
// CONNMARK: mark connections arriving on VPN interface, restore mark on replies.
// This ensures DNAT'd traffic (forwarded to namespaces/VMs) returns via the
// tunnel instead of the default route. Without this, reply packets from DNAT
// targets (e.g. namespace veth) have a different source IP than the VPN IP,
// so the "from <VPN_IP>" policy rule doesn't match them.
log.Printf("connmark: adding CONNMARK rules on %s (mark %s)", ifname, mark)
runQuiet("ip", "rule", "del", "fwmark", mark, "table", t)
run("iptables", "-t", "mangle", "-A", "PREROUTING", "-i", ifname, "-j", "CONNMARK", "--set-mark", mark)
run("iptables", "-t", "mangle", "-A", "PREROUTING", "!", "-i", ifname, "-m", "connmark", "--mark", mark, "-j", "CONNMARK", "--restore-mark")
if err := run("ip", "rule", "add", "fwmark", mark, "table", t); err != nil {
log.Printf("warning: fwmark rule: %v", err)
} else {
log.Printf("connmark: fwmark %s → table %s for DNAT reply routing", mark, t)
}
}
return func() {
runQuiet("ip", "rule", "del", "table", t)
runQuiet("ip", "route", "del", "default", "table", t)
if connmark {
mark := t
log.Printf("connmark: removing CONNMARK rules (mark %s)", mark)
runQuiet("ip", "rule", "del", "fwmark", mark, "table", t)
runQuiet("iptables", "-t", "mangle", "-D", "PREROUTING", "-i", ifname, "-j", "CONNMARK", "--set-mark", mark)
runQuiet("iptables", "-t", "mangle", "-D", "PREROUTING", "!", "-i", ifname, "-m", "connmark", "--mark", mark, "-j", "CONNMARK", "--restore-mark")
}
log.Printf("policy route: cleaned up table %s", t)
}
}