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

305 lines
11 KiB
Python

"""Host bridge, TAP, NAT, and address allocation for local microVMs."""
from __future__ import annotations
import json
import re
from collections.abc import Iterable
from ipaddress import IPv4Address
from .config import Settings
from .domain import VmRecord
from .errors import ConflictError, TapCreationError, UvmError, ValidationError
from .firewall import FirewallManager
from .system import CommandRunner
class NetworkManager:
"""Manage the shared bridge and one TAP device per persisted VM."""
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
self._settings = settings
self._runner = runner
self.firewall = FirewallManager(settings, runner)
def allocate_ip(
self,
vms: Iterable[VmRecord],
requested: IPv4Address | None,
) -> IPv4Address:
used = {vm.guest_ip for vm in vms}
if requested is not None:
if requested not in self._settings.network or requested in {
self._settings.network.network_address,
self._settings.network.broadcast_address,
self._settings.gateway,
}:
raise ValidationError(
"guest IP must be an unused address inside "
f"{self._settings.network}, excluding {self._settings.gateway}"
)
if str(requested) in used:
raise ValidationError(f"IP already allocated: {requested}")
return requested
for host in self._settings.network.hosts():
if host == self._settings.gateway:
continue
if str(host) not in used:
return host
raise UvmError(f"no free IPs in {self._settings.network}")
@staticmethod
def mac_for(index: int) -> str:
if index < 1 or index > 0xFFFFFF:
raise UvmError("no free locally administered MAC addresses remain")
return (
f"02:fc:00:{(index >> 16) & 255:02x}:"
f"{(index >> 8) & 255:02x}:{index & 255:02x}"
)
@staticmethod
def tap_name(vm_id: str) -> str:
suffix = re.sub(r"[^a-zA-Z0-9]", "", vm_id)[-11:]
if not suffix:
raise UvmError("cannot derive a TAP name from an empty VM ID")
return f"uvm-{suffix}"[:15]
def ensure_bridge(self) -> None:
bridge = self._settings.bridge
existing = self._runner.run(("ip", "link", "show", bridge), check=False, capture=True)
if existing.returncode != 0:
self._runner.run(("ip", "link", "add", bridge, "type", "bridge"))
else:
self._ensure_existing_link_is_bridge(bridge)
self._runner.run(
(
"ip",
"addr",
"replace",
f"{self._settings.gateway}/{self._settings.network.prefixlen}",
"dev",
bridge,
)
)
self._runner.run(("ip", "link", "set", bridge, "up"))
self._runner.run(("sysctl", "-w", "net.ipv4.ip_forward=1"))
self._ensure_masquerade()
def _ensure_existing_link_is_bridge(self, bridge: str) -> None:
details = self._runner.run(
("ip", "-j", "-d", "link", "show", "dev", bridge), capture=True
)
try:
links = json.loads(details.stdout)
kind = links[0]["linkinfo"]["info_kind"]
except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error:
raise UvmError(f"could not determine whether existing interface {bridge} is a bridge") from error
if kind != "bridge":
raise UvmError(f"configured bridge {bridge} exists but is not a Linux bridge")
if links[0]["linkinfo"].get("info_data", {}).get("stp_state", 0) != 0:
raise UvmError("private ports require an STP-disabled UVM bridge")
def prepare_tap(self, vm: VmRecord) -> None:
"""Create an owned, attached TAP down; policy must precede activation."""
if self._link(vm.tap) is not None:
raise ConflictError(f"TAP device already exists: {vm.tap}")
try:
self._runner.run(("ip", "tuntap", "add", "dev", vm.tap, "mode", "tap"), capture=True)
vm.tap_owned = True
self._runner.run(("ip", "link", "set", "dev", vm.tap, "alias", f"uvm:{vm.id}"), capture=True)
self._runner.run(("ip", "link", "set", vm.tap, "master", vm.bridge or self._settings.bridge), capture=True)
self._runner.run(("ip", "link", "set", "dev", vm.tap, "type", "bridge_slave",
"group_fwd_mask", "16384"), capture=True)
except BaseException:
if vm.tap_owned:
try:
self.delete_tap(vm.tap)
vm.tap_owned = False
except UvmError:
pass
raise
def activate_tap(self, vm: VmRecord) -> None:
if self._owned_link(vm) is None:
raise UvmError(f"TAP device missing: {vm.tap}")
self._runner.run(("ip", "link", "set", vm.tap, "up"), capture=True)
def deactivate_tap(self, vm: VmRecord) -> None:
if self._owned_link(vm) is not None:
self._runner.run(("ip", "link", "set", vm.tap, "down"), capture=True)
def delete_vm_tap(self, vm: VmRecord) -> None:
if self._owned_link(vm) is not None:
self.delete_tap(vm.tap)
vm.tap_owned = False
def has_tap(self, vm: VmRecord) -> bool:
return self._owned_link(vm) is not None
def _link(self, name: str) -> dict | None:
result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True)
try:
links = json.loads(result.stdout)
if not isinstance(links, list) or any(not isinstance(link, dict) for link in links):
raise ValueError("invalid interface inventory")
matches = [link for link in links if link.get("ifname") == name]
if len(matches) > 1:
raise ValueError("ambiguous interface inventory")
return matches[0] if matches else None
except (ValueError, TypeError) as error:
raise UvmError(f"cannot inspect network interfaces: {error}") from error
def _owned_link(self, vm: VmRecord) -> dict | None:
link = self._link(vm.tap)
if link is None:
return None
info = link.get("linkinfo", {})
is_tap = info.get("info_kind") == "tun" and info.get("info_data", {}).get("type") == "tap"
legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias")
if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy):
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
return link
def create_tap(self, name: str) -> None:
existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
if existing.returncode == 0:
raise UvmError(f"TAP device already exists: {name}")
created = False
try:
self._runner.run(("ip", "tuntap", "add", "dev", name, "mode", "tap"))
created = True
self._runner.run(("ip", "link", "set", name, "master", self._settings.bridge))
self._runner.run(("ip", "link", "set", name, "up"))
except BaseException as error:
if created:
try:
self.delete_tap(name)
except UvmError as cleanup_error:
raise TapCreationError(
f"could not configure TAP device {name}; cleanup also failed: {cleanup_error}",
tap_created=True,
) from error
raise
def delete_tap(self, name: str) -> None:
deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True)
if deleted.returncode == 0:
return
remaining = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
if remaining.returncode != 0:
return
detail = deleted.stderr.strip() or deleted.stdout.strip()
suffix = f": {detail}" if detail else ""
raise UvmError(f"could not delete TAP device {name}{suffix}")
def _ensure_masquerade(self) -> None:
route = self._runner.run(("ip", "route", "show", "default"), capture=True)
match = re.search(r"\bdev\s+(\S+)", route.stdout)
if match is None:
return
uplink = match.group(1)
rule = (
"iptables",
"-t",
"nat",
"-C",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
present = self._runner.run(rule, check=False)
if present.returncode != 0:
self._runner.run(
(
"iptables",
"-t",
"nat",
"-A",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
)
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
)
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
)
def _ensure_iptables_rule(
self,
check_rule: tuple[str, ...],
add_rule: tuple[str, ...],
) -> None:
present = self._runner.run(check_rule, check=False)
if present.returncode != 0:
self._runner.run(add_rule)