import sys import os import pytest # Ensure backend path is importable sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "backend"))) from utils import is_interface_name, parse_ip_or_int, is_ip_parse, ip_parse from modules.services.models import Address, Service, TRANSPORT, L4, new_id from modules.services.nftables import FiregexTables, InstalledRule, resolve_target def test_is_interface_name(): # Valid interface names assert is_interface_name("eth0") is True assert is_interface_name("lo") is True assert is_interface_name("wg0") is True assert is_interface_name("tun0") is True assert is_interface_name("enp3s0") is True assert is_interface_name("br-lan") is True assert is_interface_name("veth123456") is True assert is_interface_name("eth0.100") is True # Invalid interface names assert is_interface_name("") is False assert is_interface_name(" ") is False assert is_interface_name("a" * 16) is False # > 15 chars assert is_interface_name("eth 0") is False # space assert is_interface_name("eth/0") is False # slash assert is_interface_name(123) is False # not a string def test_parse_ip_or_int(): # Valid IPs should be normalized to network CIDR assert parse_ip_or_int("127.0.0.1") == "127.0.0.1/32" assert parse_ip_or_int("192.168.1.0/24") == "192.168.1.0/24" assert parse_ip_or_int("::1") == "::1/128" # Valid interfaces should return the trimmed name assert parse_ip_or_int("eth0") == "eth0" assert parse_ip_or_int(" wg0 ") == "wg0" assert parse_ip_or_int("lo") == "lo" # Invalid values should raise ValueError with pytest.raises(ValueError): parse_ip_or_int("not a valid name with spaces") with pytest.raises(ValueError): parse_ip_or_int("this_interface_name_is_way_too_long_for_linux") def test_address_model_interface(): addr_ip = Address( address_id=new_id(), service_id=new_id(), ip_int="192.168.1.1/32", port=80, proto=L4.TCP, ) assert addr_ip.is_interface is False assert addr_ip.is_ipv6 is False addr_ip6 = Address( address_id=new_id(), service_id=new_id(), ip_int="fd00::1/128", port=80, proto=L4.TCP, ) assert addr_ip6.is_interface is False assert addr_ip6.is_ipv6 is True addr_iface = Address( address_id=new_id(), service_id=new_id(), ip_int="eth0", port=80, proto=L4.TCP, ) assert addr_iface.is_interface is True assert addr_iface.is_ipv6 is False def test_nftables_match_generation(): table = FiregexTables.__new__(FiregexTables) # IP match (inbound daddr) m_ip_in = table._match("127.0.0.1/32", 80, "ip", "tcp", "daddr", "dport") assert len(m_ip_in) == 2 assert "payload" in m_ip_in[0]["match"]["left"] assert m_ip_in[0]["match"]["left"]["payload"]["field"] == "daddr" assert m_ip_in[1]["match"]["right"] == 80 # Interface match (inbound daddr -> iifname) m_iface_in = table._match("eth0", 80, "ip", "tcp", "daddr", "dport") assert len(m_iface_in) == 2 assert "meta" in m_iface_in[0]["match"]["left"] assert m_iface_in[0]["match"]["left"]["meta"]["key"] == "iifname" assert m_iface_in[0]["match"]["right"] == "eth0" assert m_iface_in[1]["match"]["left"]["payload"]["field"] == "dport" assert m_iface_in[1]["match"]["right"] == 80 # Interface match (outbound saddr -> oifname) m_iface_out = table._match("eth0", 80, "ip", "tcp", "saddr", "sport") assert len(m_iface_out) == 2 assert "meta" in m_iface_out[0]["match"]["left"] assert m_iface_out[0]["match"]["left"]["meta"]["key"] == "oifname" assert m_iface_out[0]["match"]["right"] == "eth0" assert m_iface_out[1]["match"]["left"]["payload"]["field"] == "sport" assert m_iface_out[1]["match"]["right"] == 80 def test_installed_rule_matches(): srv = Service( service_id="srv-1", name="web", status="active", proto=L4.TCP, transport=TRANSPORT.PROXY, ) addr_ip = Address(address_id="a1", service_id="srv-1", ip_int="10.0.0.1/32", port=80, proto=L4.TCP) addr_iface = Address(address_id="a2", service_id="srv-1", ip_int="eth0", port=80, proto=L4.TCP) rule_ip = InstalledRule( chain="fgex_nat", handle=10, proto="tcp", port=80, ip_int="10.0.0.1/32" ) rule_iface = InstalledRule( chain="fgex_nat", handle=11, proto="tcp", port=80, ip_int="eth0" ) # IP rule matches IP address, not interface assert rule_ip.matches(srv, addr_ip) is True assert rule_ip.matches(srv, addr_iface) is False # Interface rule matches interface address, not IP assert rule_iface.matches(srv, addr_iface) is True assert rule_iface.matches(srv, addr_ip) is False # Mismatched port or proto addr_diff_port = Address(address_id="a3", service_id="srv-1", ip_int="eth0", port=8080, proto=L4.TCP) assert rule_iface.matches(srv, addr_diff_port) is False addr_diff_proto = Address(address_id="a4", service_id="srv-1", ip_int="eth0", port=80, proto=L4.UDP) assert rule_iface.matches(srv, addr_diff_proto) is False def test_installed_rule_parsing(): # Verify parsing simulated nftables json rule with meta iifname raw_rule = { "chain": "fgex_nat", "handle": 42, "expr": [ { "match": { "op": "!=", "left": {"meta": {"key": "mark"}}, "right": 0x133A, } }, { "match": { "op": "==", "left": {"meta": {"key": "iifname"}}, "right": "eth0", } }, { "match": { "op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 80, } }, {"counter": {"packets": 12, "bytes": 1024}}, {"redirect": {"port": 38472}}, ] } # Simulate get() parsing logic table = FiregexTables.__new__(FiregexTables) table.list_rules = lambda tables, chains: [raw_rule] rules = table.get() assert len(rules) == 1 r = rules[0] assert r.chain == "fgex_nat" assert r.handle == 42 assert r.ip_int == "eth0" assert r.port == 80 assert r.proto == "tcp" assert r.packets == 12 assert r.bytes == 1024 def test_proxy_rule_generation_interface_vs_ip(): table = FiregexTables.__new__(FiregexTables) commands = [] table.cmd = lambda *cmds: commands.extend(cmds) srv = Service( service_id="srv-test", name="web", status="active", proto=L4.TCP, transport=TRANSPORT.PROXY, ) # 1. Interface target commands.clear() table._add_proxy(srv, "eth0", 80, "ip", "tcp", 38000) # Should only write to nat_chain and route_chain (NOT nat_output_chain) assert len(commands) == 2 chains = [c["add"]["rule"]["chain"] for c in commands] assert "fgex_nat" in chains assert "fgex_route" in chains assert "fgex_nat_out" not in chains # Check expressions in nat rule nat_rule = [c["add"]["rule"] for c in commands if c["add"]["rule"]["chain"] == "fgex_nat"][0] assert any( e.get("match", {}).get("left", {}).get("meta", {}).get("key") == "iifname" and e["match"]["right"] == "eth0" for e in nat_rule["expr"] ) # Check expressions in route rule route_rule = [c["add"]["rule"] for c in commands if c["add"]["rule"]["chain"] == "fgex_route"][0] assert any( e.get("match", {}).get("left", {}).get("meta", {}).get("key") == "oifname" and e["match"]["right"] == "eth0" for e in route_rule["expr"] ) # 2. IP target commands.clear() table._add_proxy(srv, "127.0.0.1/32", 80, "ip", "tcp", 38000) # Should write to nat_chain, route_chain, AND nat_output_chain assert len(commands) == 3 chains_ip = [c["add"]["rule"]["chain"] for c in commands] assert "fgex_nat" in chains_ip assert "fgex_route" in chains_ip assert "fgex_nat_out" in chains_ip def test_queue_rule_generation_interface(): table = FiregexTables.__new__(FiregexTables) commands = [] table.cmd = lambda *cmds: commands.extend(cmds) srv = Service( service_id="srv-test-q", name="dns", status="active", proto=L4.UDP, transport=TRANSPORT.NFQUEUE, ) commands.clear() table._add_queue(srv, "wg0", 53, "ip", "udp", [0]) assert len(commands) == 2 chains = [c["insert"]["rule"]["chain"] for c in commands] assert "fgex_queue_out_0" in chains assert "fgex_queue_in_0" in chains in_rule = [c["insert"]["rule"] for c in commands if c["insert"]["rule"]["chain"] == "fgex_queue_in_0"][0] assert any( e.get("match", {}).get("left", {}).get("meta", {}).get("key") == "iifname" and e["match"]["right"] == "wg0" for e in in_rule["expr"] ) out_rule = [c["insert"]["rule"] for c in commands if c["insert"]["rule"]["chain"] == "fgex_queue_out_0"][0] assert any( e.get("match", {}).get("left", {}).get("meta", {}).get("key") == "oifname" and e["match"]["right"] == "wg0" for e in out_rule["expr"] )