1001 lines
54 KiB
Python
1001 lines
54 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_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()
|