mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-24 16:49:24 +00:00
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Addresses, interfaces, and the nftables rules they turn into.
|
|
|
|
The backend's own modules, imported directly — no instance, no root, no kernel. What is
|
|
being checked is the translation from an address an operator typed to the match firegex
|
|
will install for it, which is pure enough to test without any of that.
|
|
|
|
`sys.path` is arranged by the root conftest. It used to be arranged here, by inserting
|
|
the backend at the front, which is how this module and the rest of the suite came to be
|
|
unable to run in one process: the backend's `utils` package and this directory's were
|
|
both called `utils`, and whichever was imported first won.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from utils import is_interface_name, parse_ip_or_int
|
|
from modules.services.models import Address, Service, TRANSPORT, L4, new_id
|
|
from modules.services.nftables import (FiregexTables, InstalledRule, NoRelayAddress,
|
|
udp_relay_host, udp_relay_key)
|
|
|
|
|
|
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"]
|
|
)
|
|
|
|
|
|
|
|
# --- one spelling of a UDP relay's upstream ----------------------------------
|
|
# The engine reports one `UDP <upstream> <port>` line per relay and is asked for a new
|
|
# one by the same token, so the transport's map, the rule that points at a relay and the
|
|
# manager adding an address to a running service all have to agree on it exactly. This
|
|
# formula was written out by hand in four places before it was one function.
|
|
|
|
|
|
def test_udp_relay_key_brackets_only_ipv6():
|
|
assert udp_relay_key("127.0.0.1", 53) == "127.0.0.1:53"
|
|
assert udp_relay_key("::1", 53) == "[::1]:53"
|
|
|
|
|
|
def test_udp_relay_key_strips_the_prefix_an_address_is_stored_with():
|
|
"""Addresses are normalised to network form, and a relay binds to one host."""
|
|
assert udp_relay_key("10.0.0.1/32", 5353) == "10.0.0.1:5353"
|
|
assert udp_relay_key("fd00::1/128", 5353) == "[fd00::1]:5353"
|
|
|
|
|
|
def test_udp_relay_host_passes_an_ip_through():
|
|
assert udp_relay_host("192.168.1.5/32") == "192.168.1.5"
|
|
|
|
|
|
def test_udp_relay_host_refuses_an_interface_with_nothing_to_bind_to():
|
|
"""Named, rather than an IndexError somewhere further down.
|
|
|
|
An interface with no address is a service the operator will have to fix, and the
|
|
message is the only thing that says which interface and why.
|
|
"""
|
|
with pytest.raises(NoRelayAddress, match="no IP address assigned"):
|
|
udp_relay_host("definitely-not-an-interface")
|
|
|
|
|
|
def test_udp_relay_host_prefers_a_real_ipv4_address():
|
|
"""IPv4 first and link-local last.
|
|
|
|
An interface almost always carries an `fe80::` address it was never configured with.
|
|
Binding the relay there would put the service on an address nobody dials, and the
|
|
symptom would be UDP that silently goes nowhere.
|
|
"""
|
|
import psutil
|
|
|
|
loopbacks = [name for name in psutil.net_if_addrs() if name in ("lo", "lo0")]
|
|
if not loopbacks:
|
|
pytest.skip("no loopback interface to resolve")
|
|
assert udp_relay_host(loopbacks[0]) == "127.0.0.1"
|