Files
kstyagi fb80ce7c8a feat: Add storage management for VM creation and resizing
- Introduced storage parameter in VmCreateRequest and VmResizeRequest models.
- Implemented storage validation to ensure minimum and maximum limits.
- Enhanced CLI to support storage options during VM creation and resizing.
- Updated lifecycle management to handle pending resize operations for VM resources.
- Added methods for resizing VM disks and validating storage capacity.
- Updated API endpoints to accommodate new storage functionality.
- Refactored related validation and configuration logic to support storage management.
2026-09-21 19:16:33 +05:30

528 lines
28 KiB
Python

"""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)])
if self._read_table(table["name"]) is not None:
raise UvmError("firewall table remains or was replaced after removal; keep the VM reservation")
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.
Legacy non-RFC1918 addresses are allowed here; only the recorded subnet,
when present, determines host-address boundaries, never current settings.
"""
self._require_offline(vm)
try:
address = IPv4Address(str(vm.guest_ip))
stored_network = getattr(vm, "network", None)
network = IPv4Network(str(stored_network)) if stored_network is not None else None
except (ValueError, TypeError) as error:
raise ValidationError("conntrack cleanup requires a valid persisted IPv4 address/network") from error
if (address.is_unspecified or address.is_loopback or address.is_link_local
or address.is_multicast or address == IPv4Address("255.255.255.255")):
raise ValidationError("conntrack cleanup requires a usable persisted unicast IPv4 address")
# /31 point-to-point subnets and /32 host routes have no broadcast address.
if network is not None and (
address not in network or (network.prefixlen < 31
and address in {network.network_address, network.broadcast_address})
):
raise ValidationError("conntrack cleanup requires a usable guest IP inside its persisted network")
address = str(address)
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
selector = ("--family", "ipv4", field, address)
self._run(("conntrack", "-D", *selector), check=False)
remaining = self._run(("conntrack", "-L", *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.
Normalize handles, empty set presentation and implied protocol dependencies
without losing address-family constraints. 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
def payload_protocols(value: Any) -> set[str]:
if not isinstance(value, dict):
return set()
payload = value.get("payload")
if (set(value) == {"payload"} and isinstance(payload, dict)
and set(payload) == {"protocol", "field"}
and isinstance(payload["protocol"], str) and isinstance(payload["field"], str)):
return {payload["protocol"]}
if set(value) == {"&"} and isinstance(value["&"], list) and len(value["&"]) == 2:
return payload_protocols(value["&"][0]) | payload_protocols(value["&"][1])
return set()
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)
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]):
matches = expressions[:count]
protocols = set().union(*(payload_protocols(expr["match"].get("left")) for expr in matches))
# nft elides ether type ip before ip protocol tcp. Materialize its
# IPv4 constraint BEFORE folding the transport dependency: a TCP
# payload alone can also match IPv6 in a bridge table.
for payloads, ethertype in (({"ip", "icmp"}, "ip"), ({"arp"}, "arp")):
dependency = _match(_payload("ether", "type"), ethertype)
if protocols & payloads and dependency not in matches:
matches.append(dependency)
matches = [expr for expr in matches if not (
("tcp" in protocols and expr == _match(_payload("ip", "protocol"), "tcp"))
or ("icmp" in protocols and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
expressions = sorted(matches, 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=(",", ":"))