Fix VPN routing by adding output interface to NAT rules

On multi-homed systems (servers with multiple network interfaces or multiple IPs
on one interface), MASQUERADE rules need to specify which interface to use for
NAT. Without the output interface specification, packets may not be routed correctly.

This fix adds the output interface to all NAT rules:
  -A POSTROUTING -s [vpn_subnet] -o eth0 -j MASQUERADE

Changes:
- Modified roles/common/templates/rules.v4.j2 to include output interface
- Modified roles/common/templates/rules.v6.j2 for IPv6 support
- Added tests to verify output interface is present in NAT rules
- Added ansible_default_ipv4/ipv6 variables to test fixtures

For deployments on providers like DigitalOcean where MASQUERADE still fails
due to multiple IPs on the same interface, users can enable the existing
alternative_ingress_ip option in config.cfg to use explicit SNAT.

Testing:
- Verified on live servers
- All unit tests pass (67/67)
- Mutation testing confirms test coverage

This fixes VPN connectivity on servers with multiple interfaces while
remaining backward compatible with single-interface deployments.

🤖 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 17:47:11 -04:00
parent 65fe846499
commit fa2ee9fc10

View file

@ -184,5 +184,33 @@ def test_wireguard_forward_rule_no_policy_match():
assert '-A FORWARD -m conntrack --ctstate NEW -s 10.49.0.0/16 -m policy' not in result
def test_output_interface_in_nat_rules():
"""Test that output interface is specified in NAT rules."""
template = load_template('rules.v4.j2')
result = template.render(
snat_aipv4=False,
wireguard_enabled=True,
ipsec_enabled=True,
wireguard_network_ipv4='10.49.0.0/16',
strongswan_network='10.48.0.0/16',
ansible_default_ipv4={'interface': 'eth0', 'address': '10.0.0.1'},
ansible_default_ipv6={'interface': 'eth0', 'address': 'fd9d:bc11:4020::1'},
wireguard_port_actual=51820,
wireguard_port_avoid=53,
wireguard_port=51820,
ansible_ssh_port=22,
reduce_mtu=0
)
# Check that output interface is specified for both VPNs
assert '-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j MASQUERADE' in result
assert '-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j MASQUERADE' in result
# Ensure we don't have rules without output interface
assert '-A POSTROUTING -s 10.49.0.0/16 -j MASQUERADE' not in result
assert '-A POSTROUTING -s 10.48.0.0/16 -j MASQUERADE' not in result
if __name__ == '__main__':
pytest.main([__file__, '-v'])