"""Unprivileged command, drift and rule-semantics tests, not packet verification.""" from __future__ import annotations import copy import json import os import re import shutil import subprocess import unittest from dataclasses import replace from pathlib import Path from types import SimpleNamespace from uvm.config import Settings from uvm.domain import PortRule, VmRecord from uvm.errors import CommandError, UvmError, ValidationError from uvm.firewall import FirewallManager, _ct, _normalize from uvm.system import CommandResult from uvm.validation import parse_ports def vm_record(**changes): fields = dict(id="vm-one", tap="tap-one", guest_ip="10.42.0.2", mac="02:fc:00:00:00:01", ports=(PortRule(8080),)) fields.update(changes) return SimpleNamespace(**fields) class FakeRunner: """Model atomic nft transactions and readback without executing commands.""" def __init__(self): self.calls = [] self.tables = {} self.links = [] self.handle = 1 self.release = "6.8.0-test" self.fail_command = None self.fail_batch = False self.before_batch = None self.after_batch = None self.listing = None self.ct_delete_code = 0 self.ct_remaining = "" def run(self, command, *, check=True, capture=False, input_text=None, timeout=None): args = tuple(command) self.calls.append((args, dict(check=check, capture=capture, input_text=input_text, timeout=timeout))) if args == self.fail_command: raise CommandError("simulated permission failure or timeout") stdout, code = "", 0 if args == ("uname", "-r"): stdout = self.release elif args == ("ip", "-j", "-d", "link", "show"): stdout = json.dumps(self.links) elif args[:2] == ("nft", "--json") and "list" in args: if self.listing is not None: stdout = self.listing else: if "tables" in args: objects = [items[0] for items in self.tables.values()] else: if args[-1] not in self.tables: raise CommandError("table disappeared") objects = self.tables[args[-1]] stdout = json.dumps({"nftables": [{"metainfo": {"version": "1.0.9"}}, *objects]}) elif args[:2] == ("nft", "--json") and args[-2:] == ("-f", "-"): commands = json.loads(input_text)["nftables"] if self.before_batch: self.before_batch() self.before_batch = None if self.fail_batch: raise CommandError("atomic nft transaction rejected") candidate = copy.deepcopy(self.tables) for command in commands: operation, item = next(iter(command.items())) kind, obj = next(iter(item.items())) # Match nft 1.0.9 json_parse_set_expr(), not an echo-only fake. if kind == "set" and obj.get("elem") == []: raise CommandError("Invalid set elem expression") if operation == "delete": name = next((name for name, items in candidate.items() if items[0]["table"].get("handle") == obj["handle"]), None) if name is None: raise CommandError("table handle disappeared") del candidate[name] else: obj["handle"] = self.handle self.handle += 1 if kind == "table": if obj["name"] in candidate: raise CommandError("exclusive create found an existing table") candidate[obj["name"]] = [item] else: candidate[obj["table"]].append(item) if "--check" not in args: self.tables = candidate if self.after_batch: self.after_batch() elif args == ("conntrack", "--version"): stdout = "conntrack v1.4.8 (conntrack-tools)" elif args[:2] == ("conntrack", "--delete"): code = self.ct_delete_code elif args[:2] == ("conntrack", "--list"): stdout = self.ct_remaining else: raise AssertionError(f"unexpected command: {args}") if check and code: raise CommandError("simulated command failure") return CommandResult(args=args, returncode=code, stdout=stdout) @property def batches(self): return [json.loads(options["input_text"])["nftables"] for args, options in self.calls if args[0] == "nft" and options["input_text"] is not None and "--check" not in args] class FirewallManagerTests(unittest.TestCase): def setUp(self): self.runner = FakeRunner() self.manager = FirewallManager(Settings(), self.runner) self.vm = vm_record() def policy(self): return next(iter(self.runner.tables.values())) def test_apply_is_atomic_bounded_and_contains_no_nat_or_global_mutations(self): self.manager.apply(self.vm) self.assertEqual(self.manager.status(self.vm), "enforced") self.assertEqual(len(self.runner.batches), 1) self.assertIn("create", self.runner.batches[0][0]) for args, options in self.runner.calls: if args[0] != "nft": self.assertEqual(args, ("ip", "-j", "-d", "link", "show")) self.assertTrue(options["capture"]) self.assertGreater(options["timeout"], 0) self.assertLessEqual(options["timeout"], 10) rendered = json.dumps(self.runner.batches) for forbidden in ("flush", "dnat", "snat", "masquerade", "redirect", "notrack"): self.assertNotIn(f'"{forbidden}"', rendered) for item in self.policy(): self.assertEqual(next(iter(item.values()))["family"], "bridge") def test_update_and_remove_do_not_rebuild_or_delete_siblings(self): self.manager.apply(self.vm) sibling = vm_record(id="vm-two", tap="tap-two", guest_ip="10.42.0.3") self.manager.apply(sibling) name = next(reversed(self.runner.tables)) saved = copy.deepcopy(self.runner.tables[name]) old_handle = self.policy()[0]["table"]["handle"] self.vm.ports = () self.manager.apply(self.vm) self.assertEqual(self.runner.batches[-1][0], { "delete": {"table": {"family": "bridge", "handle": old_handle}}, }) self.assertEqual(self.runner.tables[name], saved) self.assertEqual(self.manager.status(self.vm), "enforced") self.manager.remove(self.vm) self.manager.remove(self.vm) self.assertEqual(self.runner.tables, {name: saved}) self.assertEqual(self.manager.status(self.vm), "missing") def test_table_identity_is_stable_but_scoped_to_base_and_vm_id(self): self.manager.apply(self.vm) name = next(iter(self.runner.tables)) self.vm.ports = () self.manager.apply(self.vm) self.assertEqual(list(self.runner.tables), [name]) other = FirewallManager(replace(Settings(), base=Path("/var/lib/another-uvm")), self.runner) other.apply(self.vm) self.assertEqual(len(self.runner.tables), 2) self.assertRegex(name, r"^uvm_fw_[0-9a-f]{24}$") self.assertLess(len(self.policy()[0]["table"]["comment"]), 128) def test_legacy_is_unmanaged_and_normal_apply_refuses_without_commands(self): self.vm.ports = None with self.assertRaises(ValidationError): self.manager.apply(self.vm) self.assertEqual(self.runner.calls, []) self.assertEqual(self.manager.status(self.vm), "unmanaged") self.vm.guest_ip, self.vm.mac = "damaged", "damaged" self.manager.apply(self.vm, quarantine=True) self.assertEqual(self.manager.status(self.vm), "quarantined") def test_none_does_not_hide_an_existing_managed_table(self): self.manager.apply(self.vm) self.vm.ports = None self.assertEqual(self.manager.status(self.vm), "drift") def test_protocol_aliases_deduplicate_tcp_ports_and_allow_empty_policy(self): self.vm.ports = tuple(PortRule(port, protocol) for port, protocol in ( (443, "HTTPS"), (8080, "HTTP"), (443, "TCP"), (22, "TCP"), )) self.manager.apply(self.vm) self.assertEqual(next(item["set"]["elem"] for item in self.policy() if "set" in item), [22, 443, 8080]) self.vm.ports = () self.manager.apply(self.vm) self.assertNotIn("elem", next(item["set"] for item in self.policy() if "set" in item)) def test_real_port_models_and_cli_syntax_reach_the_same_tcp_set(self): ports = parse_ports(("1:tcp,80:http", "443:HTTPS,65535")) vm = VmRecord(id="vm-real", cpu=1, ram_mib=128, guest_ip="10.42.0.2", gateway="10.42.0.1", tap="tap-real", mac=self.vm.mac, socket="", config="", log="", ports=ports, network="10.42.0.0/24", bridge="uvm0") self.manager.apply(vm) self.assertEqual(self.manager.status(vm), "enforced") self.assertEqual(next(item["set"]["elem"] for item in self.policy() if "set" in item), [1, 80, 443, 65535]) for text in ("0", "65536", "-1", "1.5", "80:UDP", "80:HTTP:TCP", "TCP:80", "80:", ":TCP"): with self.subTest(text=text), self.assertRaises(ValidationError): parse_ports((text,)) def test_invalid_ports_are_rejected_before_commands(self): for port, protocol in ((True, "TCP"), (0, "TCP"), (-1, "TCP"), (65536, "TCP"), (80.0, "TCP"), ("80", "TCP"), (80, "tcp"), (53, "UDP"), (80, "HTTP:TCP"), (80, None), (80, "HTTP; flush ruleset")): with self.subTest(port=port, protocol=protocol): self.vm.ports = (SimpleNamespace(port=port, protocol=protocol),) with self.assertRaises(ValidationError): self.manager.apply(self.vm) self.assertEqual(self.runner.calls, []) def test_invalid_identity_is_rejected_before_commands(self): for field, value in (("tap", "tap*"), ("tap", 'x"; drop'), ("tap", "-bad"), ("tap", "a" * 16), ("tap", "uvm0"), ("id", ""), ("mac", "ff:ff:ff:ff:ff:ff"), ("mac", "00:00:00:00:00:00"), ("guest_ip", "10.42.0.1"), ("guest_ip", "10.42.0.255"), ("guest_ip", "10.43.0.2"), ("guest_ip", "127.0.0.1"), ("guest_ip", "8.8.8.8"), ("guest_ip", "::1")): with self.subTest(field=field, value=value), self.assertRaises(ValidationError): self.manager.apply(vm_record(**{field: value})) self.assertEqual(self.runner.calls, []) def test_persisted_network_can_differ_from_current_settings(self): vm = vm_record(guest_ip="172.20.1.2", network="172.20.0.0/16", bridge="old-bridge") self.manager.apply(vm) self.assertEqual(self.manager.status(vm), "enforced") def test_live_tap_must_route_lldp_through_early_filter_and_disable_stp(self): port = {"info_slave_kind": "bridge", "info_slave_data": {"group_fwd_mask": "0x4000"}} bridge = {"info_kind": "bridge", "info_data": {"stp_state": 0}} self.runner.links = [{"ifname": self.vm.tap, "master": "uvm0", "linkinfo": port}, {"ifname": "uvm0", "ifindex": 7, "linkinfo": bridge}] self.manager.apply(self.vm) self.assertEqual(self.manager.status(self.vm), "enforced") for stp, mask in ((1, "0x4000"), (0, 0), (0, "unreadable")): bridge["info_data"]["stp_state"] = stp port["info_slave_data"]["group_fwd_mask"] = mask self.assertEqual(self.manager.status(self.vm), "drift") for quarantine in (False, True): with self.assertRaisesRegex(UvmError, "LLDP forwarding"): self.manager.apply(self.vm, quarantine=quarantine) bridge["info_data"]["stp_state"] = 0 port["info_slave_data"]["group_fwd_mask"] = 0x4008 self.runner.links[0]["master"] = 7 # iproute may use an index instead of a name. self.manager.apply(self.vm, quarantine=True) self.assertEqual(self.manager.status(self.vm), "quarantined") def test_foreign_ownership_is_never_adopted_even_for_quarantine_or_remove(self): self.manager.apply(self.vm) self.policy()[0]["table"]["comment"] = "someone else's firewall" saved = copy.deepcopy(self.runner.tables) self.assertEqual(self.manager.status(self.vm), "drift") for operation in (lambda: self.manager.apply(self.vm), lambda: self.manager.apply(self.vm, quarantine=True), lambda: self.manager.remove(self.vm)): with self.assertRaisesRegex(UvmError, "foreign"): operation() self.assertEqual(self.runner.tables, saved) self.assertEqual(len(self.runner.batches), 1) def test_table_replacement_race_uses_exclusive_create_and_old_handle(self): self.manager.apply(self.vm) def replace_table(): self.policy()[0]["table"].update(handle=90000, comment="foreign replacement") self.runner.before_batch = replace_table with self.assertRaises(CommandError): self.manager.apply(self.vm, quarantine=True) self.assertEqual(self.policy()[0]["table"]["comment"], "foreign replacement") def test_initial_create_race_does_not_append_to_a_foreign_table(self): table = self.manager._policy(self.vm, quarantine=True)[0]["table"] def create_foreign(): self.runner.tables[table["name"]] = [{"table": {**table, "handle": 1000, "comment": "foreign"}}] self.runner.before_batch = create_foreign with self.assertRaises(CommandError): self.manager.apply(self.vm) self.assertEqual(len(self.policy()), 1) def test_atomic_failure_keeps_old_policy_and_readback_failure_is_not_success(self): self.manager.apply(self.vm) saved = copy.deepcopy(self.runner.tables) self.runner.fail_batch = True self.vm.ports = () with self.assertRaises(CommandError): self.manager.apply(self.vm) self.assertEqual(self.runner.tables, saved) self.runner.fail_batch = False self.runner.after_batch = lambda: self.policy().pop() with self.assertRaisesRegex(UvmError, "readback"): self.manager.apply(self.vm) def test_status_does_not_trust_owner_comment_rule_count_or_saved_flags(self): self.manager.apply(self.vm) original = copy.deepcopy(self.runner.tables) self.vm.firewall_applied = True mutations = ( lambda items: items.pop(), lambda items: next(item["set"] for item in items if "set" in item)["elem"].append(22), lambda items: next(item["chain"] for item in items if "chain" in item).update(hook="postrouting"), lambda items: next(item["chain"] for item in items if "chain" in item).update(prio=1), lambda items: next(item["chain"] for item in items if "chain" in item).update(policy="drop"), lambda items: items[0]["table"].update(flags="dormant"), lambda items: next(item["rule"] for item in items if "rule" in item)["expr"].pop(0), lambda items: next(item["rule"] for item in items if "rule" in item).update(expr=[{"accept": None}]), lambda items: items.append({"flowtable": {"family": "bridge", "name": "bypass"}}), ) for mutate in mutations: with self.subTest(mutation=mutate): self.runner.tables = copy.deepcopy(original) mutate(self.policy()) self.assertEqual(self.manager.status(self.vm), "drift") self.runner.tables = original for item in self.policy(): if "rule" in item and item["rule"]["chain"] == "from_vm": item["rule"]["expr"] = [{"return": None}] break self.assertEqual(self.manager.status(self.vm), "drift") def test_rule_order_is_not_normalized_away(self): self.manager.apply(self.vm) positions = [index for index, item in enumerate(self.policy()) if "rule" in item and item["rule"]["chain"] == "from_vm"] first, last = positions[0], positions[-1] self.policy()[first], self.policy()[last] = self.policy()[last], self.policy()[first] self.assertEqual(self.manager.status(self.vm), "drift") def test_normalizes_nft_json_presentation_not_effective_policy(self): self.manager.apply(self.vm) for item in self.policy(): obj = next(iter(item.values())) obj["handle"] += 100 if "set" in item: obj["elem"].reverse() obj["flags"] = "constant" if "rule" not in item: continue exprs = obj["expr"] protocols = {expr.get("match", {}).get("left", {}).get("payload", {}).get("protocol") for expr in exprs} # nft elides EtherType dependencies of ARP/IP payloads, and # coalesces/sorts adjacent pure payload matches when listing. has_tcp_payload = '"protocol": "tcp"' in json.dumps(exprs) if protocols & {"arp", "ip"} and not has_tcp_payload: exprs[:] = [expr for expr in exprs if expr.get("match", {}).get("left") != { "payload": {"protocol": "ether", "field": "type"}, }] for expr in exprs: match = expr.get("match", {}) if match.get("right") == "@tcp_ports": match["op"] = "in" if match.get("left") == {"ct": {"key": "state"}} and isinstance(match["right"], list): match["right"].reverse() if "&" in match.get("left", {}): match["left"]["&"][1] = {"|": [{"|": ["fin", "syn"]}, {"|": ["rst", "ack"]}]} match["right"] = "syn" exprs[:-1] = reversed(exprs[:-1]) self.assertEqual(self.manager.status(self.vm), "enforced") def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self): self.vm.ports = () self.manager.apply(self.vm) self.assertNotIn("elem", next(item["set"] for item in self.policy() if "set" in item)) self.assertEqual(self.manager.status(self.vm), "enforced") next(item["set"] for item in self.policy() if "set" in item)["elem"] = [] self.assertEqual(self.manager.status(self.vm), "enforced") for item in self.policy(): if "rule" in item: item["rule"]["expr"] = [expr for expr in item["rule"]["expr"] if expr.get("match", {}).get("left") != {"ct": {"key": "direction"}}] self.assertEqual(self.manager.status(self.vm), "drift") def test_missing_tools_permissions_timeouts_and_bad_json_are_unknown_not_missing(self): self.runner.fail_command = ("nft", "--json", "list", "tables", "bridge") self.assertEqual(self.manager.status(self.vm), "unknown") with self.assertRaises(CommandError): self.manager.apply(self.vm) self.runner.fail_command = None for listing in ("", "not json", "{}", '{"nftables": null}', '{"nftables": [null]}', '{"nftables": [{"table": {}}]}'): with self.subTest(listing=listing): self.runner.listing = listing self.assertEqual(self.manager.status(self.vm), "unknown") with self.assertRaises(UvmError): self.manager.apply(self.vm) self.assertEqual(self.runner.batches, []) def test_remove_and_cleanup_refuse_a_live_tap_even_when_quarantined(self): self.manager.apply(self.vm, quarantine=True) self.runner.links = [{"ifname": self.vm.tap, "master": "uvm0", "linkinfo": { "info_slave_kind": "bridge", "info_slave_data": {"group_fwd_mask": 0x4000}, }}, {"ifname": "uvm0", "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}}}] for operation in (self.manager.remove, self.manager.cleanup_connections): with self.assertRaisesRegex(UvmError, "still exists"): operation(self.vm) self.assertEqual(self.manager.status(self.vm), "quarantined") self.assertFalse(any(args[0] == "conntrack" for args, _ in self.runner.calls)) def test_offline_check_errors_do_not_release_policy(self): self.manager.apply(self.vm, quarantine=True) self.runner.fail_command = ("ip", "-j", "-d", "link", "show") with self.assertRaises(CommandError): self.manager.remove(self.vm) self.runner.fail_command = None self.runner.links = [{}] with self.assertRaises(UvmError): self.manager.cleanup_connections(self.vm) self.assertEqual(self.manager.status(self.vm), "unknown") def test_offline_conntrack_cleanup_is_scoped_to_all_four_tuple_addresses(self): self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too. self.manager.cleanup_connections(self.vm) for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): for command in ("--delete", "--list"): self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip), [args for args, _ in self.runner.calls]) self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls)) self.runner.ct_remaining = "tcp 6 100 ESTABLISHED ..." with self.assertRaisesRegex(UvmError, "do not reuse"): self.manager.cleanup_connections(self.vm) self.runner.ct_remaining = "" self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip) with self.assertRaises(CommandError): self.manager.cleanup_connections(self.vm) def test_legacy_cleanup_outside_current_network_uses_only_recorded_ip(self): record = dict(id="vm-legacy", cpu=1, ram_mib=128, guest_ip="203.0.113.7", gateway="203.0.113.1", tap="tap-legacy", mac=self.vm.mac, socket="", config="", log="", status="stopped") for stored_network in (None, "203.0.113.0/24"): with self.subTest(network=stored_network): vm = VmRecord.from_dict({**record, **({"network": stored_network} if stored_network else {})}) self.assertIsNone(vm.ports) self.runner.calls.clear() self.manager.cleanup_connections(vm) expected = [("ip", "-j", "-d", "link", "show")] expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7") for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst") for command in ("--delete", "--list")) # No nft policy, publishing, or unrelated address/conntrack flush. self.assertEqual([args for args, _ in self.runner.calls], expected) def test_cleanup_host_boundaries_use_stored_network_not_current_settings(self): cases = ( ("10.77.8.2", "10.77.0.0/16"), ("100.64.3.4", "100.64.0.0/16"), ("10.42.0.1", None), # Current gateway is not a cleanup constraint. ("10.42.0.0", None), # Do not guess the old prefix from current settings. ("10.42.0.255", None), ("203.0.113.0", "203.0.0.0/16"), ("203.0.113.255", "203.0.0.0/16"), ("203.0.113.8", "203.0.113.8/31"), ("203.0.113.9", "203.0.113.8/31"), ("203.0.113.8", "203.0.113.8/32"), ) for address, network in cases: with self.subTest(address=address, network=network): self.runner.calls.clear() self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None)) deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")] self.assertEqual(len(deletes), 4) self.assertTrue(all(args[-1] == address for args in deletes)) def test_cleanup_rejects_invalid_or_non_host_persisted_addresses(self): cases = [(address, None) for address in ( "not-an-ip", "203.0.113.999", "203.0.113.7/24", "2001:db8::7", "::ffff:203.0.113.7", None, True, 3405803783, "0.0.0.0", "127.0.0.1", "127.12.2.3", "169.254.1.2", "224.0.0.1", "239.255.255.255", "255.255.255.255", )] cases.extend(( ("203.0.113.0", "203.0.113.0/24"), ("203.0.113.255", "203.0.113.0/24"), ("203.0.113.7", "203.0.114.0/24"), ("203.0.113.7", "not-a-network"), ("203.0.113.7", ""), ("203.0.113.7", "2001:db8::/64"), )) for address, network in cases: with self.subTest(address=address, network=network): self.runner.calls.clear() with self.assertRaises(ValidationError): self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None)) self.assertEqual([args for args, _ in self.runner.calls], [("ip", "-j", "-d", "link", "show")]) def test_legacy_public_cleanup_still_requires_tap_absence(self): self.runner.links = [{"ifname": "tap-legacy"}] vm = vm_record(tap="tap-legacy", guest_ip="203.0.113.7", ports=None) with self.assertRaisesRegex(UvmError, "still exists"): self.manager.cleanup_connections(vm) self.assertFalse(any(args[0] == "conntrack" for args, _ in self.runner.calls)) def test_cleanup_compatibility_does_not_allow_a_public_managed_policy(self): vm = vm_record(guest_ip="203.0.113.7", network="203.0.113.0/24", ports=()) with self.assertRaisesRegex(ValidationError, "private guest IP"): self.manager.apply(vm) self.assertEqual(self.runner.calls, []) self.manager.cleanup_connections(vm) def test_availability_checks_kernel_and_full_policy_without_committing(self): self.manager.check_available() self.assertEqual(self.runner.tables, {}) self.assertEqual(self.runner.calls[0][0], ("conntrack", "--version")) self.assertTrue(self.runner.calls[0][1]["check"]) self.assertEqual(self.runner.calls[0][1]["timeout"], 5) batch = next((args, options) for args, options in self.runner.calls if "--check" in args) self.assertIn('"ct"', batch[1]["input_text"]) self.assertIn('"hook": "prerouting"', batch[1]["input_text"]) for release in ("5.2.99", "4.19.0", "not-linux"): self.runner.release = release with self.assertRaisesRegex(UvmError, ">= 5.3"): self.manager.check_available() self.runner.release = "5.3.0" self.runner.fail_batch = True with self.assertRaisesRegex(UvmError, "unavailable"): self.manager.check_available() def test_missing_cleanup_utility_fails_preflight_before_nft(self): self.runner.fail_command = ("conntrack", "--version") with self.assertRaises(CommandError): self.manager.check_available() self.assertEqual([args for args, _ in self.runner.calls], [("conntrack", "--version")]) def test_status_rejects_changed_ct_keys_masks_verdicts_and_quarantine_hooks(self): self.manager.apply(self.vm) original = copy.deepcopy(self.runner.tables) for alteration in ("tuple-key", "ct-mask", "tcp-mask", "return", "extra-set-property"): self.runner.tables = copy.deepcopy(original) rules = [item["rule"] for item in self.policy() if "rule" in item] expressions = [expr for rule in rules for expr in rule["expr"]] if alteration == "tuple-key": match = next(expr["match"] for expr in expressions if expr.get("match", {}).get("left") == {"ct": {"key": "ip saddr", "dir": "original"}}) match["left"] = {"ct": {"key": "saddr", "family": "ip", "dir": "original"}} elif alteration == "ct-mask": next(expr["match"] for expr in expressions if expr.get("match", {}).get("left") == {"ct": {"key": "state"}})["right"] = "established" elif alteration == "tcp-mask": next(expr["match"] for expr in expressions if "&" in expr.get("match", {}).get("left", {}))["left"]["&"][1] = 2 elif alteration == "return": verdict = next(expr for expr in expressions if "return" in expr) verdict.clear() verdict["accept"] = None else: next(item["set"] for item in self.policy() if "set" in item)["timeout"] = 30 with self.subTest(alteration=alteration): self.assertEqual(self.manager.status(self.vm), "drift") self.runner.tables = original self.manager.apply(self.vm, quarantine=True) self.policy().pop() self.assertEqual(self.manager.status(self.vm), "drift") @unittest.skipUnless(os.path.exists("/proc/self/ns/net"), "Linux network namespace handles required") def test_integration_harness_refuses_to_run_in_the_parent_namespace(self): from tests.integration.test_private_ports import _sandbox with open("/proc/self/ns/net", "rb") as parent: with self.assertRaisesRegex(RuntimeError, "parent network namespace"): _sandbox(parent.fileno()) class NftDumpFixtureTests(unittest.TestCase): """Literal upstream JSON fixtures, not output synthesized by FakeRunner. Sources below are from nftables tag v1.0.9: https://git.netfilter.org/nftables/tree/tests?h=v1.0.9 https://github.com/1g4-mirror/nftables/tree/v1.0.9/tests tests/py/nft-test.py lists with numeric-protocol enabled. Files ending in .json.output are its recorded expected nft dumps, not packet tests run here. """ @staticmethod def normalize_rule(text): return _normalize([{"rule": {"family": "bridge", "table": "fixture", "chain": "input", "expr": json.loads(text)}}]) def test_explicit_ct_address_keys_match_upstream_wire_format(self): # tests/py/inet/ct.t.json, "ct original ip daddr 1.2.3.4 accept". # There is no .json.output override for this example: dump == input. dumped = json.loads('''[ {"match": {"left": {"ct": {"dir": "original", "key": "ip daddr"}}, "op": "==", "right": "1.2.3.4"}}, {"accept": null} ]''') self.assertEqual(_ct("ip daddr", "original"), dumped[0]["match"]["left"]) # ct_templates[NFT_CT_SRC_IP] / [NFT_CT_DST_IP] in src/ct.c; the # v1.0.9 json_parse_ct_expr() looks up these keys and ignores family. self.assertEqual(_ct("ip saddr", "original"), {"ct": {"key": "ip saddr", "dir": "original"}}) policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False) addresses = [expr["match"]["left"]["ct"] for item in policy if "rule" in item for expr in item["rule"]["expr"] if "ct" in expr.get("match", {}).get("left", {}) and "addr" in expr["match"]["left"]["ct"]["key"]] self.assertEqual({ct["key"] for ct in addresses}, {"ip saddr", "ip daddr"}) self.assertTrue(all(ct["dir"] == "original" and "family" not in ct for ct in addresses)) def test_upstream_ct_list_and_set_dumps_preserve_distinct_operators(self): # tests/py/any/ct.t.json and ct.t.json.output, ct state list vs set. pairs = ( ('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": ["new", "established", "related", "untracked"]}}]''', '''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": ["established", "related", "new", "untracked"]}}]'''), ('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": {"set": ["new", "established", "related", "untracked"]}}}]''', '''[{"match": {"left": {"ct": {"key": "state"}}, "op": "==", "right": {"set": ["established", "related", "new", "untracked"]}}}]'''), ('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": 8}}]''', '''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}}]'''), ) for source, dumped in pairs: with self.subTest(source=source): self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped)) # Equality against the ORed mask is NOT the list's any-bit test. self.assertNotEqual(self.normalize_rule(pairs[0][1]), self.normalize_rule(pairs[0][1].replace('"op": "in"', '"op": "=="'))) def test_upstream_bridge_icmp_numeric_protocol_dump(self): # tests/py/bridge/icmpX.t.json and icmpX.t.json.output. source = '''[ {"match": {"left": {"payload": {"field": "protocol", "protocol": "ip"}}, "op": "==", "right": "icmp"}}, {"match": {"left": {"payload": {"field": "type", "protocol": "icmp"}}, "op": "==", "right": "echo-request"}} ]''' dumped = '''[ {"match": {"left": {"payload": {"field": "protocol", "protocol": "ip"}}, "op": "==", "right": 1}}, {"match": {"left": {"payload": {"field": "type", "protocol": "icmp"}}, "op": "==", "right": "echo-request"}} ]''' self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped)) self.assertNotEqual(self.normalize_rule(source), self.normalize_rule(dumped.replace('"right": 1', '"right": 6'))) def test_upstream_arp_payload_field_names_and_reordering(self): # tests/py/arp/arp.t.json and arp.t.json.output: # arp daddr ether fe:ed:00:c0:ff:ee arp saddr ip 192.168.1.1 source = '''[ {"match": {"left": {"payload": {"field": "daddr ether", "protocol": "arp"}}, "op": "==", "right": "fe:ed:00:c0:ff:ee"}}, {"match": {"left": {"payload": {"field": "saddr ip", "protocol": "arp"}}, "op": "==", "right": "192.168.1.1"}} ]''' dumped = '''[ {"match": {"left": {"payload": {"field": "saddr ip", "protocol": "arp"}}, "op": "==", "right": "192.168.1.1"}}, {"match": {"left": {"payload": {"field": "daddr ether", "protocol": "arp"}}, "op": "==", "right": "fe:ed:00:c0:ff:ee"}} ]''' self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped)) self.assertNotEqual(self.normalize_rule(source), self.normalize_rule(dumped.replace("saddr ip", "daddr ip"))) policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False) fields = {expr["match"]["left"]["payload"]["field"] for item in policy if "rule" in item for expr in item["rule"]["expr"] if expr.get("match", {}).get("left", {}).get("payload", {}).get("protocol") == "arp"} self.assertEqual(fields, {"htype", "ptype", "hlen", "plen", "operation", "saddr ip", "saddr ether", "daddr ip", "daddr ether"}) def test_upstream_tcp_flag_dump_does_not_hide_mask_changes(self): # tests/py/inet/tcp.t.json.output, tcp flags & (syn|fin) == (syn|fin). dumped = '''[{"match": { "left": {"&": [{"payload": {"field": "flags", "protocol": "tcp"}}, {"|": ["fin", "syn"]}]}, "op": "==", "right": {"|": ["fin", "syn"]} }}]''' numeric = '''[{"match": { "left": {"&": [{"payload": {"field": "flags", "protocol": "tcp"}}, 3]}, "op": "==", "right": 3 }}]''' self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric)) self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]'))) def test_upstream_full_dump_ignores_only_handles_and_metainfo(self): # Literal RULESET from tests/shell/testcases/json/0006obj_comment_0. dumped = json.loads('''{"nftables": [ {"metainfo": {"version": "1.0.5", "release_name": "Lester Gooch #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "t", "handle": 9}}, {"counter": {"family": "inet", "name": "mycounter", "table": "t", "handle": 1, "comment": "my comment in counter", "packets": 0, "bytes": 0}} ]}''')["nftables"] expected = [ {"table": {"family": "inet", "name": "t"}}, {"counter": {"family": "inet", "name": "mycounter", "table": "t", "comment": "my comment in counter", "packets": 0, "bytes": 0}}, ] self.assertEqual(_normalize(dumped), _normalize(expected)) dumped[1]["table"]["flags"] = "dormant" self.assertNotEqual(_normalize(dumped), _normalize(expected)) @unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks") class NftJsonParserTests(unittest.TestCase): """Exercise the actual JSON parser, NOT semantic/kernel/packet validation. A deliberately invalid final command must be the first command rejected. This proves all earlier commands parsed even when cache initialization lacks CAP_NET_ADMIN. --check and the invalid command both prevent any commit. """ @staticmethod def parse(commands): commands = [*commands, {"add": {"uvm_schema_sentinel": None}}] result = subprocess.run( ("nft", "--check", "--json", "-f", "-"), input=json.dumps({"nftables": commands}), text=True, capture_output=True, timeout=5, env={**os.environ, "LC_ALL": "C"}, ) indices = [int(index) for index in re.findall(r"Parsing command array at index (\d+) failed", result.stderr)] return result, indices @classmethod def setUpClass(cls): result, indices = cls.parse([]) if indices != [0]: raise unittest.SkipTest(f"nft parser diagnostics unavailable before netlink: {result.stderr.strip()}") def test_all_generated_transactions_reach_only_the_final_invalid_command(self): runner = FakeRunner() manager = FirewallManager(Settings(), runner) vm = vm_record(ports=(PortRule(1), PortRule(443, "HTTPS"), PortRule(65535))) manager.apply(vm) vm.ports = () manager.apply(vm) vm.ports = None manager.apply(vm, quarantine=True) manager.remove(vm) manager.check_available() transactions = [json.loads(options["input_text"])["nftables"] for args, options in runner.calls if args[0] == "nft" and options["input_text"]] for index, commands in enumerate(transactions): with self.subTest(transaction=index): result, rejected = self.parse(commands) self.assertNotEqual(result.returncode, 0) self.assertEqual(rejected, [len(commands)], result.stderr) def test_parser_negative_controls_fail_before_the_sentinel(self): bad_commands = ( {"add": {"set": {"family": "bridge", "table": "probe", "name": "ports", "type": "inet_service", "elem": []}}}, {"delete": {"table": {"family": "bridge", "handle": "not-an-integer"}}}, {"add": {"chain": {"family": "bridge", "table": "probe", "name": "input", "type": "filter", "hook": "not-a-hook", "prio": 0}}}, {"add": {"rule": {"family": "bridge", "table": "probe", "chain": "input", "expr": [ {"match": {"left": {"ct": {"key": "state", "dir": "original"}}, "op": "in", "right": "new"}}, ]}}}, {"add": {"rule": {"family": "bridge", "table": "probe", "chain": "input", "expr": [ {"match": {"left": {"payload": {"protocol": "arp", "field": "saddr"}}, "op": "==", "right": "10.42.0.2"}}, ]}}}, ) for command in bad_commands: with self.subTest(command=command): result, rejected = self.parse([command]) self.assertNotEqual(result.returncode, 0) self.assertEqual(rejected, [0], result.stderr) class RulesetModel: """Evaluate the emitted pure match/jump/return/drop subset, not a kernel. Conntrack metadata is supplied by each test. This catches policy/direction mistakes but deliberately does not pretend to test native bridge tracking. """ def __init__(self, tables): self.tables = tables def permits(self, packet, *hooks): for hook in hooks: for objects in self.tables: chains = {item["chain"]["name"]: item["chain"] for item in objects if "chain" in item} rules = {} sets = {item["set"]["name"]: item["set"].get("elem", []) for item in objects if "set" in item} for item in objects: if "rule" in item: rules.setdefault(item["rule"]["chain"], []).append(item["rule"]["expr"]) def value(expr): if isinstance(expr, str) and expr.startswith("@"): return sets[expr[1:]] if not isinstance(expr, dict): return expr if "meta" in expr: return packet.get(expr["meta"]["key"]) if "payload" in expr: payload = expr["payload"] return packet.get((payload["protocol"], payload["field"])) if "ct" in expr: ct = expr["ct"] return packet.get(("ct", ct["key"], ct.get("dir"))) if "set" in expr: return expr["set"] if "&" in expr: left, right = map(value, expr["&"]) return None if left is None else left & right raise AssertionError(f"unmodeled expression {expr}") def evaluate(name): for expressions in rules.get(name, []): for expr in expressions: if "match" in expr: match = expr["match"] left, right = value(match["left"]), value(match["right"]) if left is None: break same = left in right if isinstance(right, list) else left == right if not (not same if match["op"] == "!=" else same): break elif "jump" in expr: verdict = evaluate(expr["jump"]["target"]) if verdict != "return": return verdict elif "return" in expr: return "return" elif "drop" in expr: return "drop" elif "accept" in expr: return "accept" else: raise AssertionError(f"unmodeled statement {expr}") return "return" for name, chain in chains.items(): if chain.get("hook") == hook and evaluate(name) == "drop": return False return True def packet(src, dst, *, protocol="tcp", reply=False, state="established", dport=8080, flags=16): original_src, original_dst = (dst, src) if reply else (src, dst) return { "iifname": src.tap, "oifname": dst.tap, ("ether", "type"): "ip", ("ether", "saddr"): src.mac, ("ether", "daddr"): dst.mac, ("ip", "saddr"): src.guest_ip, ("ip", "daddr"): dst.guest_ip, ("ip", "protocol"): protocol, ("tcp", "flags"): flags, ("ct", "direction", None): "reply" if reply else "original", ("ct", "ip saddr", "original"): original_src.guest_ip, ("ct", "ip daddr", "original"): original_dst.guest_ip, ("ct", "proto-dst", "original"): dport, ("ct", "protocol", None): protocol, ("ct", "state", None): state, ("icmp", "type"): "echo-reply" if reply else "echo-request", } class FirewallPolicyTests(unittest.TestCase): def setUp(self): self.manager = FirewallManager(Settings(), FakeRunner()) self.guest = vm_record() self.peer = vm_record(id="vm-two", tap="tap-two", guest_ip="10.42.0.3", mac="02:fc:00:00:00:02", ports=()) self.host = vm_record(tap="", guest_ip="10.42.0.1", mac="02:00:00:00:00:01") def model(self, *, quarantine=False, peer=True, reverse=False): tables = [self.manager._policy(self.guest, quarantine=quarantine)] if peer: tables.append(self.manager._policy(self.peer, quarantine=False)) return RulesetModel(list(reversed(tables)) if reverse else tables) def test_all_hooks_and_return_verdicts_are_present_with_early_identity(self): policy = self.manager._policy(self.guest, quarantine=False) chains = [item["chain"] for item in policy if "chain" in item and "hook" in item["chain"]] self.assertEqual({chain["hook"] for chain in chains}, {"prerouting", "input", "forward", "output"}) self.assertLess(next(chain["prio"] for chain in chains if chain["hook"] == "prerouting"), -200) verdicts = [expr for item in policy if "rule" in item for expr in item["rule"]["expr"]] self.assertIn({"return": None}, verdicts) self.assertNotIn({"accept": None}, verdicts) def test_host_and_peer_service_connections_close_in_both_directions(self): for client, incoming, outgoing in ((self.host, ("output",), ("prerouting", "input")), (self.peer, ("prerouting", "forward"), ("prerouting", "forward"))): for reverse in (False, True): with self.subTest(client=client.guest_ip, reverse=reverse): self.guest.ports = (SimpleNamespace(port=8080, protocol="TCP"),) traffic = packet(client, self.guest) response = packet(self.guest, client, reply=True) self.assertTrue(self.model(reverse=reverse).permits(traffic, *incoming)) self.assertTrue(self.model(reverse=reverse).permits(response, *outgoing)) self.guest.ports = () self.assertFalse(self.model(reverse=reverse).permits(traffic, *incoming)) self.assertFalse(self.model(reverse=reverse).permits(response, *outgoing)) def test_closed_policy_preserves_guest_egress_tcp_udp_and_icmp_replies(self): self.guest.ports = () for protocol in ("tcp", "udp", "icmp"): with self.subTest(protocol=protocol): request = packet(self.guest, self.host, protocol=protocol, state="new", flags=2) response = packet(self.host, self.guest, protocol=protocol, reply=True) self.assertTrue(self.model().permits(request, "prerouting", "input")) self.assertTrue(self.model().permits(response, "output")) response[("ct", "direction", None)] = "original" self.assertFalse(self.model().permits(response, "output")) def test_peer_egress_never_skips_the_destination_policy(self): for reverse in (False, True): traffic = packet(self.guest, self.peer, state="new", flags=2) self.assertFalse(self.model(reverse=reverse).permits(traffic, "prerouting", "forward")) self.peer.ports = (SimpleNamespace(port=8080, protocol="TCP"),) self.assertTrue(self.model(reverse=reverse).permits(traffic, "prerouting", "forward")) self.peer.ports = () def test_new_tcp_requires_syn_for_inbound_and_egress_tracking_loss(self): for flags in (0, 16, 18, 24, 1, 4, 3): with self.subTest(flags=flags): ingress = packet(self.host, self.guest, state="new", flags=flags) lost_server_flow = packet(self.guest, self.host, state="new", flags=flags) self.assertFalse(self.model().permits(ingress, "output")) self.assertFalse(self.model().permits(lost_server_flow, "prerouting", "input")) for flags in (2, 2 | 64 | 128): self.assertTrue(self.model().permits(packet(self.host, self.guest, state="new", flags=flags), "output")) for state in ("invalid", "untracked", "related"): self.assertFalse(self.model().permits(packet(self.host, self.guest, state=state), "output")) def test_current_original_destination_port_and_address_are_required(self): for key, value in ((("ct", "proto-dst", "original"), 22), (("ct", "ip daddr", "original"), "10.42.0.99"), (("ct", "protocol", None), "udp")): for reply in (False, True): traffic = packet(self.guest, self.host, reply=True) if reply else packet(self.host, self.guest) traffic[key] = value self.assertFalse(self.model().permits(traffic, *(('prerouting', 'input') if reply else ('output',)))) def test_inbound_udp_and_non_tcp_transports_are_not_opened(self): for protocol in ("udp", "sctp", "gre"): for state in ("new", "established", "related"): self.assertFalse(self.model().permits(packet(self.host, self.guest, protocol=protocol, state=state), "output")) def test_required_icmp_is_authorized_by_parent_tuple_not_blanket_related(self): self.guest.ports = () self.assertTrue(self.model().permits(packet(self.host, self.guest, protocol="icmp", state="new"), "output")) for protocol in ("tcp", "udp", "icmp"): traffic = packet(self.host, self.guest, protocol=protocol, state="related", reply=True) traffic[("ip", "protocol")] = "icmp" for icmp in ("destination-unreachable", "time-exceeded", "parameter-problem"): traffic[("icmp", "type")] = icmp self.assertTrue(self.model().permits(traffic, "output")) traffic[("icmp", "type")] = "redirect" self.assertFalse(self.model().permits(traffic, "output")) service_error = packet(self.host, self.guest, state="related") service_error.update({("ip", "protocol"): "icmp", ("icmp", "type"): "destination-unreachable"}) self.assertFalse(self.model().permits(service_error, "output")) def test_identity_is_enforced_before_learning_and_ipv6_and_vlan_are_dropped(self): for key, value in ((("ether", "saddr"), self.peer.mac), (("ip", "saddr"), self.peer.guest_ip), (("ether", "type"), "ip6"), (("ether", "type"), "vlan"), (("ether", "type"), "8021ad")): traffic = packet(self.guest, self.host) traffic[key] = value self.assertFalse(self.model().permits(traffic, "prerouting")) for ethertype in ("ip6", "vlan", "8021ad"): traffic = packet(self.host, self.guest) traffic[("ether", "type")] = ethertype self.assertFalse(self.model().permits(traffic, "output")) traffic = packet(self.host, self.guest) traffic[("ip", "daddr")] = self.peer.guest_ip self.assertFalse(self.model().permits(traffic, "output")) def test_arp_identity_and_address_probes_are_narrow(self): traffic = packet(self.guest, self.host) traffic.update({("ether", "type"): "arp", ("arp", "htype"): 1, ("arp", "ptype"): "ip", ("arp", "hlen"): 6, ("arp", "plen"): 4, ("arp", "operation"): "request", ("arp", "saddr ether"): self.guest.mac, ("arp", "saddr ip"): self.guest.guest_ip, ("arp", "daddr ip"): self.host.guest_ip}) self.assertTrue(self.model().permits(traffic, "prerouting", "input")) for key, bad in ((("arp", "saddr ip"), self.peer.guest_ip), (("arp", "saddr ether"), self.peer.mac), (("arp", "hlen"), 5), (("arp", "operation"), "rreply")): self.assertFalse(self.model().permits({**traffic, key: bad}, "prerouting")) self.assertFalse(self.model().permits({**traffic, key: bad}, "input")) traffic[("arp", "saddr ip")] = "0.0.0.0" self.assertFalse(self.model().permits(traffic, "prerouting")) traffic[("arp", "daddr ip")] = self.guest.guest_ip self.assertTrue(self.model().permits(traffic, "prerouting")) def test_quarantine_drops_every_path_but_unrelated_traffic_is_untouched(self): for protocol in ("tcp", "udp", "icmp"): for endpoint, hook in ((self.host, "input"), (self.peer, "forward")): self.assertFalse(self.model(quarantine=True).permits(packet(self.guest, endpoint, protocol=protocol), "prerouting", hook)) self.assertFalse(self.model(quarantine=True).permits(packet(endpoint, self.guest, protocol=protocol), "output" if hook == "input" else "forward")) other = vm_record(tap="unmanaged", guest_ip="10.42.0.99") self.assertTrue(self.model(quarantine=True, peer=False).permits(packet(other, self.host), "prerouting", "input")) if __name__ == "__main__": unittest.main()