Files
nest/tests/test_firewall.py
T
kstyagi d8a25209b9 Refactor VM lifecycle management and enhance validation
- Introduced a new method `installed_kernel` in `ImageStore` to handle kernel verification and checksum validation.
- Updated `Installer` to include additional packages: `nftables` and `conntrack`.
- Refined `LifecycleService` to improve error handling and state management during VM creation, starting, stopping, and deletion.
- Enhanced port management with new methods for setting and syncing ports, including validation for port rules.
- Improved error handling in `StateStore` to raise specific `NotFoundError` and `ConflictError` exceptions.
- Updated network management to ensure proper TAP device handling and firewall integration.
- Enhanced API responses in `vms.py` to include policy status for better client-side handling.
- Added validation for VM names and ports in `validation.py` to enforce stricter rules and prevent conflicts.
2026-09-09 17:27:50 +05:30

927 lines
49 KiB
Python

"""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_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()