Files
algo/tests/unit/test_iptables_rules.py
984831bcab fix: add explicit bool filters for Ansible 12 jinja2_native compatibility (#14963)
* fix: add explicit bool filters for Ansible 12 jinja2_native compatibility

Ansible 12 enables jinja2_native by default, which means string values
like "true"/"false" are no longer automatically coerced to booleans in
when: conditions and Jinja2 if statements. Add | bool filters to all
boolean variable references in tasks, templates, and handlers.

Also reformats long single-line Jinja2 conditionals into multi-line for
readability, fixes GCE default() calls for native mode, adds help
command to the algo script, and updates test fixtures to register the
bool filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add j2lint for Jinja2 template linting

Add j2lint (aristanetworks/j2lint) to catch syntax errors, spacing
issues, and operator formatting in Jinja2 templates. Integrated into
pre-commit hooks, lint.yml CI, and smart-tests.yml.

Rules S3/S5/S6/S7/V1 are ignored — they enforce conventions
incompatible with Ansible's config-file-embedded templates.

Also fixes int+1 → int + 1 operator spacing in server.conf.j2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve all ansible-lint warnings and enforce zero-tolerance policy

Fix 18 jinja[spacing] errors across 12 files by moving Jinja2 block
delimiters to prevent YAML >- folding from introducing trailing spaces.

Fix 27 key-order[task] warnings across 17 files by reordering task keys
to canonical order (name → when → tags → environment → become → block).

Promote key-order[task] and yaml[line-length] from warn_list to hard
errors by removing warn_list entirely from .ansible-lint.

Add zero-tolerance warning policy to CLAUDE.md explaining why warnings
are unacceptable in a security tool and documenting resolution order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 11:21:56 -05:00

257 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""
Test iptables rules logic for VPN traffic routing.
These tests verify that the iptables rules templates generate correct
NAT rules for both WireGuard and IPsec VPN traffic.
"""
from pathlib import Path
import pytest
from jinja2 import Environment, FileSystemLoader
def _ansible_bool(value):
"""Simulate the Ansible bool filter for test purposes."""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() not in ("false", "no", "0", "")
return bool(value)
def load_template(template_name):
"""Load a Jinja2 template from the roles/common/templates directory."""
template_dir = Path(__file__).parent.parent.parent / "roles" / "common" / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
env.filters["bool"] = _ansible_bool
return env.get_template(template_name)
def test_wireguard_nat_rules_ipv4():
"""Test that WireGuard traffic gets proper NAT rules without policy matching."""
template = load_template("rules.v4.j2")
# Test with WireGuard enabled
result = template.render(
ipsec_enabled=False,
wireguard_enabled=True,
wireguard_network_ipv4="10.49.0.0/16",
wireguard_port=51820,
wireguard_port_avoid=53,
wireguard_port_actual=51820,
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4=None,
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="10.49.0.1",
ansible_ssh_port=22,
reduce_mtu=0,
)
# 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
def test_ipsec_nat_rules_ipv4():
"""Test that IPsec traffic gets proper NAT rules without policy matching."""
template = load_template("rules.v4.j2")
# Test with IPsec enabled
result = template.render(
ipsec_enabled=True,
wireguard_enabled=False,
strongswan_network="10.48.0.0/16",
strongswan_network_ipv6="2001:db8::/48",
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4=None,
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="10.48.0.1",
ansible_ssh_port=22,
reduce_mtu=0,
)
# 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
def test_both_vpns_nat_rules_ipv4():
"""Test NAT rules when both VPN types are enabled."""
template = load_template("rules.v4.j2")
result = template.render(
ipsec_enabled=True,
wireguard_enabled=True,
strongswan_network="10.48.0.0/16",
wireguard_network_ipv4="10.49.0.0/16",
strongswan_network_ipv6="2001:db8::/48",
wireguard_network_ipv6="2001:db8:a160::/48",
wireguard_port=51820,
wireguard_port_avoid=53,
wireguard_port_actual=51820,
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4=None,
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="10.49.0.1",
ansible_ssh_port=22,
reduce_mtu=0,
)
# 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
def test_alternative_ingress_snat():
"""Test that alternative ingress IP uses SNAT instead of MASQUERADE."""
template = load_template("rules.v4.j2")
result = template.render(
ipsec_enabled=True,
wireguard_enabled=True,
strongswan_network="10.48.0.0/16",
wireguard_network_ipv4="10.49.0.0/16",
strongswan_network_ipv6="2001:db8::/48",
wireguard_network_ipv6="2001:db8:a160::/48",
wireguard_port=51820,
wireguard_port_avoid=53,
wireguard_port_actual=51820,
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4="192.168.1.100", # Alternative ingress IP
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="10.49.0.1",
ansible_ssh_port=22,
reduce_mtu=0,
)
# 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
def test_ipsec_forward_rule_has_policy_match():
"""Test that IPsec FORWARD rules still use policy matching (this is correct)."""
template = load_template("rules.v4.j2")
result = template.render(
ipsec_enabled=True,
wireguard_enabled=False,
strongswan_network="10.48.0.0/16",
strongswan_network_ipv6="2001:db8::/48",
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4=None,
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="10.48.0.1",
ansible_ssh_port=22,
reduce_mtu=0,
)
# FORWARD rule should have policy match (this is correct and should stay)
assert "-A FORWARD -m conntrack --ctstate NEW -s 10.48.0.0/16 -m policy --pol ipsec --dir in -j ACCEPT" in result
def test_wireguard_forward_rule_no_policy_match():
"""Test that WireGuard FORWARD rules don't use policy matching."""
template = load_template("rules.v4.j2")
result = template.render(
ipsec_enabled=False,
wireguard_enabled=True,
wireguard_network_ipv4="10.49.0.0/16",
wireguard_port=51820,
wireguard_port_avoid=53,
wireguard_port_actual=51820,
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4=None,
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="10.49.0.1",
ansible_ssh_port=22,
reduce_mtu=0,
)
# WireGuard FORWARD rule should NOT have any policy match
assert "-A FORWARD -m conntrack --ctstate NEW -s 10.49.0.0/16 -j ACCEPT" in result
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
def test_dns_firewall_restricted_to_vpn():
"""Test that DNS access is restricted to VPN clients only."""
template = load_template("rules.v4.j2")
result = template.render(
ipsec_enabled=True,
wireguard_enabled=True,
strongswan_network="10.48.0.0/16",
wireguard_network_ipv4="10.49.0.0/16",
strongswan_network_ipv6="2001:db8::/48",
wireguard_network_ipv6="2001:db8:a160::/48",
wireguard_port=51820,
wireguard_port_avoid=53,
wireguard_port_actual=51820,
ansible_default_ipv4={"interface": "eth0"},
snat_aipv4=None,
BetweenClients_DROP=True,
block_smb=True,
block_netbios=True,
local_service_ip="172.23.198.242",
ansible_ssh_port=22,
reduce_mtu=0,
)
# DNS should only be accessible from VPN subnets
assert "-A INPUT -s 10.48.0.0/16,10.49.0.0/16 -d 172.23.198.242 -p udp --dport 53 -j ACCEPT" in result
# Should NOT have unrestricted DNS access
assert "-A INPUT -d 172.23.198.242 -p udp --dport 53 -j ACCEPT" not in result
if __name__ == "__main__":
pytest.main([__file__, "-v"])