Fix VPN routing by adding output interface to NAT rules

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 <noreply@anthropic.com>
This commit is contained in:
Dan Guido 2025-08-17 16:54:19 -04:00
parent 9cc0b029ac
commit 65fe846499
4 changed files with 20 additions and 14 deletions

View file

@ -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 %}

View file

@ -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

View file

@ -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

View file

@ -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