From 65fe8464997b19e4f4ee0e60c6ad49f0e700d092 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Sun, 17 Aug 2025 16:54:19 -0400 Subject: [PATCH] Fix VPN routing by adding output interface to NAT rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NAT rules were missing the output interface specification (-o eth0), which caused routing failures on multi-homed systems (servers with multiple network interfaces). Without specifying the output interface, packets might not be NAT'd correctly. Changes: - Added -o {{ ansible_default_ipv4['interface'] }} to all NAT rules - Updated both IPv4 and IPv6 templates - Updated tests to verify output interface is present - Added ansible_default_ipv4/ipv6 to test fixtures This fixes the issue where VPN clients could connect but not route traffic to the internet on servers with multiple network interfaces (like DigitalOcean droplets with private networking enabled). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- roles/common/templates/rules.v4.j2 | 4 ++-- roles/common/templates/rules.v6.j2 | 4 ++-- tests/fixtures/test_variables.yml | 6 ++++++ tests/unit/test_iptables_rules.py | 20 ++++++++++---------- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/roles/common/templates/rules.v4.j2 b/roles/common/templates/rules.v4.j2 index 0a978094..4e1ba86f 100644 --- a/roles/common/templates/rules.v4.j2 +++ b/roles/common/templates/rules.v4.j2 @@ -38,11 +38,11 @@ COMMIT # Allow traffic from the VPN network to the outside world, and replies {% if ipsec_enabled %} # For IPsec traffic - NAT the decrypted packets from the VPN subnet --A POSTROUTING -s {{ strongswan_network }} {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 else '-j MASQUERADE' }} +-A POSTROUTING -s {{ strongswan_network }} -o {{ ansible_default_ipv4['interface'] }} {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 else '-j MASQUERADE' }} {% endif %} {% if wireguard_enabled %} # For WireGuard traffic - NAT packets from the VPN subnet --A POSTROUTING -s {{ wireguard_network_ipv4 }} {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 else '-j MASQUERADE' }} +-A POSTROUTING -s {{ wireguard_network_ipv4 }} -o {{ ansible_default_ipv4['interface'] }} {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 else '-j MASQUERADE' }} {% endif %} diff --git a/roles/common/templates/rules.v6.j2 b/roles/common/templates/rules.v6.j2 index 397bfb6c..8d90beb1 100644 --- a/roles/common/templates/rules.v6.j2 +++ b/roles/common/templates/rules.v6.j2 @@ -37,11 +37,11 @@ COMMIT # Allow traffic from the VPN network to the outside world, and replies {% if ipsec_enabled %} # For IPsec traffic - NAT the decrypted packets from the VPN subnet --A POSTROUTING -s {{ strongswan_network_ipv6 }} {{ '-j SNAT --to ' + ipv6_egress_ip | ansible.utils.ipaddr('address') if alternative_ingress_ip else '-j MASQUERADE' }} +-A POSTROUTING -s {{ strongswan_network_ipv6 }} -o {{ ansible_default_ipv6['interface'] }} {{ '-j SNAT --to ' + ipv6_egress_ip | ansible.utils.ipaddr('address') if alternative_ingress_ip else '-j MASQUERADE' }} {% endif %} {% if wireguard_enabled %} # For WireGuard traffic - NAT packets from the VPN subnet --A POSTROUTING -s {{ wireguard_network_ipv6 }} {{ '-j SNAT --to ' + ipv6_egress_ip | ansible.utils.ipaddr('address') if alternative_ingress_ip else '-j MASQUERADE' }} +-A POSTROUTING -s {{ wireguard_network_ipv6 }} -o {{ ansible_default_ipv6['interface'] }} {{ '-j SNAT --to ' + ipv6_egress_ip | ansible.utils.ipaddr('address') if alternative_ingress_ip else '-j MASQUERADE' }} {% endif %} COMMIT diff --git a/tests/fixtures/test_variables.yml b/tests/fixtures/test_variables.yml index 0af0a1fc..42e22f8b 100644 --- a/tests/fixtures/test_variables.yml +++ b/tests/fixtures/test_variables.yml @@ -72,6 +72,12 @@ CA_password: test-ca-pass # System ansible_ssh_port: 4160 ansible_python_interpreter: /usr/bin/python3 +ansible_default_ipv4: + interface: eth0 + address: 10.0.0.1 +ansible_default_ipv6: + interface: eth0 + address: 'fd9d:bc11:4020::1' BetweenClients_DROP: 'Y' ssh_tunnels_config_path: /etc/ssh/ssh_tunnels config_prefix: /etc/algo diff --git a/tests/unit/test_iptables_rules.py b/tests/unit/test_iptables_rules.py index e3eca742..7242a377 100644 --- a/tests/unit/test_iptables_rules.py +++ b/tests/unit/test_iptables_rules.py @@ -41,8 +41,8 @@ def test_wireguard_nat_rules_ipv4(): reduce_mtu=0 ) - # Verify NAT rule exists without policy matching - assert '-A POSTROUTING -s 10.49.0.0/16 -j MASQUERADE' in result + # Verify NAT rule exists with output interface and without policy matching + assert '-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j MASQUERADE' in result # Verify no policy matching in WireGuard NAT rules assert '-A POSTROUTING -s 10.49.0.0/16 -m policy' not in result @@ -67,8 +67,8 @@ def test_ipsec_nat_rules_ipv4(): reduce_mtu=0 ) - # Verify NAT rule exists without policy matching - assert '-A POSTROUTING -s 10.48.0.0/16 -j MASQUERADE' in result + # Verify NAT rule exists with output interface and without policy matching + assert '-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j MASQUERADE' in result # Verify no policy matching in IPsec NAT rules (this was the bug) assert '-A POSTROUTING -s 10.48.0.0/16 -m policy --pol none' not in result @@ -97,9 +97,9 @@ def test_both_vpns_nat_rules_ipv4(): reduce_mtu=0 ) - # Both should have NAT rules - assert '-A POSTROUTING -s 10.48.0.0/16 -j MASQUERADE' in result - assert '-A POSTROUTING -s 10.49.0.0/16 -j MASQUERADE' in result + # Both should have NAT rules with output interface + assert '-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j MASQUERADE' in result + assert '-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j MASQUERADE' in result # Neither should have policy matching assert '-m policy --pol none' not in result @@ -129,9 +129,9 @@ def test_alternative_ingress_snat(): reduce_mtu=0 ) - # Should use SNAT with specific IP instead of MASQUERADE - assert '-A POSTROUTING -s 10.48.0.0/16 -j SNAT --to 192.168.1.100' in result - assert '-A POSTROUTING -s 10.49.0.0/16 -j SNAT --to 192.168.1.100' in result + # Should use SNAT with specific IP and output interface instead of MASQUERADE + assert '-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j SNAT --to 192.168.1.100' in result + assert '-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j SNAT --to 192.168.1.100' in result assert 'MASQUERADE' not in result