diff --git a/test/case/infix_firewall/Readme.adoc b/test/case/infix_firewall/Readme.adoc index b20fb711..434bb914 100644 --- a/test/case/infix_firewall/Readme.adoc +++ b/test/case/infix_firewall/Readme.adoc @@ -8,3 +8,7 @@ include::basic/Readme.adoc[] <<< include::lan-wan/Readme.adoc[] + +<<< + +include::wan-dmz-lan/Readme.adoc[] diff --git a/test/case/infix_firewall/all.yaml b/test/case/infix_firewall/all.yaml index 7cf796c6..73cb73b9 100644 --- a/test/case/infix_firewall/all.yaml +++ b/test/case/infix_firewall/all.yaml @@ -4,3 +4,6 @@ - name: LAN-WAN Firewall with Masquerading case: lan-wan/test.py + +- name: WAN-DMZ-LAN Firewall with Port Forwarding + case: wan-dmz-lan/test.py diff --git a/test/case/infix_firewall/wan-dmz-lan/Readme.adoc b/test/case/infix_firewall/wan-dmz-lan/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/wan-dmz-lan/test.adoc b/test/case/infix_firewall/wan-dmz-lan/test.adoc new file mode 100644 index 00000000..1b533a11 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/test.adoc @@ -0,0 +1,34 @@ +=== WAN-DMZ-LAN Firewall with Port Forwarding + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_firewall/wan-dmz-lan] + +==== Description + +Multi-zone firewall setup with port forwarding (DNAT) to a DMZ server, +and masquerading (SNAT) of WAN-bound traffic. + +image::wan-dmz-lan.svg[align=center, scaledwidth=50%] + +- DUT/Gateway with WAN/DMZ/LAN zones and NAT +- Test host's WAN interface acts as external Internet client +- Test host's DMZ interface acts as internal server (HTTP on port 80) +- Test host's LAN interface acts as internal LAN client + +==== Topology + +image::topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to gateway +. Configure gateway with multi-zone firewall and NAT +. Verify basic connectivity within zones +. Verify WAN to DMZ port forwarding (DNAT) +. Verify LAN to DMZ connectivity +. Verify DMZ to LAN blocking +. Verify WAN isolation +. Verify LAN to WAN connectivity with SNAT +. Verify DMZ to WAN connectivity with SNAT +. Verify zone default actions/services + + diff --git a/test/case/infix_firewall/wan-dmz-lan/test.py b/test/case/infix_firewall/wan-dmz-lan/test.py new file mode 100755 index 00000000..5fcc0280 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/test.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""WAN-DMZ-LAN Firewall with Port Forwarding + +Multi-zone firewall setup with port forwarding (DNAT) to a DMZ server, +and masquerading (SNAT) of WAN-bound traffic. + +image::wan-dmz-lan.svg[align=center, scaledwidth=50%] + +- DUT/Gateway with WAN/DMZ/LAN zones and NAT +- Test host's WAN interface acts as external Internet client +- Test host's DMZ interface acts as internal server (HTTP on port 80) +- Test host's LAN interface acts as internal LAN client +""" + +import time +import infamy +from infamy.util import until + + +with infamy.Test() as test: + with test.step("Set up topology and attach to gateway"): + env = infamy.Env() + gateway = env.attach("gateway", "mgmt") + _, wan_if = env.ltop.xlate("gateway", "wan") + _, dmz_if = env.ltop.xlate("gateway", "dmz") + _, lan_if = env.ltop.xlate("gateway", "lan") + _, mgmt_if = env.ltop.xlate("gateway", "mgmt") + _, host_wan = env.ltop.xlate("host", "wan") + _, host_dmz = env.ltop.xlate("host", "dmz") + _, host_lan = env.ltop.xlate("host", "lan") + + WAN_NET = "203.0.113.0/24" # RFC 5737 test network + WAN_ROUTER_IP = "203.0.113.1" # Gateway WAN interface + WAN_CLIENT_IP = "203.0.113.100" # Host WAN interface + + DMZ_NET = "10.0.1.0/24" + DMZ_ROUTER_IP = "10.0.1.1" # Gateway DMZ interface + DMZ_SERVER_IP = "10.0.1.100" # Host DMZ interface + + LAN_NET = "192.168.1.0/24" + LAN_ROUTER_IP = "192.168.1.1" # Gateway LAN interface + LAN_CLIENT_IP = "192.168.1.100" # Host LAN interface + + with test.step("Configure gateway with multi-zone firewall and NAT"): + gateway.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": wan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": WAN_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": dmz_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": DMZ_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": lan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": LAN_ROUTER_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + gateway.put_config_dict("infix-firewall", { + "firewall": { + "default": "wan", + "logging": "all", + "zone": [ + { + "name": "wan", + "description": "External WAN interface - untrusted", + "action": "drop", + "interface": [wan_if], + "port-forward": [{ + "lower": 8080, + "proto": "tcp", + "to": { + "addr": DMZ_SERVER_IP, + "port": 80 + } + }] + }, + { + "name": "dmz", + "description": "DMZ network - limited trust", + "action": "reject", + "network": [DMZ_NET], + "service": ["http"] + }, + { + "name": "lan", + "description": "Internal LAN network - trusted", + "action": "accept", + "interface": [lan_if, mgmt_if] + } + ], + "policy": [ + { + "name": "loc-to-wan", + "description": "Allow local networks to WAN with SNAT", + "ingress": ["lan", "dmz"], + "egress": ["wan"], + "action": "accept", + "masquerade": True + }, { + "name": "lan-to-dmz", + "description": "Allow LAN access to DMZ services", + "ingress": ["lan"], + "egress": ["dmz"], + "action": "accept", + "service": ["ssh", "http"] + } + ] + } + }) + + # Wait for configuration to be activated + infamy.Firewall.wait_for_operational(gateway, { + "wan": {"action": "drop"}, + "dmz": {"action": "reject"}, + "lan": {"action": "accept"} + }) + + # Verify firewall operational state + data = gateway.get_data("/infix-firewall:firewall") + fw = data["firewall"] + zones = {z["name"]: z for z in fw["zone"]} + + # Verify WAN zone with port forwarding + wan_zone = zones["wan"] + assert wan_zone["action"] == "drop" + assert wan_if in wan_zone["interface"] + assert len(wan_zone["port-forward"]) == 1 + pf = next(iter(wan_zone["port-forward"])) + assert pf["lower"] == 8080 + assert pf["to"]["addr"] == DMZ_SERVER_IP + assert pf["to"]["port"] == 80 + + # Verify DMZ zone + dmz_zone = zones["dmz"] + assert dmz_zone["action"] == "reject" + assert DMZ_NET in dmz_zone["network"] + assert "http" in dmz_zone["service"] + + # Verify LAN zone + lan_zone = zones["lan"] + assert lan_zone["action"] == "accept" + assert lan_if in lan_zone["interface"] + + # Check policies + policies = {p["name"]: p for p in fw["policy"]} + + # Verify loc-to-wan policy + loc_wan_policy = policies["loc-to-wan"] + assert set(loc_wan_policy["ingress"]) == {"lan", "dmz"} + assert loc_wan_policy["egress"] == ["wan"] + assert loc_wan_policy["masquerade"] is True + + # Verify lan-to-dmz policy + lan_dmz_policy = policies["lan-to-dmz"] + assert lan_dmz_policy["ingress"] == ["lan"] + assert lan_dmz_policy["egress"] == ["dmz"] + assert "ssh" in lan_dmz_policy["service"] + assert "http" in lan_dmz_policy["service"] + + with infamy.IsolatedMacVlan(host_wan) as wan_client: + wan_client.addip(WAN_CLIENT_IP) + + with infamy.IsolatedMacVlan(host_dmz) as dmz_server: + dmz_server.addip(DMZ_SERVER_IP) + dmz_server.addroute("0.0.0.0", DMZ_ROUTER_IP, prefix_length="0") + + with infamy.IsolatedMacVlan(host_lan) as lan_client: + lan_client.addip(LAN_CLIENT_IP) + lan_client.addroute("0.0.0.0", LAN_ROUTER_IP, prefix_length="0") + + with test.step("Verify basic connectivity within zones"): + lan_client.must_reach(LAN_ROUTER_IP, timeout=3) + dmz_server.must_not_reach(DMZ_ROUTER_IP, timeout=3) + + with test.step("Verify WAN to DMZ port forwarding (DNAT)"): + firewall = infamy.Firewall(wan_client, dmz_server) + + # Test port forwarding: WAN:8080 → DMZ:80 + ok, info = firewall.verify_dnat( + WAN_ROUTER_IP, forward_port=8080, target_port=80) + + if not ok: + print(f" ⚠ {info}") + test.fail() + + with test.step("Verify LAN to DMZ connectivity"): + lan_client.must_reach(DMZ_SERVER_IP, timeout=3) + firewall = infamy.Firewall(lan_client, None) + svc = [ + (22, "tcp", "ssh"), + (80, "tcp", "http"), + ] + + ok, ports = firewall.verify_allowed(DMZ_SERVER_IP, svc) + if not ok: + print(f" ⚠ Some DMZ services filtered from LAN: {', '.join(ports)}") + test.fail() + + with test.step("Verify DMZ to LAN blocking"): + dmz_server.must_not_reach(LAN_CLIENT_IP, timeout=3) + + with test.step("Verify WAN isolation"): + firewall = infamy.Firewall(wan_client, None) + + ok, ports, _ = firewall.verify_blocked(LAN_ROUTER_IP) + if not ok: + print(f" ⚠ WAN can access LAN ports: {', '.join(ports)}") + test.fail() + + ok, ports, _ = firewall.verify_blocked(DMZ_ROUTER_IP) + if not ok: + print(f" ⚠ WAN can access DMZ ports: {', '.join(ports)}") + + with test.step("Verify LAN to WAN connectivity with SNAT"): + firewall = infamy.Firewall(lan_client, wan_client) + + lan_client.must_reach(WAN_CLIENT_IP, timeout=3) + + ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP) + if not ok: + print(f" ⚠ LAN to WAN SNAT: {info}") + test.fail() + + with test.step("Verify DMZ to WAN connectivity with SNAT"): + firewall = infamy.Firewall(dmz_server, wan_client) + + dmz_server.must_reach(WAN_CLIENT_IP, timeout=3) + + ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP) + if not ok: + print(f" ⚠ DMZ to WAN SNAT: {info}") + test.fail() + + with test.step("Verify zone default actions/services"): + firewall_lan = infamy.Firewall(lan_client, None) + firewall_dmz = infamy.Firewall(dmz_server, None) + firewall_wan = infamy.Firewall(wan_client, None) + + svc = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp") + ] + ok, ports = firewall_lan.verify_allowed(LAN_ROUTER_IP, svc) + if not ok: + print(f" ⚠ LAN services not properly accessible: {', '.join(ports)}") + + svc = [(80, "tcp", "http")] + ok, ports = firewall_dmz.verify_allowed(DMZ_ROUTER_IP, svc) + if not ok: + print(f" ⚠ DMZ HTTP service not accessible: {', '.join(ports)}") + + ok, ports, _ = firewall_wan.verify_blocked(WAN_ROUTER_IP) + if not ok: + print(f" ⚠ WAN has unexpected open ports: {', '.join(ports)}") + + test.succeed() diff --git a/test/case/infix_firewall/wan-dmz-lan/topology.dot b/test/case/infix_firewall/wan-dmz-lan/topology.dot new file mode 100644 index 00000000..c087f7b4 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/topology.dot @@ -0,0 +1,25 @@ +graph "1x4" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | wan | dmz | lan }", + pos="1,1!", + requires="controller" + ]; + + gateway [ + label="{ mgmt | wan | dmz | lan } | gateway", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- gateway:mgmt [requires="mgmt", color="lightgray"] + host:wan -- gateway:wan [color=red, fontcolor=red, taillabel="203.0.113.0/24"] + host:dmz -- gateway:dmz [color=orange, fontcolor=orange, taillabel="10.0.1.0/24"] + host:lan -- gateway:lan [color=black, fontcolor=black, taillabel="192.168.1.0/24"] +} diff --git a/test/case/infix_firewall/wan-dmz-lan/topology.svg b/test/case/infix_firewall/wan-dmz-lan/topology.svg new file mode 100644 index 00000000..9e27552f --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/topology.svg @@ -0,0 +1,63 @@ + + + + + + +1x4 + + + +host + +host + +mgmt + +wan + +dmz + +lan + + + +gateway + +mgmt + +wan + +dmz + +lan + +gateway + + + +host:mgmt--gateway:mgmt + + + + +host:wan--gateway:wan + +203.0.113.0/24 + + + +host:dmz--gateway:dmz + +10.0.1.0/24 + + + +host:lan--gateway:lan + +192.168.1.0/24 + + + diff --git a/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.svg b/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.svg new file mode 100644 index 00000000..ce9f1a53 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/infamy/firewall.py b/test/infamy/firewall.py index 784cd8e1..24d0078a 100644 --- a/test/infamy/firewall.py +++ b/test/infamy/firewall.py @@ -8,6 +8,7 @@ behavior in automated tests. Supports: - Zone policy checks using targeted port scans - Positive and negative policy validation (allowed vs. blocked ports) """ +import subprocess import time from typing import Tuple, List from .sniffer import Sniffer @@ -187,3 +188,44 @@ class Firewall: filtered_ports.append(f"{name}({port})") return len(filtered_ports) == 0, filtered_ports + + def verify_dnat(self, gateway_ip: str, forward_port: int, target_port: int, + timeout: int = 5) -> Tuple[bool, str]: + """ + Verify DNAT (port forwarding) by testing end-to-end connectivity + + Args: + gateway_ip: Gateway IP where port forwarding is configured + forward_port: External port being forwarded (e.g., 8080) + target_port: Internal target port (e.g., 80) + timeout: Connection timeout + Returns: + Tuple of (dnat_working: bool, details: str) + """ + try: + # Use netcat to simulate a simple service on target port + cmd = f"nc -l -p {target_port} -e /bin/echo 'DNAT-TEST-OK'" + pid = self.dstns.popen(cmd.split(), stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + time.sleep(1) # Give server time to start + + # Test connection from source to gateway:forward_port + cmd = f"nc -w {timeout} {gateway_ip} {forward_port}" + result = self.srcns.runsh(cmd) + + try: + pid.terminate() + pid.wait(timeout=1) + except: + pid.kill() + + # Check if we got the expected response + if "DNAT-TEST-OK" in result.stdout: + return True, f"DNAT working: {gateway_ip}:{forward_port} → target:{target_port}" + if result.returncode == 0: + return True, f"DNAT working: connection successful to {gateway_ip}:{forward_port}" + + return False, f"DNAT failed: no response from {gateway_ip}:{forward_port}" + + except Exception as e: + return False, f"DNAT verification failed with error: {e}"