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.
This commit is contained in:
2026-09-09 17:27:50 +05:30
parent 4ce14c3378
commit d8a25209b9
31 changed files with 9576 additions and 1331 deletions
+496
View File
@@ -0,0 +1,496 @@
"""Private IPv4 service filtering, not host-port forwarding or publishing.
The lifecycle owner must serialize mutations, keep a live VM's identity immutable,
install the policy before starting its guest, and quarantine before teardown.
Only bridge/TAP traffic is covered; do not offload it around the bridge hooks.
Before making a TAP usable, attach it to an STP-disabled software bridge and set
the TAP's bridge_slave group_fwd_mask bit 0x4000. Otherwise Linux can deliver LLDP
(and STP) before *any* bridge hook and learn spoofed MACs. Network/lifecycle owns
these link settings; this manager only verifies them when the TAP exists.
No br_netfilter sysctls, NAT, persistent applied flags, or live conntrack flushes
are used. Native bridge conntrack (Linux >= 5.3) and nft JSON support are required.
"""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from ipaddress import IPv4Address, IPv4Network
from types import SimpleNamespace
from typing import Any
from .config import Settings
from .domain import PortRule, VmRecord
from .errors import UvmError, ValidationError
from .system import CommandResult, CommandRunner
_COMMAND_TIMEOUT_S = 5.0
_PRIVATE_NETWORKS = tuple(IPv4Network(value) for value in (
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
))
def _match(left: dict[str, Any], right: Any, op: str = "==") -> dict[str, Any]:
return {"match": {"op": op, "left": left, "right": right}}
def _payload(protocol: str, field: str) -> dict[str, Any]:
return {"payload": {"protocol": protocol, "field": field}}
def _ct(key: str, direction: str | None = None) -> dict[str, Any]:
# nft 1.0.9 parser_json.c/ct.c use the literal keys "ip saddr"/"ip daddr".
# Its CT parser ignores the "family" property advertised by the JSON manual.
return {"ct": {"key": key, **({"dir": direction} if direction else {})}}
class FirewallManager:
"""Own one table per (resolved settings.base, VM id) in the current netns.
``ports=None`` is legacy/unmanaged, not an empty allowlist. Normal apply
refuses it; quarantine works even without a port policy or valid IP/MAC.
Errors leave existing rules in place (or the committed replacement on a
readback/timeout failure). Callers must not start/release a VM on an error.
"""
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
self._settings = settings
self._runner = runner
def apply(self, vm: VmRecord, *, quarantine: bool = False) -> None:
"""Atomically install/replace only this VM's owned table; retain CT state."""
expected = self._policy(vm, quarantine=quarantine)
if not self._bridge_paths_safe(vm):
raise UvmError(
"firewall needs an STP-disabled software bridge and TAP bridge_slave "
"group_fwd_mask bit 0x4000 (LLDP forwarding) before enforcement"
)
table = expected[0]["table"]
current = self._read_table(table["name"])
commands = []
if current is not None:
commands.append(self._delete_owned(current, table))
# Exclusive create plus deletion by handle also protects against a table
# being replaced by a foreign owner between inspection and commit.
commands.append({"create": expected[0]})
commands.extend({"add": item} for item in expected[1:])
self._transaction(commands)
installed = self._read_table(table["name"])
if installed is None or _normalize(installed) != _normalize(expected):
raise UvmError("firewall readback differs from the requested policy; keep VM isolated")
def remove(self, vm: VmRecord) -> None:
"""Remove an owned table only after the TAP is gone; absence is harmless."""
self._require_offline(vm)
table = self._table_identity(vm)
current = self._read_table(table["name"])
if current is not None:
self._transaction([self._delete_owned(current, table)])
def status(self, vm: VmRecord) -> str:
"""Inspect actual hooks, ordered rules, identity and complete port set.
Missing legacy tables are unmanaged. Foreign or altered tables are
drift; failed inspection/invalid metadata is unknown. A legacy VM can
report quarantined. This is a ruleset snapshot, not a reachability test.
"""
try:
table = self._table_identity(vm)
current = self._read_table(table["name"])
if current is None:
return "unmanaged" if getattr(vm, "ports", None) is None else "missing"
if _normalize(current) == _normalize(self._policy(vm, quarantine=True)):
return "quarantined" if self._bridge_paths_safe(vm) else "drift"
if getattr(vm, "ports", None) is not None:
if _normalize(current) == _normalize(self._policy(vm, quarantine=False)):
return "enforced" if self._bridge_paths_safe(vm) else "drift"
return "drift"
except (UvmError, ValueError, TypeError, KeyError):
return "unknown"
def check_available(self) -> None:
"""Check cleanup tooling, kernel and a full non-committing nft policy."""
self._run(("conntrack", "--version"))
release = self._run(("uname", "-r")).stdout.strip()
version = re.match(r"^(\d+)\.(\d+)", release)
if version is None or tuple(map(int, version.groups())) < (5, 3):
raise UvmError("private ports require Linux >= 5.3 with native bridge conntrack")
probe = SimpleNamespace(
id=f"probe-{uuid.uuid4().hex}", tap=f"uvm{uuid.uuid4().hex[:11]}",
guest_ip="10.0.0.2", gateway="10.0.0.1",
network="10.0.0.0/24", mac="02:00:00:00:00:02",
ports=(PortRule(443),),
)
policy = self._policy(probe, quarantine=False) # type: ignore[arg-type]
commands = [{"create": policy[0]}, *({"add": item} for item in policy[1:])]
try:
self._transaction(commands, check_only=True)
except UvmError as error:
raise UvmError(f"nft bridge/conntrack policy is unavailable: {error}") from error
def cleanup_connections(self, vm: VmRecord) -> None:
"""Offline, IP-scoped CT deletion before address reuse, never for close.
All four tuple addresses are checked, including NAT reply tuples. A
failed deletion is tolerated only if a successful list proves absence.
The caller must retain the address reservation until this succeeds.
"""
self._require_offline(vm)
address = self._guest_ip(vm)
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
selector = ("--family", "ipv4", field, address)
self._run(("conntrack", "--delete", *selector), check=False)
remaining = self._run(("conntrack", "--list", *selector))
if remaining.stdout.strip():
raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP")
def _run(self, command: tuple[str, ...], **kwargs: Any) -> CommandResult:
return self._runner.run(
command, capture=True, timeout=_COMMAND_TIMEOUT_S, **kwargs,
)
def _transaction(self, commands: list[dict[str, Any]], *, check_only: bool = False) -> None:
args = ("nft", "--json", *(("--check",) if check_only else ()), "-f", "-")
self._run(args, input_text=json.dumps({"nftables": commands}))
def _table_identity(self, vm: VmRecord) -> dict[str, Any]:
if not isinstance(vm.id, str) or not vm.id:
raise ValidationError("firewall requires a nonempty VM ID")
identity = json.dumps([str(self._settings.base.resolve()), vm.id]).encode("utf-8")
digest = hashlib.sha256(identity).hexdigest()
return {"family": "bridge", "name": f"uvm_fw_{digest[:24]}",
"comment": f"uvm-private-ports:v1:{digest}"}
def _tap(self, vm: VmRecord) -> str:
if not isinstance(vm.tap, str) or re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}", vm.tap) is None:
raise ValidationError("firewall requires an exact, safe TAP name (1-15 characters)")
if vm.tap in {self._settings.bridge, getattr(vm, "bridge", None)}:
raise ValidationError("firewall TAP must not be the bridge itself")
return vm.tap
def _guest_ip(self, vm: VmRecord) -> str:
try:
address = IPv4Address(vm.guest_ip)
network = IPv4Network(getattr(vm, "network", None) or self._settings.network)
except (ValueError, TypeError) as error:
raise ValidationError("firewall requires a valid guest IPv4 address/network") from error
gateway = str(getattr(vm, "gateway", None) or self._settings.gateway)
if (not any(address in private for private in _PRIVATE_NETWORKS)
or address not in network
or address in {network.network_address, network.broadcast_address}
or str(address) == gateway):
raise ValidationError("firewall requires a usable private guest IP inside its network")
return str(address)
def _links(self) -> list[dict[str, Any]]:
try:
links = json.loads(self._run(("ip", "-j", "-d", "link", "show")).stdout)
if not isinstance(links, list) or any(
not isinstance(link, dict) or not isinstance(link.get("ifname"), str)
for link in links
):
raise ValueError("invalid link listing")
except (ValueError, TypeError) as error:
raise UvmError("cannot inspect network interfaces; retaining firewall/conntrack state") from error
return links
def _bridge_paths_safe(self, vm: VmRecord) -> bool:
links = self._links()
name = self._tap(vm)
taps = [link for link in links if link["ifname"] == name]
if not taps:
return True # Policy may precede TAP creation; main must prepare it before bringing it up.
if len(taps) != 1:
return False
tap = taps[0]
bridges = [link for link in links if tap.get("master") in (link["ifname"], link.get("ifindex"))]
if len(bridges) != 1:
return False
try:
port = tap["linkinfo"]
bridge = bridges[0]["linkinfo"]
mask = int(str(port["info_slave_data"]["group_fwd_mask"]), 0)
return (port["info_slave_kind"] == "bridge" and bridge["info_kind"] == "bridge"
and bridge["info_data"]["stp_state"] == 0
and 0 <= mask <= 65535 and bool(mask & 0x4000))
except (KeyError, TypeError, ValueError):
return False
def _require_offline(self, vm: VmRecord) -> None:
tap = self._tap(vm)
if any(link["ifname"] == tap for link in self._links()):
raise UvmError(f"TAP {tap} still exists; quarantine it and remove the TAP first")
def _read_table(self, name: str) -> list[dict[str, Any]] | None:
# A successful table inventory, not a localized 'not found' error,
# distinguishes missing policy from missing privileges/tools.
def objects(command: tuple[str, ...]) -> list[dict[str, Any]]:
try:
data = json.loads(self._run(command).stdout)["nftables"]
if not isinstance(data, list) or any(
not isinstance(item, dict) or len(item) != 1
or not isinstance(next(iter(item.values())), dict) for item in data
):
raise ValueError("invalid nft object list")
return [item for item in data if "metainfo" not in item]
except (ValueError, KeyError, TypeError) as error:
raise UvmError("could not inspect nft bridge tables") from error
tables = objects(("nft", "--json", "list", "tables", "bridge"))
if any("table" not in item or item["table"].get("family") != "bridge"
or not isinstance(item["table"].get("name"), str) for item in tables):
raise UvmError("invalid nft bridge table inventory")
matches = [item for item in tables if item["table"].get("name") == name]
if not matches:
return None
if len(matches) != 1:
raise UvmError("ambiguous nft bridge table inventory")
data = objects(("nft", "--json", "--numeric-protocol", "list", "table", "bridge", name))
headers = [item["table"] for item in data if "table" in item]
if len(headers) != 1 or headers[0].get("name") != name or headers[0].get("family") != "bridge":
raise UvmError("invalid nft bridge table snapshot")
return data
@staticmethod
def _delete_owned(current: list[dict[str, Any]], table: dict[str, Any]) -> dict[str, Any]:
header = next(item["table"] for item in current if "table" in item)
if any(header.get(key) != value for key, value in table.items()):
raise UvmError(f"refusing to touch foreign nft table {table['name']}")
handle = header.get("handle")
if type(handle) is not int or handle <= 0:
raise UvmError("owned nft table has no valid handle; refusing unsafe deletion")
return {"delete": {"table": {"family": "bridge", "handle": handle}}}
def _policy(self, vm: VmRecord, *, quarantine: bool) -> list[dict[str, Any]]:
ports = getattr(vm, "ports", None)
if ports is None and not quarantine:
raise ValidationError("legacy VM has no managed port policy; only quarantine is allowed")
table = self._table_identity(vm)
tap = self._tap(vm)
scope = {"family": "bridge", "table": table["name"]}
policy: list[dict[str, Any]] = [{"table": table}]
def chain(name: str, hook: str | None = None, priority: int = 0) -> None:
definition = {**scope, "name": name}
if hook is not None:
definition.update(type="filter", hook=hook, prio=priority, policy="accept")
policy.append({"chain": definition})
def rule(name: str, *expressions: dict[str, Any]) -> None:
policy.append({"rule": {**scope, "chain": name, "expr": list(expressions)}})
for hook in ("prerouting", "input", "forward", "output"):
chain(hook, hook, -310 if hook == "prerouting" else 0)
if not quarantine:
for name in ("identity", "from_vm", "to_vm"):
chain(name)
for hook, interface, target in (
("prerouting", "iifname", "identity"),
("input", "iifname", "from_vm"),
("forward", "iifname", "from_vm"),
("forward", "oifname", "to_vm"),
("output", "oifname", "to_vm"),
):
rule(hook, _match({"meta": {"key": interface}}, tap),
{"drop": None} if quarantine else {"jump": {"target": target}})
if quarantine:
return policy
address = self._guest_ip(vm)
mac = vm.mac.lower() if isinstance(vm.mac, str) else ""
if (re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", mac) is None
or int(mac[:2], 16) & 1 or mac == "00:00:00:00:00:00"):
raise ValidationError("firewall requires a nonzero unicast guest MAC")
if not isinstance(ports, tuple):
raise ValidationError("managed ports must be a tuple of TCP/HTTP/HTTPS port rules")
allowed = set()
for port_rule in ports:
port = getattr(port_rule, "port", None)
if (type(port) is not int or not 1 <= port <= 65535
or getattr(port_rule, "protocol", None) not in ("TCP", "HTTP", "HTTPS")):
raise ValidationError("private ports must be TCP/HTTP/HTTPS integers from 1 to 65535")
allowed.add(port)
# json_parse_set_expr() in nft 1.0.9 rejects [], even for an empty named
# set. Omit elem, as set_print_json() does when listing an empty set.
policy.insert(1, {"set": {**scope, "name": "tcp_ports", "type": "inet_service",
"flags": ["constant"],
**({"elem": sorted(allowed)} if allowed else {})}})
drop, success = {"drop": None}, {"return": None}
ipv4 = _match(_payload("ether", "type"), "ip")
arp = _match(_payload("ether", "type"), "arp")
arp_header = [arp, _match(_payload("arp", "htype"), 1),
_match(_payload("arp", "ptype"), "ip"),
_match(_payload("arp", "hlen"), 6), _match(_payload("arp", "plen"), 4)]
# Before conntrack (-200) and FDB learning: no MAC/IP/ARP spoofing,
# IPv6, VLAN encapsulation or other EtherTypes from a managed port.
rule("identity", _match(_payload("ether", "saddr"), mac, "!="), drop)
rule("identity", ipv4, _match(_payload("ip", "saddr"), address), success)
rule("identity", *arp_header,
_match(_payload("arp", "operation"), {"set": ["request", "reply"]}),
_match(_payload("arp", "saddr ether"), mac),
_match(_payload("arp", "saddr ip"), address), success)
# RFC 5227 address probes may use zero, but only probe this VM's IP.
rule("identity", *arp_header, _match(_payload("arp", "operation"), "request"),
_match(_payload("arp", "saddr ether"), mac),
_match(_payload("arp", "saddr ip"), "0.0.0.0"),
_match(_payload("arp", "daddr ip"), address), success)
rule("identity", drop)
# Some link-local bridge frames take input without prerouting. Recheck
# identity there too, before its local-finish callback can learn a MAC.
rule("from_vm", {"jump": {"target": "identity"}})
rule("from_vm", arp, success)
rule("to_vm", *arp_header, _match(_payload("arp", "daddr ip"), address),
_match(_payload("arp", "operation"), "request"), success)
rule("to_vm", *arp_header, _match(_payload("arp", "daddr ip"), address),
_match(_payload("arp", "daddr ether"), mac),
_match(_payload("arp", "operation"), "reply"), success)
for name, outbound in (("from_vm", True), ("to_vm", False)):
rule(name, _match(_payload("ether", "type"), "ip", "!="), drop)
if not outbound:
rule(name, ipv4, _match(_payload("ip", "daddr"), address, "!="), drop)
rule(name, _match(_payload("ether", "daddr"), mac, "!="), drop)
# Loose TCP tracking must not reclassify a server's midstream data
# as a newly guest-initiated connection after tracking loss.
rule(name, ipv4, _match(_payload("ip", "protocol"), "tcp"),
_match(_ct("state"), "new", "in"),
_match({"&": [_payload("tcp", "flags"), 23]}, 2, "!="), drop)
for client in (True, False):
original = outbound == client
flow = [_match(_ct("direction"), "original" if original else "reply"),
_match(_ct("ip saddr" if client else "ip daddr", "original"), address)]
states = ["new", "established"] if original else ["established"]
state = _match(_ct("state"), states[0] if len(states) == 1 else states, "in")
for protocol in (("tcp", "udp", "icmp") if client else ("tcp", "icmp")):
parent = [*flow, _match(_ct("protocol"), protocol)]
if not client and protocol == "tcp":
parent.append(_match(_ct("proto-dst", "original"), "@tcp_ports"))
packet = [ipv4, _match(_payload("ip", "protocol"), protocol)]
if protocol == "icmp":
packet.append(_match(_payload("icmp", "type"),
"echo-request" if original else "echo-reply"))
rule(name, *packet, *parent, state, success)
# Related is restricted to essential ICMP errors and the
# same authorized parent tuple, never TCP/UDP helper flows.
rule(name, ipv4, _match(_payload("ip", "protocol"), "icmp"),
_match(_payload("icmp", "type"), {"set": [
"destination-unreachable", "time-exceeded", "parameter-problem",
]}), *parent, _match(_ct("state"), "related", "in"), success)
rule(name, drop)
return policy
def _normalize(objects: list[dict[str, Any]]) -> str:
"""Canonical nft JSON, retaining every effective object, rule and verdict.
Ignore only handles, empty set presentation and nft's redundant protocol
dependencies. Rule order is significant; pure conjunctions and sets are not.
Unknown expressions/properties remain different rather than being ignored.
"""
def canonical(value: Any) -> Any:
if isinstance(value, list):
return [canonical(item) for item in value]
if not isinstance(value, dict):
return value
value = {key: canonical(item) for key, item in value.items()}
if (set(value) == {"|"} and isinstance(value["|"], list)
and all(type(item) is int for item in value["|"])):
result = 0
for item in value["|"]:
result |= item
return result
if set(value) == {"set"} and isinstance(value["set"], list):
value["set"] = sorted(value["set"], key=lambda item: json.dumps(item, sort_keys=True))
return value
def bitmask(value: Any, names: dict[str, int]) -> Any:
if isinstance(value, str):
return names.get(value, value)
if isinstance(value, dict) and set(value) == {"|"}:
value = value["|"]
if isinstance(value, list):
bits = [bitmask(item, names) for item in value]
if all(type(item) is int for item in bits):
result = 0
for item in bits:
result |= item
return result
return value
definitions, rules = [], {}
for item in objects:
kind, raw = next(iter(item.items()))
if kind == "metainfo":
continue
obj = {key: canonical(value) for key, value in raw.items() if key != "handle"}
if kind == "set":
elements = obj.get("elem", [])
if not isinstance(elements, list):
elements = [elements]
obj["elem"] = sorted(elements, key=lambda value: json.dumps(value, sort_keys=True))
if obj.get("flags") == []:
del obj["flags"]
elif isinstance(obj.get("flags"), str):
obj["flags"] = [obj["flags"]]
if kind != "rule":
definitions.append({kind: obj})
continue
expressions = obj.get("expr", [])
if not isinstance(expressions, list) or not isinstance(obj.get("chain"), str):
raise UvmError("invalid nft rule snapshot")
tcp_flags = {"fin": 1, "syn": 2, "rst": 4, "psh": 8, "ack": 16,
"urg": 32, "ecn": 64, "cwr": 128}
for expression in expressions:
if not isinstance(expression, dict) or not isinstance(expression.get("match", {}), dict):
continue
match = expression.get("match", {})
left = match.get("left")
if match and not {"left", "right", "op"}.issubset(match):
raise UvmError("invalid nft match snapshot")
right = match.get("right")
if left in (_payload("ip", "protocol"), _ct("protocol")) and type(right) is int:
match["right"] = {1: "icmp", 6: "tcp", 17: "udp"}.get(right, right)
if match.get("op") == "in" and (
(isinstance(right, str) and right.startswith("@"))
or (isinstance(right, dict) and set(right) == {"set"})
):
match["op"] = "=="
if left == _ct("state"):
match["right"] = bitmask(match["right"], {
"invalid": 1, "established": 2, "related": 4, "new": 8, "untracked": 64,
})
if (isinstance(left, dict) and isinstance(left.get("&"), list)
and len(left["&"]) == 2 and left["&"][0] == _payload("tcp", "flags")):
left["&"][1] = bitmask(left["&"][1], tcp_flags)
match["right"] = bitmask(match["right"], tcp_flags)
# nft's replayable JSON omits dependencies implied by named payloads.
encoded = json.dumps(expressions, sort_keys=True)
has_tcp = '"protocol": "tcp"' in encoded
has_icmp = '"protocol": "icmp"' in encoded
expressions = [expr for expr in expressions if not (
(has_tcp and expr == _match(_payload("ip", "protocol"), "tcp"))
or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
encoded = json.dumps(expressions, sort_keys=True)
expressions = [expr for expr in expressions if not (
('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp"))
or (('"protocol": "ip"' in encoded or has_icmp)
and expr == _match(_payload("ether", "type"), "ip"))
)]
count = len(expressions)
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
count -= 1
if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]):
expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
obj["expr"] = expressions
rules.setdefault(obj.get("chain", ""), []).append(obj)
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
return json.dumps([definitions, rules], sort_keys=True, separators=(",", ":"))