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
+37 -9
View File
@@ -3,8 +3,9 @@
from __future__ import annotations
from ipaddress import IPv4Address
from typing import Literal
from pydantic import BaseModel, Field, SecretStr
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from .domain import VmRecord
@@ -14,10 +15,9 @@ class HealthResponse(BaseModel):
class InstallRequest(BaseModel):
force: bool = False
model_config = ConfigDict(extra="forbid")
class Config:
extra = "forbid"
force: bool = False
class InstallResponse(BaseModel):
@@ -26,19 +26,31 @@ class InstallResponse(BaseModel):
rootfs: str
class PortRuleModel(BaseModel):
"""A private guest TCP port; HTTP and HTTPS are descriptive labels only."""
model_config = ConfigDict(extra="forbid")
port: int = Field(strict=True, ge=1, le=65535)
protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP"
class VmCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float = Field(default=1, gt=0)
ram: str | int | float = "512"
guest_ip: IPv4Address | None = None
username: str = "root"
password: SecretStr = SecretStr("root")
class Config:
extra = "forbid"
name: str | None = None
ports: list[PortRuleModel] | None = None
no_default_ports: bool = Field(default=False, strict=True)
class VmResponse(BaseModel):
id: str
name: str | None
cpu: float
ram_mib: int
guest_ip: str
@@ -47,6 +59,9 @@ class VmResponse(BaseModel):
username: str
status: str
observed_status: str
ports: list[PortRuleModel] | None
policy_status: str
operation: str | None
pid: int | None
created_at: int
updated_at: int
@@ -58,9 +73,15 @@ class DestroyResponse(BaseModel):
status: str = "terminated"
def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmResponse:
def vm_response(
vm: VmRecord,
*,
observed_status: str | None = None,
policy_status: str = "unknown",
) -> VmResponse:
return VmResponse(
id=vm.id,
name=vm.name,
cpu=vm.cpu,
ram_mib=vm.ram_mib,
guest_ip=vm.guest_ip,
@@ -68,7 +89,14 @@ def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmRespon
mac=vm.mac,
username=vm.username,
status=vm.status,
observed_status=observed_status or vm.status,
observed_status=observed_status if observed_status is not None else vm.status,
ports=(
[PortRuleModel(port=rule.port, protocol=rule.protocol) for rule in vm.ports]
if vm.ports is not None
else None
),
policy_status=policy_status,
operation=vm.operation,
pid=vm.pid,
created_at=vm.created_at,
updated_at=vm.updated_at,
+491 -40
View File
@@ -3,22 +3,54 @@
from __future__ import annotations
import argparse
import codecs
import json
import os
import sys
import time
from collections.abc import Sequence
from ipaddress import IPv4Address
from pathlib import Path
from .app import Application, build_application
from .domain import VmSpec
from .domain import PortRule, VmRecord, VmSpec
from .errors import UvmError, ValidationError
from .firecracker.config import vcpu_count
from .validation import parse_cpu, parse_password, parse_ram, parse_username
from .validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_timeout,
parse_username,
)
class _CommandParser(argparse.ArgumentParser):
def __init__(self, *args, ssh_command: bool = False, **kwargs) -> None:
super().__init__(*args, allow_abbrev=False, **kwargs)
self._ssh_command = ssh_command
def parse_known_args(self, args=None, namespace=None):
if not self._ssh_command:
return super().parse_known_args(args, namespace)
values = list(sys.argv[1:] if args is None else args)
remote_command = []
if "--" in values:
separator = values.index("--")
values, remote_command = values[:separator], values[separator + 1 :]
# REMAINDER would swallow SSH options placed after the VM identifier.
parsed, unknown = super().parse_known_args(values, namespace)
parsed.remote_command = remote_command
return parsed, unknown
def build_parser(application: Application) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog=application.settings.app_name,
description="Tiny Firecracker microVM CLI for Ubuntu.",
allow_abbrev=False,
)
parser.add_argument(
"--serve",
@@ -36,7 +68,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
default=8000,
help="server bind port used with --serve; defaults to 8000",
)
subcommands = parser.add_subparsers(dest="command")
subcommands = parser.add_subparsers(dest="command", parser_class=_CommandParser)
install = subcommands.add_parser(
"install",
@@ -53,7 +85,8 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
)
create = subcommands.add_parser("create", help="create and boot a microVM")
create = subcommands.add_parser("create", help="create and boot a microVM detached")
create.add_argument("--name", help="unique local VM name")
create.add_argument(
"--cpu",
default=str(application.settings.default_vcpu),
@@ -65,6 +98,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
help="RAM, e.g. 512, 1G, 512M",
)
create.add_argument(
"--guest-ip",
"--host-ip",
dest="host_ip",
help="guest IP to assign; otherwise uvm allocates one",
@@ -74,36 +108,133 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
default=application.settings.default_ssh_user,
help="existing guest account to configure; defaults to root",
)
create.add_argument(
credentials = create.add_mutually_exclusive_group()
credentials.add_argument(
"--password",
default="root",
help="guest login password; defaults to root",
)
credentials.add_argument(
"--password-stdin",
action="store_true",
help="read the guest password from one line of stdin",
)
port_help = (
"private guest PORT[:TYPE], e.g. 22,80:HTTP,443:HTTPS; "
"repeat or separate with commas (TYPE: HTTP, HTTPS, TCP; default: TCP); "
"no host-public ports, forwarding, or proxy"
)
create.add_argument("--ports", action="append", default=[], help=port_help)
create.add_argument(
"--no-default-ports", action="store_true", help="do not add the default 22:TCP rule"
)
create.add_argument("--json", action="store_true")
subcommands.add_parser("list", help="list VMs")
listing = subcommands.add_parser("list", help="list VMs")
listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
output = listing.add_mutually_exclusive_group()
output.add_argument("--json", action="store_true")
output.add_argument("-q", "--quiet", action="store_true", help="print only VM IDs")
ssh = subcommands.add_parser("ssh", help="SSH into a VM")
ssh.add_argument("vm", help="VM ID or guest IP")
inspect = subcommands.add_parser("inspect", help="inspect one VM")
inspect.add_argument("vm", help="VM ID, name, or guest IP")
inspect.add_argument("--json", action="store_true")
for action in ("start", "stop", "restart", "delete"):
command = subcommands.add_parser(
action,
aliases=["destroy"] if action == "delete" else [],
help=f"{action} selected VMs",
)
targets = command.add_mutually_exclusive_group(required=True)
targets.add_argument(
"vms", nargs="*", default=[], metavar="VM", help="VM IDs, names, or guest IPs"
)
targets.add_argument("--all", dest="all_vms", action="store_true", help="select all VMs")
command.add_argument("--yes", "-y", action="store_true", help="skip confirmation")
command.add_argument("--dry-run", action="store_true", help="show targets without changes")
command.add_argument("--json", action="store_true")
if action != "start":
command.add_argument(
"--timeout",
help=(
"seconds to wait after SIGTERM before SIGKILL "
"(defaults to the configured timeout, normally 5s); "
"does not coordinate guest shutdown"
),
)
ssh = subcommands.add_parser(
"ssh",
ssh_command=True,
help="SSH into a VM",
usage="%(prog)s [options] VM [-- COMMAND ...]",
epilog="Append a remote command after a literal --, e.g. ssh VM -- uname -a.",
)
ssh.add_argument("vm", help="VM ID, name, or guest IP")
ssh.add_argument("--user", help="override the username stored for the VM")
ssh.add_argument("--key")
ssh.add_argument("--port", type=_port_number, default=22, help="guest SSH port (default: 22)")
ssh.add_argument(
"--insecure-host-key",
action="store_true",
help="disable SSH host-key verification for this connection",
)
stop = subcommands.add_parser("stop", help="stop a VM")
stop.add_argument("vm")
logs = subcommands.add_parser("logs", help="read a VM's local Firecracker log")
logs.add_argument("vm", help="VM ID, name, or guest IP")
logs.add_argument("--tail", type=_line_count, default=100, metavar="N")
logs.add_argument("-f", "--follow", action="store_true")
destroy = subcommands.add_parser("destroy", help="stop and remove a VM")
destroy.add_argument("vm")
ports = subcommands.add_parser(
"ports",
help="manage enforced private-IP guest ports (not public ports)",
description=(
"Manage private-IP TCP allowlists. HTTP/HTTPS are TCP labels, not proxies "
"or TLS configuration. No host-public ports or NAT rules are created."
),
)
port_commands = ports.add_subparsers(dest="ports_command", required=True)
for action in ("list", "open", "close", "set", "sync"):
command = port_commands.add_parser(action, help=f"{action} private-IP port rules")
command.add_argument("--json", action="store_true")
if action in {"list", "sync"}:
targets = command.add_mutually_exclusive_group(required=True)
targets.add_argument(
"vms" if action == "sync" else "vm",
nargs="*" if action == "sync" else "?",
default=[] if action == "sync" else None,
metavar="VM",
help="VM ID, name, or guest IP",
)
targets.add_argument("--all", dest="all_vms", action="store_true")
else:
command.add_argument("vm", help="VM ID, name, or guest IP")
if action == "open":
command.add_argument("--ports", action="append", required=True, help=port_help)
else:
selection = command.add_mutually_exclusive_group(required=True)
selection.add_argument("--ports", action="append", default=[], help=port_help)
if action == "close":
selection.add_argument(
"--all", dest="all_ports", action="store_true", help="close all ports of this VM"
)
else:
selection.add_argument("--none", action="store_true", help="deny all inbound ports")
if action != "list":
command.add_argument("--dry-run", action="store_true", help="show changes without applying")
if action in {"close", "set", "sync"}:
command.add_argument("--yes", "-y", action="store_true", help="skip confirmation")
doctor = subcommands.add_parser("doctor", help="check local host readiness and policy health")
doctor.add_argument("--json", action="store_true")
return parser
def main(argv: Sequence[str] | None = None) -> int:
try:
application = build_application()
application = build_application(emit=lambda message: print(message, file=sys.stderr))
parser = build_parser(application)
args = parser.parse_args(argv)
if args.serve:
@@ -113,9 +244,15 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.command is None:
parser.error("a command is required unless --serve is used")
return _run_command(application, args)
except ValidationError as error:
print(f"uvm: error: {error}", file=sys.stderr)
return 2
except UvmError as error:
print(f"uvm: error: {error}", file=sys.stderr)
return error.exit_code
except OSError as error:
print(f"uvm: error: {error}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("uvm: interrupted", file=sys.stderr)
return 130
@@ -146,54 +283,125 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
if args.command == "create":
guest_ip = _parse_guest_ip(args.host_ip)
ports = parse_ports(args.ports)
if not args.no_default_ports and not any(rule.port == 22 for rule in ports):
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
password = args.password
if args.password_stdin:
password = sys.stdin.readline(131).removesuffix("\n").removesuffix("\r")
vm = application.lifecycle.create(
VmSpec(
cpu=parse_cpu(args.cpu),
ram_mib=parse_ram(args.ram),
guest_ip=guest_ip,
username=parse_username(args.username),
password=parse_password(args.password),
password=parse_password(password),
name=parse_name(args.name) if args.name is not None else None,
ports=ports,
)
)
if vm.cpu < 1.0:
print(
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU;"
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU; "
"fractional CPU enforcement is not yet applied.",
file=sys.stderr,
)
print(" fractional CPU enforcement is not yet applied by this MVP.")
print()
print(f"VM created: {vm.id}")
print(f" IP: {vm.guest_ip}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" CPU: {vm.cpu}")
print(f" TAP: {vm.tap}")
print(f" Username: {vm.username}")
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
print()
if vm.password == "root":
print("WARNING: the guest is using the default password 'root'. Change it promptly.")
if password == "root":
print(
"WARNING: the guest is using the default password 'root'. Change it promptly.",
file=sys.stderr,
)
if args.json:
_print_json(_vm_output(
vm, policy_status="unmanaged" if vm.ports is None else "enforced"
))
else:
print(f"VM created: {vm.id}")
if vm.name is not None:
print(f" Name: {vm.name}")
print(f" IP: {vm.guest_ip}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" CPU: {vm.cpu}")
print(f" TAP: {vm.tap}")
print(f" Username: {vm.username}")
print(f" Private ports: {_format_ports(vm.ports)}")
if any(rule.port == 22 for rule in vm.ports or ()):
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
return 0
if args.command == "list":
vms = application.lifecycle.list_vms()
vms = [
listed for listed in application.lifecycle.list_vms()
if not args.status or listed.observed_status in args.status
]
if args.json:
_print_json([
_vm_output(listed.vm, listed.observed_status, listed.policy_status)
for listed in vms
])
return 0
if args.quiet:
for listed in vms:
print(listed.vm.id)
return 0
if not vms:
print("No VMs.")
return 0
print(
f"{'ID':<37} {'IP':<16} {'USER':<16} {'CPU':<7} "
f"{'RAM':<8} {'STATUS':<10} PID"
f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} "
f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} PID"
)
for listed in vms:
vm = listed.vm
pid = vm.pid if vm.pid is not None else ""
print(
f"{vm.id:<37} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
f"{vm.ram_mib:<8} {listed.observed_status:<10} {pid}"
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
f"{vm.ram_mib:<8} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
)
return 0
if args.command == "inspect":
listed = application.lifecycle.inspect(args.vm)
data = _vm_output(listed.vm, listed.observed_status, listed.policy_status)
if args.json:
_print_json(data)
else:
for key, value in data.items():
if key == "ports":
value = _format_ports(listed.vm.ports)
print(f"{key}: {value if value is not None else '-'}")
return 0
if args.command in {"start", "stop", "restart", "delete", "destroy"} or (
args.command == "ports" and args.ports_command == "sync"
):
action = "ports-sync" if args.command == "ports" else args.command
if action == "destroy":
action = "delete"
timeout = None
if action in {"stop", "restart", "delete"}:
timeout = parse_timeout(
args.timeout if args.timeout is not None else str(application.settings.terminate_timeout_s)
)
targets = application.lifecycle.resolve_targets(args.vms, all_vms=args.all_vms)
vm_ids = tuple(vm.id for vm in targets)
if args.dry_run:
_print_preview(action, targets, as_json=args.json)
return 0
if not targets:
return _print_results([], as_json=args.json)
if action == "delete" or args.all_vms:
if not _confirm(f"{action} VMs: {', '.join(vm_ids)}?", yes=args.yes):
if args.json:
_print_json([])
return 0
results = application.lifecycle.run_many(action, vm_ids, timeout=timeout)
return _print_results(results, as_json=args.json)
if args.command == "ssh":
user = parse_username(args.user) if args.user is not None else None
vm = application.lifecycle.find_for_ssh(args.vm)
command = ["ssh"]
command = ["ssh", "-p", str(args.port)]
if args.key:
command.extend(("-i", args.key))
if args.insecure_host_key:
@@ -214,23 +422,266 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
"StrictHostKeyChecking=accept-new",
)
)
command.append(f"{args.user or vm.username}@{vm.guest_ip}")
command.append(f"{user or vm.username}@{vm.guest_ip}")
command.extend(args.remote_command)
os.execvp(command[0], command)
return 0
if args.command == "stop":
vm = application.lifecycle.stop(args.vm)
print(f"Stopped {vm.id}")
if args.command == "logs":
_show_logs(application.lifecycle.log_path(args.vm), args.tail, follow=args.follow)
return 0
if args.command == "destroy":
vm = application.lifecycle.destroy(args.vm)
print(f"Destroyed {vm.id}")
if args.command == "ports":
mode = args.ports_command
if mode == "list":
listed_vms = (
application.lifecycle.list_vms() if args.all_vms
else [application.lifecycle.inspect(args.vm)]
)
if args.json:
_print_json([
_vm_output(listed.vm, listed.observed_status, listed.policy_status)
for listed in listed_vms
])
elif not listed_vms:
print("No VMs.")
else:
print(f"{'ID':<37} {'NAME':<20} {'IP':<16} {'POLICY':<12} PRIVATE-IP PORTS")
for listed in listed_vms:
vm = listed.vm
print(
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} "
f"{listed.policy_status:<12} {_format_ports(vm.ports)}"
)
return 0
requested = parse_ports(args.ports)
listed = application.lifecycle.inspect(args.vm)
vm = listed.vm
vm_id = vm.id
before = vm.ports
if mode in {"open", "close"} and before is None:
raise UvmError(
f"{vm_id} has an unmanaged port policy; use ports set on a stopped VM first"
)
service_mode = mode
if mode == "close" and args.all_ports:
service_mode = "set"
requested = ()
if service_mode == "set":
after = requested
elif service_mode == "open":
merged = {rule.port: rule for rule in before}
for rule in requested:
merged.setdefault(rule.port, rule)
after = tuple(merged[port] for port in sorted(merged))
else:
removed = {rule.port for rule in requested}
after = tuple(rule for rule in before if rule.port not in removed)
if args.dry_run:
_print_preview(
f"ports-{mode}", [vm], as_json=args.json,
changes={"ports": {"before": _ports_output(before), "after": _ports_output(after)}},
)
return 0
if (
before == after == ()
and vm.operation is None
and (vm.status, listed.observed_status, listed.policy_status) in {
("running", "running", "enforced"),
("stopped", "stopped", "inactive"),
}
):
if args.json:
_print_json(_vm_output(vm, listed.observed_status, listed.policy_status))
else:
print(f"{vm_id}: no port changes.")
return 0
removes_ssh = (
any(rule.port == 22 for rule in before or ())
and not any(rule.port == 22 for rule in after)
)
if service_mode == "set" or removes_ssh:
if not _confirm(
f"{mode} private-IP ports for {vm_id}: "
f"{_format_ports(before)} -> {_format_ports(after)}?",
yes=args.yes,
):
if args.json:
_print_json([])
return 0
updated = application.lifecycle.set_ports(vm_id, requested, mode=service_mode)
if args.json:
_print_json(_vm_output(
updated, policy_status="enforced" if updated.status == "running" else "inactive"
))
else:
print(f"{updated.id} private-IP ports: {_format_ports(updated.ports)}")
return 0
if args.command == "doctor":
checks = [
{"name": check["name"], "ok": check["ok"], "message": check["message"]}
for check in application.lifecycle.diagnostics()
]
if args.json:
_print_json(checks)
else:
for check in checks:
print(f"{'OK' if check['ok'] else 'FAIL'} {check['name']}: {check['message']}")
return 0 if all(check["ok"] for check in checks) else 1
raise UvmError(f"unsupported command: {args.command}")
def _vm_output(
vm: VmRecord, observed_status: str | None = None, policy_status: str = "unknown"
) -> dict:
# This is an output whitelist, not the persisted record (which contains credentials).
return {
"id": vm.id,
"name": vm.name,
"cpu": vm.cpu,
"ram_mib": vm.ram_mib,
"guest_ip": vm.guest_ip,
"gateway": vm.gateway,
"tap": vm.tap,
"mac": vm.mac,
"username": vm.username,
"status": vm.status,
"observed_status": observed_status if observed_status is not None else vm.status,
"policy_status": policy_status,
"ports": _ports_output(vm.ports),
"pid": vm.pid,
"disk": vm.disk,
"disk_ready": vm.disk_ready,
"socket": vm.socket,
"config": vm.config,
"log": vm.log,
"operation": vm.operation,
"boot_id": vm.boot_id,
"created_at": vm.created_at,
"updated_at": vm.updated_at,
"last_error": vm.last_error,
}
def _ports_output(ports: tuple[PortRule, ...] | None) -> list[dict] | None:
return None if ports is None else [rule.to_dict() for rule in ports]
def _format_ports(ports: tuple[PortRule, ...] | None) -> str:
if ports is None:
return "unmanaged"
return ",".join(f"{rule.port}:{rule.protocol}" for rule in ports) or "(none)"
def _print_json(value: object) -> None:
print(json.dumps(value, indent=2))
def _print_preview(
action: str, targets: Sequence[VmRecord], *, as_json: bool, changes: dict | None = None
) -> None:
if as_json:
data = {"dry_run": True, "action": action, "targets": [_vm_output(vm) for vm in targets]}
if changes is not None:
data["changes"] = changes
_print_json(data)
else:
print(f"Dry run: {action}")
if not targets:
print("No VMs.")
for vm in targets:
print(f" {vm.id} {vm.name or '-'} {vm.guest_ip}")
if changes is not None:
for field, change in changes.items():
print(f" {field}: {json.dumps(change['before'])} -> {json.dumps(change['after'])}")
def _confirm(message: str, *, yes: bool) -> bool:
if yes:
return True
if not sys.stdin.isatty():
raise UvmError("confirmation requires a TTY; use --yes or preview with --dry-run")
print(f"{message} [y/N] ", end="", file=sys.stderr, flush=True)
if sys.stdin.readline().strip().lower() in {"y", "yes"}:
return True
print("Cancelled.", file=sys.stderr)
return False
def _print_results(results: Sequence, *, as_json: bool) -> int:
if as_json:
_print_json([
{"id": result.id, "action": result.action, "outcome": result.outcome, "error": result.error}
for result in results
])
elif not results:
print("No VMs.")
else:
for result in results:
detail = f": {result.error}" if result.error else ""
print(f"{result.id} {result.action}: {result.outcome}{detail}")
if any(result.outcome == "interrupted" for result in results):
return 130
return 1 if any(result.outcome == "failed" for result in results) else 0
def _show_logs(path: Path, tail: int, *, follow: bool) -> None:
with path.open("rb") as stream:
end = stream.seek(0, os.SEEK_END)
position = end
remaining = tail
# Locate the starting line backwards without loading or retaining the whole log.
while position > 0 and remaining:
size = min(position, 65536)
position -= size
stream.seek(position)
block = stream.read(size)
if position + size == end and block.endswith(b"\n"):
block = block[:-1]
newlines = block.count(b"\n")
if newlines >= remaining:
offset = len(block)
for _ in range(remaining):
offset = block.rfind(b"\n", 0, offset)
position += offset + 1
break
remaining -= newlines
stream.seek(position)
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
while stream.tell() < end:
block = stream.read(min(65536, end - stream.tell()))
if not block:
break
sys.stdout.write(decoder.decode(block))
if not follow:
sys.stdout.write(decoder.decode(b"", final=True))
return
sys.stdout.flush()
while True:
block = stream.read(65536)
if block:
sys.stdout.write(decoder.decode(block))
sys.stdout.flush()
else:
if os.fstat(stream.fileno()).st_size < stream.tell():
stream.seek(0)
decoder.reset()
time.sleep(0.2)
def _line_count(value: str) -> int:
try:
count = int(value)
except ValueError as error:
raise argparse.ArgumentTypeError("tail must be a nonnegative integer") from error
if count < 0:
raise argparse.ArgumentTypeError("tail must be a nonnegative integer")
return count
def _parse_guest_ip(value: str | None) -> IPv4Address | None:
if value is None:
return None
+73 -2
View File
@@ -8,10 +8,37 @@ from dataclasses import dataclass, field
from ipaddress import IPv4Address
from typing import Any
from .errors import StateError
from .errors import StateError, ValidationError
STATE_SCHEMA_VERSION = 1
STATE_SCHEMA_VERSION = 2
@dataclass(frozen=True, slots=True)
class PortRule:
"""One private guest TCP port; HTTP/HTTPS are descriptive service labels."""
port: int
protocol: str = "TCP"
def __post_init__(self) -> None:
if type(self.port) is not int or not 1 <= self.port <= 65535:
raise ValidationError("port must be an integer between 1 and 65535")
if not isinstance(self.protocol, str) or self.protocol.upper() not in {"TCP", "HTTP", "HTTPS"}:
raise ValidationError("port type must be TCP, HTTP, or HTTPS")
object.__setattr__(self, "protocol", self.protocol.upper())
def to_dict(self) -> dict[str, Any]:
return {"port": self.port, "protocol": self.protocol}
@classmethod
def from_dict(cls, value: object) -> "PortRule":
if not isinstance(value, dict) or set(value) - {"port", "protocol"}:
raise StateError("invalid port rule")
try:
return cls(port=value["port"], protocol=value.get("protocol", "TCP"))
except (KeyError, ValidationError) as error:
raise StateError(f"invalid port rule: {error}") from error
@dataclass(frozen=True, slots=True)
@@ -23,6 +50,8 @@ class VmSpec:
guest_ip: IPv4Address | None = None
username: str = "root"
password: str = field(default="root", repr=False)
name: str | None = None
ports: tuple[PortRule, ...] = (PortRule(22),)
@dataclass(slots=True)
@@ -48,6 +77,16 @@ class VmRecord:
created_at: int = field(default_factory=lambda: int(time.time()))
updated_at: int = field(default_factory=lambda: int(time.time()))
last_error: str | None = None
name: str | None = None
ports: tuple[PortRule, ...] | None = None
disk_ready: bool = False
runtime_owned: bool = False
tap_owned: bool = False
operation: str | None = None
boot_id: str | None = None
spawn_pending: bool = False
network: str | None = None
bridge: str | None = None
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {
@@ -67,6 +106,16 @@ class VmRecord:
"status": self.status,
"created_at": self.created_at,
"updated_at": self.updated_at,
"name": self.name,
"ports": [port.to_dict() for port in self.ports] if self.ports is not None else None,
"disk_ready": self.disk_ready,
"runtime_owned": self.runtime_owned,
"tap_owned": self.tap_owned,
"operation": self.operation,
"boot_id": self.boot_id,
"spawn_pending": self.spawn_pending,
"network": self.network,
"bridge": self.bridge,
}
if self.pid is not None:
data["pid"] = self.pid
@@ -99,6 +148,16 @@ class VmRecord:
try:
pid_value = value.get("pid")
raw_ports = value.get("ports")
if raw_ports is not None and not isinstance(raw_ports, list):
raise StateError("VM ports must be a list or null for a legacy VM")
ports = tuple(PortRule.from_dict(port) for port in raw_ports) if raw_ports is not None else None
if ports is not None and len({port.port for port in ports}) != len(ports):
raise StateError("VM has duplicate TCP port rules")
legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"}
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
if name in value and type(value[name]) is not bool:
raise StateError(f"VM {name} must be a boolean")
return cls(
id=str(value["id"]),
cpu=float(value["cpu"]),
@@ -131,6 +190,16 @@ class VmRecord:
if value.get("last_error") is not None
else None
),
name=str(value["name"]) if value.get("name") is not None else None,
ports=ports,
disk_ready=value.get("disk_ready", legacy_ready),
runtime_owned=value.get("runtime_owned", bool(value.get("disk"))),
tap_owned=value.get("tap_owned", bool(value.get("pid"))),
operation=str(value["operation"]) if value.get("operation") is not None else None,
boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None,
spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"),
network=str(value["network"]) if value.get("network") is not None else None,
bridge=str(value["bridge"]) if value.get("bridge") is not None else None,
)
except (TypeError, ValueError) as error:
raise StateError(f"invalid VM record: {error}") from error
@@ -170,6 +239,8 @@ class State:
raise StateError("state file has an invalid vms collection")
vms = {str(vm_id): VmRecord.from_dict(vm) for vm_id, vm in raw_vms.items()}
if any(vm_id != vm.id for vm_id, vm in vms.items()):
raise StateError("VM record ID does not match its registry key")
next_mac_index = value.get("next_mac_index")
if next_mac_index is None:
next_mac_index = _next_mac_index(vms.values())
+8
View File
@@ -23,6 +23,14 @@ class StateError(UvmError):
"""Raised when persisted VM state cannot be safely used."""
class NotFoundError(UvmError):
"""A requested registry entry does not exist."""
class ConflictError(UvmError):
"""An allocation or pending operation prevents the requested action."""
class CommandError(UvmError):
"""Raised when a required host command cannot be executed successfully."""
+368 -96
View File
@@ -2,8 +2,13 @@
from __future__ import annotations
import ctypes
import errno
import math
import os
import select
import signal
import stat
import subprocess
import time
from collections.abc import Callable
@@ -26,6 +31,50 @@ class _PopenLike(Protocol):
class ProcessInfo:
pid: int
start_time: str
boot_id: str | None = None
def _libc_pidfd(name: str, *arguments: int | None) -> int:
# Some Linux Python builds omit pidfd bindings even when libc supports them.
try:
function = getattr(ctypes.CDLL(None, use_errno=True), name)
except (AttributeError, OSError) as error:
raise OSError(errno.ENOSYS, f"{name} is unavailable") from error
function.argtypes = (
[ctypes.c_int, ctypes.c_uint]
if name == "pidfd_open"
else [ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint]
)
function.restype = ctypes.c_int
result = function(*arguments)
if result == -1:
error_number = ctypes.get_errno()
raise OSError(error_number, os.strerror(error_number))
return result
def _open_pidfd(pid: int) -> int:
native = getattr(os, "pidfd_open", None)
if native is not None:
return native(pid, 0)
return _libc_pidfd("pidfd_open", pid, 0)
def _signal_pidfd(pidfd: int, signal_number: int) -> None:
native = getattr(signal, "pidfd_send_signal", None)
if native is not None:
native(pidfd, signal_number)
else:
_libc_pidfd("pidfd_send_signal", pidfd, signal_number, None, 0)
def _pidfd_exited(pidfd: int) -> bool:
poller = select.poll()
poller.register(pidfd, select.POLLIN)
events = poller.poll(0)
if any(event & (select.POLLERR | select.POLLNVAL) for _, event in events):
raise OSError(errno.EIO, "could not observe the Firecracker pidfd")
return any(event & (select.POLLIN | select.POLLHUP) for _, event in events)
class FirecrackerProcessManager:
@@ -43,103 +92,334 @@ class FirecrackerProcessManager:
self._popen = popen
self._sleep = sleep
self._monotonic = monotonic
self._children: dict[int, _PopenLike] = {}
def start(self, vm: VmRecord) -> ProcessInfo:
socket_path = Path(vm.socket)
if socket_path.exists():
def start(
self,
vm: VmRecord,
*,
on_spawn: Callable[[ProcessInfo], None] | None = None,
) -> ProcessInfo:
"""Publish identity before readiness; never replace an ambiguous process."""
observation = self.observe(vm)
if observation != "gone":
raise FirecrackerError(
f"refusing to start Firecracker: existing process is {observation}"
)
boot_id = self._boot_id()
if boot_id is None:
raise FirecrackerError("could not establish the current host boot identity")
# Check support before creating a child that we might be unable to stop safely.
try:
pidfd = _open_pidfd(os.getpid())
try:
socket_path.unlink()
except OSError as error:
raise FirecrackerError(
f"could not remove stale Firecracker socket {socket_path}: {error}"
) from error
_signal_pidfd(pidfd, 0)
finally:
os.close(pidfd)
except (OSError, NotImplementedError) as error:
raise FirecrackerError(
f"safe pidfd support is required to start Firecracker: {error}"
) from error
socket_path = Path(vm.socket)
self._remove_socket(socket_path)
log_path = Path(vm.log)
log_path.parent.mkdir(parents=True, exist_ok=True)
process: _PopenLike | None = None
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("ab", buffering=0) as log_file:
process = self._popen(
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
stdout=log_file,
stderr=log_file,
start_new_session=True,
previous_identity = (
vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending,
)
vm.pid = None
vm.process_start_time = None
vm.boot_id = boot_id
vm.operation = "spawning"
vm.spawn_pending = True
try:
process = self._popen(
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
stdout=log_file,
stderr=log_file,
start_new_session=True,
)
except OSError as error:
# Popen cleans up an exec/fork failure, but an interrupt is uncertain.
if not isinstance(error, InterruptedError):
(
vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending,
) = previous_identity
raise
vm.pid = process.pid
self._children[process.pid] = process
vm.process_start_time = self.process_start_time(process.pid)
if vm.process_start_time is None:
raise FirecrackerError(
f"could not establish a safe process identity for Firecracker process {process.pid}"
)
info = ProcessInfo(process.pid, vm.process_start_time, boot_id)
if on_spawn is not None:
on_spawn(info)
else:
vm.spawn_pending = False
self._wait_for_socket(process, socket_path, log_path)
start_time = self.process_start_time(process.pid)
if start_time is None:
raise FirecrackerError(
f"could not establish a safe process identity for Firecracker process {process.pid}"
)
return ProcessInfo(pid=process.pid, start_time=start_time)
except OSError as error:
return info
except BaseException as error:
if process is not None:
self._cleanup_started_process(process, socket_path)
raise FirecrackerError(f"could not start Firecracker: {error}") from error
except BaseException:
if process is not None:
self._cleanup_started_process(process, socket_path)
try:
self.terminate(vm)
except BaseException as cleanup_error:
raise FirecrackerError(
f"Firecracker startup failed ({type(error).__name__}: {error}); "
f"cleanup incomplete for PID {vm.pid}: {cleanup_error}"
) from error
if isinstance(error, OSError):
raise FirecrackerError(f"could not start Firecracker: {error}") from error
raise
def is_alive(self, vm: VmRecord) -> bool:
if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None:
def adopt_legacy_identity(self, vm: VmRecord) -> bool:
"""Validate a v1 process; the caller must lock and persist the adopted boot ID."""
pid, start_time = vm.pid, vm.process_start_time
if (
vm.boot_id is not None
or type(pid) is not int
or not 0 < pid < 2**31
or not isinstance(start_time, str)
or not start_time.isascii()
or not start_time.isdigit()
):
return False
try:
os.kill(vm.pid, 0)
except ProcessLookupError:
pidfd = _open_pidfd(pid)
try:
process_stat = self._process_stat(pid)
if (
process_stat is None
or process_stat[1] != start_time
or process_stat[0] in {"Z", "X", "x"}
):
return False
binary_path = self._settings.firecracker_binary
socket_path = self._settings.vm_dir(vm.id) / "firecracker.sock"
if (
not binary_path.is_absolute()
or ".." in binary_path.parts
or not vm.id
or vm.id in {".", ".."}
or Path(vm.id).name != vm.id
or vm.socket != str(socket_path)
):
return False
# Sticky ancestors are safe only because every existing entry is root-owned.
for directory in set(binary_path.parents) | set(socket_path.parents):
metadata = directory.lstat()
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != 0
or (directory == socket_path.parent and metadata.st_mode & 0o077)
or (metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX)
):
return False
binary_stat = binary_path.lstat()
socket_stat = socket_path.lstat()
if (
not stat.S_ISREG(binary_stat.st_mode)
or binary_stat.st_uid != 0
or binary_stat.st_mode & 0o022
or not stat.S_ISSOCK(socket_stat.st_mode)
or socket_stat.st_uid != 0
):
return False
proc_path = Path(f"/proc/{pid}")
executable = proc_path / "exe"
if executable.readlink() != binary_path or not executable.samefile(binary_path):
return False
expected_cmdline = b"\0".join(
(os.fsencode(binary_path), b"--api-sock", os.fsencode(socket_path), b"")
)
if (proc_path / "cmdline").read_bytes() != expected_cmdline:
return False
boot_id = self._boot_id()
if boot_id is None or _pidfd_exited(pidfd):
return False
finally:
os.close(pidfd)
except (OSError, ValueError, NotImplementedError):
return False
except PermissionError:
return True
return self.process_start_time(vm.pid) == vm.process_start_time
if (vm.pid, vm.process_start_time, vm.boot_id) != (pid, start_time, None):
return False
vm.boot_id = boot_id
return True
def terminate(self, vm: VmRecord) -> None:
def observe(self, vm: VmRecord) -> str:
"""Distinguish proven exit from missing or unreadable process identity."""
if vm.pid is not None and vm.pid <= 0:
return "unknown"
try:
self._reap_child(vm.pid)
except OSError:
return "unknown"
boot_id = self._boot_id()
if vm.boot_id and boot_id and vm.boot_id != boot_id:
return "gone"
if vm.pid is None:
self._remove_socket(Path(vm.socket))
return
if vm.pid <= 0:
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
return "unknown"
try:
Path(vm.socket).lstat()
except FileNotFoundError:
return "gone"
except OSError:
return "unknown"
# An unowned socket may belong to a child whose identity was never saved.
return "unknown"
stat = self._process_stat(vm.pid)
if stat is None:
return "unknown" if self._pid_exists(vm.pid) else "gone"
state, start_time = stat
has_start_time = (
isinstance(vm.process_start_time, str)
and vm.process_start_time.isascii()
and vm.process_start_time.isdigit()
)
if has_start_time and vm.process_start_time != start_time:
return "gone"
if state in {"Z", "X", "x"}:
# A zombie thread-group leader can still have live worker threads.
try:
pidfd = _open_pidfd(vm.pid)
try:
return "gone" if _pidfd_exited(pidfd) else "unknown"
finally:
os.close(pidfd)
except ProcessLookupError:
return "gone"
except (OSError, NotImplementedError):
return "unknown"
if not has_start_time or not vm.boot_id or not boot_id:
return "unknown"
return "alive"
def is_alive(self, vm: VmRecord) -> bool:
"""Conservative compatibility query: unknown processes may still be alive."""
return self.observe(vm) != "gone"
def terminate(self, vm: VmRecord, *, timeout: float | None = None) -> None:
"""Wait for exit before unlinking; timeout controls the SIGTERM grace period."""
if vm.pid is not None and vm.pid <= 0:
raise FirecrackerError(f"invalid persisted Firecracker PID: {vm.pid}")
if vm.process_start_time is None:
if not self._pid_exists(vm.pid):
self._remove_socket(Path(vm.socket))
return
grace_timeout = self._settings.terminate_timeout_s if timeout is None else timeout
if not math.isfinite(grace_timeout) or grace_timeout < 0:
raise FirecrackerError("Firecracker termination timeout must be finite and non-negative")
observation = self.observe(vm)
if observation == "unknown":
raise FirecrackerError(
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous"
)
if not self.is_alive(vm):
if observation == "gone":
self._remove_socket(Path(vm.socket))
return
try:
os.kill(vm.pid, signal.SIGTERM)
pidfd = _open_pidfd(vm.pid)
except ProcessLookupError:
self._reap_child(vm.pid)
self._remove_socket(Path(vm.socket))
return
except OSError as error:
raise FirecrackerError(f"could not stop Firecracker process {vm.pid}: {error}") from error
except (OSError, NotImplementedError) as error:
raise FirecrackerError(
f"could not safely open a pidfd for Firecracker process {vm.pid}: {error}"
) from error
deadline = self._monotonic() + self._settings.terminate_timeout_s
while self.is_alive(vm) and self._monotonic() < deadline:
self._sleep(0.05)
if self.is_alive(vm):
self._terminate_process_id(vm.pid, force=True)
self._remove_socket(Path(vm.socket))
try:
if not _pidfd_exited(pidfd):
# Open the handle BEFORE rechecking identity; never signal by bare PID.
observation = self.observe(vm)
if observation == "unknown":
raise FirecrackerError(
f"refusing to stop Firecracker PID {vm.pid}: process identity became ambiguous"
)
if observation == "alive":
try:
_signal_pidfd(pidfd, signal.SIGTERM)
except ProcessLookupError:
pass
if not self._wait_for_exit(pidfd, grace_timeout):
try:
_signal_pidfd(pidfd, signal.SIGKILL)
except ProcessLookupError:
pass
# Even an immediate forced stop needs time to confirm kernel exit.
if not self._wait_for_exit(pidfd, max(1.0, grace_timeout)):
raise FirecrackerError(
f"could not confirm exit of Firecracker process {vm.pid} after SIGKILL; "
"process identity and socket retained"
)
self._reap_child(vm.pid)
self._remove_socket(Path(vm.socket))
except (OSError, NotImplementedError) as error:
raise FirecrackerError(
f"could not safely stop Firecracker process {vm.pid}: {error}"
) from error
finally:
os.close(pidfd)
def process_start_time(self, pid: int) -> str | None:
"""Read Linux proc start time so a reused PID is not mistaken for a VM."""
stat = self._process_stat(pid)
return stat[1] if stat is not None else None
def host_boot_id(self) -> str | None:
"""Return the current host boot identity, or None when it is unreadable."""
return self._boot_id()
@staticmethod
def _boot_id() -> str | None:
try:
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except OSError:
return Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip() or None
except (OSError, UnicodeError):
return None
@staticmethod
def _process_stat(pid: int) -> tuple[str, str] | None:
if pid <= 0:
return None
try:
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace")
except (OSError, UnicodeError):
return None
opening_parenthesis = stat.find("(")
closing_parenthesis = stat.rfind(")")
if closing_parenthesis < 0:
if (
opening_parenthesis < 0
or closing_parenthesis < opening_parenthesis
or stat[:opening_parenthesis].strip() != str(pid)
):
return None
fields = stat[closing_parenthesis + 2 :].split()
try:
return fields[19]
except IndexError:
fields = stat[closing_parenthesis + 1 :].split()
if (
len(fields) < 20
or fields[0] not in {"R", "S", "D", "Z", "T", "t", "X", "x", "K", "W", "P", "I"}
or not fields[19].isascii()
or not fields[19].isdigit()
):
return None
return fields[0], fields[19]
def _wait_for_socket(
self,
@@ -148,13 +428,32 @@ class FirecrackerProcessManager:
log_path: Path,
) -> None:
deadline = self._monotonic() + self._settings.api_socket_timeout_s
while self._monotonic() < deadline:
if socket_path.exists():
return
while True:
if process.poll() is not None:
raise FirecrackerError(f"Firecracker exited early; see {log_path}")
self._sleep(0.05)
raise FirecrackerError("timed out waiting for Firecracker API socket")
if socket_path.is_socket():
return
remaining = deadline - self._monotonic()
if remaining <= 0:
raise FirecrackerError("timed out waiting for Firecracker API socket")
self._sleep(min(0.05, remaining))
def _wait_for_exit(self, pidfd: int, timeout: float) -> bool:
deadline = self._monotonic() + timeout
while True:
if _pidfd_exited(pidfd):
return True
remaining = deadline - self._monotonic()
if remaining <= 0:
return False
self._sleep(min(0.05, remaining))
def _reap_child(self, pid: int | None) -> None:
if pid is None:
return
process = self._children.get(pid)
if process is not None and process.poll() is not None:
self._children.pop(pid, None)
@staticmethod
def _remove_socket(socket_path: Path) -> None:
@@ -162,9 +461,10 @@ class FirecrackerProcessManager:
socket_path.unlink()
except FileNotFoundError:
pass
except OSError:
# A stale socket is cleaned on the next launch; never hide a successful stop.
pass
except OSError as error:
raise FirecrackerError(
f"could not remove Firecracker socket {socket_path}: {error}"
) from error
@staticmethod
def _pid_exists(pid: int) -> bool:
@@ -172,34 +472,6 @@ class FirecrackerProcessManager:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
except (OSError, OverflowError):
return True
return True
def _cleanup_started_process(self, process: _PopenLike, socket_path: Path) -> None:
try:
self._terminate_process_id(process.pid)
except FirecrackerError:
pass
deadline = self._monotonic() + self._settings.terminate_timeout_s
while process.poll() is None and self._monotonic() < deadline:
self._sleep(0.05)
if process.poll() is None:
try:
self._terminate_process_id(process.pid, force=True)
except FirecrackerError:
pass
self._remove_socket(socket_path)
@staticmethod
def _terminate_process_id(pid: int, *, force: bool = False) -> None:
if pid <= 0:
raise FirecrackerError(f"invalid Firecracker PID: {pid}")
signal_to_send = signal.SIGKILL if force else signal.SIGTERM
try:
os.kill(pid, signal_to_send)
except ProcessLookupError:
pass
except OSError as error:
action = "force-stop" if force else "stop"
raise FirecrackerError(f"could not {action} Firecracker process {pid}: {error}") from error
+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=(",", ":"))
+14 -10
View File
@@ -58,23 +58,14 @@ class ImageStore:
self._runner = runner or CommandRunner()
def installed_assets(self) -> GuestAssets:
kernel = self._settings.kernel_image
kernel = self.installed_kernel()
rootfs = self._settings.rootfs_image
if not kernel.exists() or not rootfs.exists():
raise UvmError("guest assets missing. Run: sudo uvm install")
manifest = load_manifest(self._settings.integrity_manifest_path)
kernel_checksum = self._settings.kernel_sha256
rootfs_checksum = self._settings.rootfs_sha256
if manifest.verified or self._settings.allow_unverified_downloads:
kernel_checksum = kernel_checksum or manifest.checksums.get("kernel")
rootfs_checksum = rootfs_checksum or manifest.checksums.get("rootfs")
verify_file(
kernel,
kernel_checksum,
"guest kernel",
"UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
verify_file(
rootfs,
rootfs_checksum,
@@ -84,6 +75,19 @@ class ImageStore:
)
return GuestAssets(kernel=kernel, rootfs=rootfs)
def installed_kernel(self) -> Path:
"""A retained private disk does not depend on the original rootfs template."""
kernel = self._settings.kernel_image
if not kernel.is_file():
raise UvmError("guest kernel missing. Run: sudo uvm install")
manifest = load_manifest(self._settings.integrity_manifest_path)
checksum = self._settings.kernel_sha256
if manifest.verified or self._settings.allow_unverified_downloads:
checksum = checksum or manifest.checksums.get("kernel")
verify_file(kernel, checksum, "guest kernel", "UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads)
return kernel
def create_vm_disk(self, source: Path, destination: Path) -> Path:
"""Copy the template before Firecracker opens it read-write for a VM."""
+2
View File
@@ -110,6 +110,8 @@ class Installer:
"jq",
"iproute2",
"iptables",
"nftables",
"conntrack",
"e2fsprogs",
"util-linux",
"ca-certificates",
+426 -203
View File
@@ -1,43 +1,49 @@
"""Ordered VM lifecycle operations and compensation for partial failures."""
"""Serialized lifecycle and private-port policy changes with durable intent."""
from __future__ import annotations
import re
import shutil
import time
from collections.abc import Callable
from dataclasses import dataclass
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from .config import Settings
from .domain import VmRecord, VmSpec, new_vm_id
from .errors import TapCreationError, UvmError
from .domain import PortRule, VmRecord, VmSpec, new_vm_id
from .errors import ConflictError, NotFoundError, UvmError, ValidationError
from .firecracker.api import FirecrackerClient
from .firecracker.config import build_config, write_config
from .firecracker.process import FirecrackerProcessManager
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
from .images import ImageStore
from .integrity import load_manifest, verify_file
from .network import NetworkManager
from .state import StateStore
from .system import check_kvm, ensure_data_directories, require_root
from .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username
@dataclass(frozen=True, slots=True)
class ListedVm:
vm: VmRecord
observed_status: str
policy_status: str = "unknown"
@dataclass(frozen=True, slots=True)
class OperationResult:
id: str
action: str
outcome: str
error: str | None = None
class LifecycleService:
"""The only module allowed to coordinate multiple VM host resources."""
"""All mutating entry points use operation lock -> short state transaction."""
def __init__(
self,
settings: Settings,
state_store: StateStore,
images: ImageStore,
network: NetworkManager,
process: FirecrackerProcessManager,
*,
self, settings: Settings, state_store: StateStore, images: ImageStore,
network: NetworkManager, process: FirecrackerProcessManager, *,
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
clock: Callable[[], float] = time.time,
) -> None:
@@ -50,235 +56,452 @@ class LifecycleService:
self._clock = clock
def create(self, spec: VmSpec) -> VmRecord:
"""Reserve identity first, then create all VM resources in dependency order."""
require_root()
parse_cpu(str(spec.cpu))
if type(spec.ram_mib) is not int or spec.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
parse_username(spec.username)
parse_password(spec.password)
if spec.name is not None:
parse_name(spec.name)
ports = self._validated_ports(spec.ports)
ensure_data_directories(self._settings)
check_kvm()
if not self._settings.firecracker_binary.exists():
raise UvmError("Firecracker is not installed. Run: sudo uvm install")
with self._state_store.operation_lock():
manifest = load_manifest(self._settings.integrity_manifest_path)
firecracker_checksum = self._settings.firecracker_binary_sha256
if manifest.verified or self._settings.allow_unverified_downloads:
firecracker_checksum = firecracker_checksum or manifest.checksums.get("firecracker")
verify_file(
self._settings.firecracker_binary,
firecracker_checksum,
"Firecracker binary",
"UVM_FIRECRACKER_BINARY_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
return self._create_locked(spec)
def _create_locked(self, spec: VmSpec) -> VmRecord:
assets = self._images.installed_assets()
vm = self._reserve_vm(spec)
tap_created = False
try:
runtime_dir = self._settings.vm_dir(vm.id)
runtime_dir.mkdir(parents=True, exist_ok=False)
runtime_dir.chmod(0o700)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
self._images.provision_credentials(disk, vm.username, vm.password)
self._network.ensure_bridge()
self._check_boot()
assets = self._images.installed_assets()
vm = self._reserve_vm(replace(spec, ports=ports))
try:
self._network.create_tap(vm.tap)
except TapCreationError as error:
tap_created = error.tap_created
raise
else:
tap_created = True
runtime = self._settings.vm_dir(vm.id)
runtime.mkdir(mode=0o700, exist_ok=False)
vm.runtime_owned = True
self._save(vm)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
self._images.provision_credentials(disk, vm.username, vm.password)
vm.disk_ready = True
self._save(vm)
self._boot(vm, assets.kernel)
return vm
except BaseException as error:
cleanup_error = None
try:
if vm.operation != "preparing":
self._cleanup_runtime(vm)
self._remove_runtime(vm)
self._remove_record(vm.id)
except BaseException as cleanup:
cleanup_error = cleanup
message = f"failed to create {vm.id}: {error}"
if cleanup_error is not None:
message += f"; cleanup incomplete: {cleanup_error}"
self._record_failure(vm, message)
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
raise UvmError(message) from error
config = build_config(self._settings, vm, assets.kernel, disk)
write_config(Path(vm.config), config)
def _check_boot(self) -> None:
check_kvm()
manifest = load_manifest(self._settings.integrity_manifest_path)
checksum = self._settings.firecracker_binary_sha256
if manifest.verified or self._settings.allow_unverified_downloads:
checksum = checksum or manifest.checksums.get("firecracker")
verify_file(self._settings.firecracker_binary, checksum, "Firecracker binary",
"UVM_FIRECRACKER_BINARY_SHA256",
allow_unverified=self._settings.allow_unverified_downloads)
self._network.firewall.check_available()
process_info = self._process.start(vm)
vm.pid = process_info.pid
vm.process_start_time = process_info.start_time
vm.updated_at = self._now()
self._replace_vm(vm)
def _boot(self, vm: VmRecord, kernel: Path) -> None:
vm.status, vm.operation = "starting", "network"
vm.pid = vm.process_start_time = None
vm.spawn_pending = False
self._save(vm)
self._network.ensure_bridge()
self._network.prepare_tap(vm)
self._save(vm)
self._network.firewall.apply(vm)
self._network.activate_tap(vm)
config = build_config(self._settings, vm, kernel, Path(vm.disk))
write_config(Path(vm.config), config)
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
client.configure_and_start(config)
# Persist the crash window before spawning, but give start() the already
# verified process-free record. A restored pending spawn is never guessed gone.
vm.operation = "boot"
vm.boot_id = self._process.host_boot_id()
self._save(replace(vm, operation="spawning", spawn_pending=True))
def spawned(info: ProcessInfo) -> None:
vm.pid, vm.process_start_time, vm.boot_id = info.pid, info.start_time, info.boot_id
vm.spawn_pending = False
vm.operation = "configuring"
self._save(vm)
self._process.start(vm, on_spawn=spawned)
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
client.configure_and_start(config)
if self._network.firewall.status(vm) != "enforced":
raise UvmError("private-port enforcement could not be confirmed after boot")
vm.status, vm.operation, vm.last_error = "running", None, None
self._save(vm)
def start(self, identifier: str) -> VmRecord:
require_root()
with self._state_store.operation_lock():
return self._start_locked(self._load_for_operation(identifier))
def _start_locked(self, vm: VmRecord) -> VmRecord:
self._not_deleting(vm)
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
observation = self._process.observe(vm)
if observation == "unknown":
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
if observation == "alive":
if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
return self._sync_ports_locked(vm)
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
self._check_boot()
kernel = self._images.installed_kernel()
try:
self._cleanup_runtime(vm)
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel)
return vm
except BaseException as error:
cleanup_errors = self._rollback_create(vm, tap_created)
message = f"failed to create {vm.id}: {error}"
if cleanup_errors:
message = f"{message}; cleanup failed: {'; '.join(cleanup_errors)}"
vm.status = "failed"
vm.last_error = message
vm.updated_at = self._now()
self._replace_vm_if_present(vm)
else:
self._remove_vm_if_present(vm.id)
message = f"failed to start {vm.id}: {error}"
try:
self._cleanup_runtime(vm)
except BaseException as cleanup:
message += f"; cleanup incomplete: {cleanup}"
self._record_failure(vm, message)
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
if isinstance(error, UvmError):
raise error
raise UvmError(message) from error
vm.status = "running"
vm.last_error = None
vm.updated_at = self._now()
self._replace_vm(vm)
return vm
def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root()
with self._state_store.operation_lock():
return self._stop_locked(self._load_for_operation(identifier), timeout=timeout)
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
deleting = vm.operation == "delete" or vm.status == "terminating"
vm.status, vm.operation = "stopping", "delete" if deleting else "stop"
self._save(vm)
try:
self._cleanup_runtime(vm, timeout=timeout)
vm.status = "stopped"
vm.operation = "delete" if deleting else None
vm.last_error = None
self._save(vm)
return vm
except BaseException as error:
self._record_failure(vm, f"failed to stop {vm.id}: {error}")
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
raise UvmError(vm.last_error) from error
def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root()
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root()
with self._state_store.operation_lock():
return self._delete_locked(self._load_for_operation(identifier), timeout=timeout)
def _delete_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
vm.status, vm.operation = "terminating", "delete"
self._save(vm)
try:
self._cleanup_runtime(vm, timeout=timeout)
self._remove_runtime(vm)
self._remove_record(vm.id)
return vm
except BaseException as error:
self._record_failure(vm, f"failed to delete {vm.id}: {error}")
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
raise UvmError(vm.last_error) from error
def _cleanup_runtime(self, vm: VmRecord, *, timeout: float | None = None) -> None:
containment_errors = []
try:
if self._network.has_tap(vm):
try:
self._network.firewall.apply(vm, quarantine=True)
finally:
self._network.deactivate_tap(vm)
except UvmError as error:
containment_errors.append(str(error))
try:
self._process.terminate(vm, timeout=timeout)
except UvmError as error:
detail = f"; containment: {'; '.join(containment_errors)}" if containment_errors else ""
raise UvmError(f"process exit unconfirmed: {error}{detail}") from error
vm.pid = vm.process_start_time = None
vm.spawn_pending = False
if vm.operation == "spawning":
vm.operation = "boot"
self._network.delete_vm_tap(vm)
self._network.firewall.cleanup_connections(vm)
self._network.firewall.remove(vm)
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
require_root()
ports = self._validated_ports(ports)
if mode not in {"open", "close", "set"}:
raise ValidationError(f"invalid port operation: {mode}")
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
observation = self._process.observe(vm)
if observation == "unknown":
raise ConflictError("port changes require a known process identity")
if vm.ports is None and (mode != "set" or observation != "gone" or vm.status != "stopped"):
raise ConflictError("legacy VM policy must be explicitly set while stopped")
if vm.operation not in {None, "policy"}:
raise ConflictError(f"finish the pending {vm.operation} operation before editing ports")
current = {port.port: port for port in vm.ports or ()}
if mode == "set":
current = {port.port: port for port in ports}
elif mode == "open":
for port in ports:
current.setdefault(port.port, port)
else:
for port in ports:
current.pop(port.port, None)
vm.ports = tuple(current[number] for number in sorted(current))
vm.operation = "policy"
self._save(vm)
return self._sync_ports_locked(vm)
def sync_ports(self, identifier: str) -> VmRecord:
require_root()
with self._state_store.operation_lock():
return self._sync_ports_locked(self._load_for_operation(identifier))
def _sync_ports_locked(self, vm: VmRecord) -> VmRecord:
self._not_deleting(vm)
if vm.ports is None:
raise ConflictError("legacy VM has no managed policy; stop it and use ports set")
if vm.operation not in {None, "policy"}:
raise ConflictError(f"finish the pending {vm.operation} operation before syncing ports")
observation = self._process.observe(vm)
if observation == "unknown":
raise ConflictError("cannot enforce policy for a VM with unknown process identity")
vm.operation = "policy"
self._save(vm)
try:
if observation == "alive":
if not self._network.has_tap(vm):
raise UvmError("running VM has no owned network attachment")
self._network.firewall.apply(vm)
self._network.activate_tap(vm)
vm.status = "running"
else:
if vm.status != "stopped" or vm.tap_owned:
self._cleanup_runtime(vm)
vm.status = "stopped"
vm.operation, vm.last_error = None, None
self._save(vm)
return vm
except BaseException as error:
# Never restore an older, more permissive policy after a failed close.
containment_errors = []
for contain in (
lambda: self._network.deactivate_tap(vm),
lambda: self._network.firewall.apply(vm, quarantine=True),
):
try:
contain()
except UvmError as containment_error:
containment_errors.append(str(containment_error))
detail = f"; containment errors: {'; '.join(containment_errors)}" if containment_errors else ""
self._record_failure(vm, f"policy pending for {vm.id}: {error}{detail}")
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
raise UvmError(vm.last_error) from error
def resolve_targets(self, identifiers: Sequence[str], *, all_vms: bool = False) -> list[VmRecord]:
if all_vms and identifiers:
raise ValidationError("--all cannot be combined with explicit VM targets")
state = self._state_store.load()
if all_vms:
return list(state.vms.values())
if not identifiers:
raise ValidationError("specify a VM or --all")
resolved = [self._state_store.resolve(state, identifier) for identifier in identifiers]
return list({vm.id: vm for vm in resolved}.values())
def run_many(self, action: str, vm_ids: Sequence[str], *, timeout: float | None = None) -> list[OperationResult]:
require_root()
if action not in {"start", "stop", "restart", "delete", "ports-sync"}:
raise ValidationError(f"invalid lifecycle action: {action}")
results = []
with self._state_store.operation_lock():
for index, vm_id in enumerate(vm_ids):
try:
vm = self._load_for_operation(vm_id, exact_id=True)
if action == "start":
self._start_locked(vm)
elif action == "stop":
self._stop_locked(vm, timeout=timeout)
elif action == "delete":
self._delete_locked(vm, timeout=timeout)
elif action == "ports-sync":
self._sync_ports_locked(vm)
else:
self._not_deleting(vm)
self._stop_locked(vm, timeout=timeout)
self._start_locked(vm)
results.append(OperationResult(vm_id, action, "succeeded"))
except NotFoundError:
results.append(OperationResult(vm_id, action, "skipped", "already absent"))
except KeyboardInterrupt:
results.append(OperationResult(vm_id, action, "interrupted", "interrupted"))
results.extend(OperationResult(remaining, action, "skipped", "not attempted after interruption")
for remaining in vm_ids[index + 1:])
break
except (UvmError, OSError) as error:
results.append(OperationResult(vm_id, action, "failed", str(error)))
return results
def list_vms(self) -> list[ListedVm]:
state = self._state_store.load()
listed: list[ListedVm] = []
for vm in state.vms.values():
observed_status = vm.status
if vm.status in {"starting", "running", "stopping", "terminating"}:
if not self._process.is_alive(vm):
observed_status = "dead"
listed.append(ListedVm(vm=vm, observed_status=observed_status))
return listed
return [self._listed(vm) for vm in self._state_store.load().vms.values()]
def inspect(self, identifier: str) -> ListedVm:
return self._listed(self._state_store.find_by_id_or_ip(identifier))
def _listed(self, vm: VmRecord) -> ListedVm:
observation = self._process.observe(vm)
status = vm.status
if observation == "unknown":
status = "unknown"
elif observation == "alive" and status == "stopped":
status = "running"
elif observation == "gone" and status == "running":
status = "dead"
policy = ("inactive" if observation == "gone" and vm.status == "stopped" and vm.operation is None
else self._network.firewall.status(vm))
if vm.ports is None and policy == "inactive":
policy = "unmanaged"
return ListedVm(vm, status, policy)
def find_for_ssh(self, identifier: str) -> VmRecord:
return self._state_store.find_by_id_or_ip(identifier)
def stop(self, vm_id: str) -> VmRecord:
"""Stop one VM while retaining its private writable disk for future use."""
def log_path(self, identifier: str) -> Path:
vm = self._state_store.find_by_id_or_ip(identifier)
self._validate_paths(vm)
return Path(vm.log)
require_root()
with self._state_store.operation_lock():
return self._stop_locked(vm_id)
def _stop_locked(self, vm_id: str) -> VmRecord:
vm = self._begin_transition(vm_id, "stopping")
try:
self._process.terminate(vm)
self._network.delete_tap(vm.tap)
except BaseException as error:
vm.status = "failed"
vm.last_error = f"failed to stop VM: {error}"
vm.updated_at = self._now()
self._replace_vm_if_present(vm)
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
if isinstance(error, UvmError):
raise error
raise UvmError(vm.last_error) from error
vm.status = "stopped"
vm.pid = None
vm.process_start_time = None
vm.last_error = None
vm.updated_at = self._now()
self._replace_vm(vm)
return vm
def destroy(self, vm_id: str) -> VmRecord:
"""Stop a VM, remove its private resources, then release its allocation."""
require_root()
with self._state_store.operation_lock():
return self._destroy_locked(vm_id)
def _destroy_locked(self, vm_id: str) -> VmRecord:
vm = self._begin_transition(vm_id, "terminating")
try:
self._process.terminate(vm)
self._network.delete_tap(vm.tap)
def diagnostics(self) -> list[dict]:
checks = [("root", require_root), ("kvm", check_kvm),
("guest assets", self._images.installed_assets),
("private firewall", self._network.firewall.check_available)]
results = []
for name, check in checks:
try:
shutil.rmtree(self._settings.vm_dir(vm.id))
except FileNotFoundError:
pass
except BaseException as error:
vm.status = "failed"
vm.last_error = f"failed to destroy VM: {error}"
vm.updated_at = self._now()
self._replace_vm_if_present(vm)
if isinstance(error, (KeyboardInterrupt, SystemExit)):
raise
if isinstance(error, UvmError):
raise error
raise UvmError(vm.last_error) from error
check()
results.append({"name": name, "ok": True, "message": "available"})
except (UvmError, OSError) as error:
results.append({"name": name, "ok": False, "message": str(error)})
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"):
path = shutil.which(command)
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
return results
self._remove_vm_if_present(vm.id)
def _load_for_operation(self, identifier: str, *, exact_id: bool = False) -> VmRecord:
vm = (self._state_store.get(identifier) if exact_id
else self._state_store.find_by_id_or_ip(identifier))
self._validate_paths(vm)
if self._process.adopt_legacy_identity(vm):
self._save(vm)
return vm
def _validate_paths(self, vm: VmRecord) -> None:
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None:
raise ConflictError("invalid persisted VM ID; refusing resource access")
directory = self._settings.vm_dir(vm.id).absolute()
if directory.is_symlink():
raise ConflictError("VM directory must not be a symbolic link")
for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"),
("log", "firecracker.log"), ("disk", "rootfs.ext4")):
path = getattr(vm, field)
if field == "disk" and not path:
continue # Legacy records may have used a shared template, never delete it.
if Path(path).absolute() != directory / filename or Path(path).is_symlink():
raise ConflictError(f"unsafe persisted VM {field} path")
@staticmethod
def _not_deleting(vm: VmRecord) -> None:
if vm.operation == "delete" or vm.status == "terminating":
raise ConflictError(f"deletion is pending for {vm.id}; retry delete instead")
@staticmethod
def _validated_ports(ports: tuple[PortRule, ...]) -> tuple[PortRule, ...]:
if not isinstance(ports, tuple) or any(not isinstance(port, PortRule) for port in ports):
raise ValidationError("ports must be a tuple of private TCP port rules")
return parse_ports([f"{port.port}:{port.protocol}" for port in ports])
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
with self._state_store.transaction() as state:
if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()):
raise ConflictError(f"VM name already exists: {spec.name}")
vm_id = new_vm_id()
used_taps = {existing_vm.tap for existing_vm in state.vms.values()}
while vm_id in state.vms or self._network.tap_name(vm_id) in used_taps:
used_taps = {vm.tap for vm in state.vms.values()}
while vm_id in state.vms or any(vm.name == vm_id for vm in state.vms.values()) or self._network.tap_name(vm_id) in used_taps:
vm_id = new_vm_id()
guest_ip = self._network.allocate_ip(state.vms.values(), spec.guest_ip)
mac = self._network.mac_for(state.next_mac_index)
state.next_mac_index += 1
now = self._now()
runtime_dir = self._settings.vm_dir(vm_id)
directory = self._settings.vm_dir(vm_id)
vm = VmRecord(
id=vm_id,
cpu=spec.cpu,
ram_mib=spec.ram_mib,
guest_ip=str(guest_ip),
gateway=str(self._settings.gateway),
tap=self._network.tap_name(vm_id),
mac=mac,
socket=str(runtime_dir / "firecracker.sock"),
config=str(runtime_dir / "config.json"),
log=str(runtime_dir / "firecracker.log"),
disk=str(runtime_dir / "rootfs.ext4"),
username=spec.username,
password=spec.password,
status="starting",
created_at=now,
updated_at=now,
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib,
guest_ip=str(guest_ip), gateway=str(self._settings.gateway),
network=str(self._settings.network), bridge=self._settings.bridge,
tap=self._network.tap_name(vm_id), mac=mac,
socket=str(directory / "firecracker.sock"), config=str(directory / "config.json"),
log=str(directory / "firecracker.log"), disk=str(directory / "rootfs.ext4"),
username=spec.username, password=spec.password, ports=spec.ports,
status="starting", operation="preparing", created_at=int(self._clock()), updated_at=int(self._clock()),
)
state.vms[vm.id] = vm
return vm
def _begin_transition(self, vm_id: str, target_status: str) -> VmRecord:
with self._state_store.transaction() as state:
try:
vm = state.vms[vm_id]
except KeyError as error:
raise UvmError(f"VM not found: {vm_id}") from error
if vm.status in {"stopping", "terminating"}:
raise UvmError(f"VM operation is already in progress: {vm_id}")
vm.status = target_status
vm.updated_at = self._now()
state.vms[vm_id] = vm
return vm
def _remove_runtime(self, vm: VmRecord) -> None:
self._validate_paths(vm)
directory = self._settings.vm_dir(vm.id)
if directory.exists():
if not vm.runtime_owned:
raise ConflictError(f"refusing to remove unowned VM directory {directory}")
if self._process.observe(vm) != "gone":
raise ConflictError("refusing to remove disk without confirmed process exit")
shutil.rmtree(directory)
vm.runtime_owned = False
def _replace_vm(self, vm: VmRecord) -> None:
def _save(self, vm: VmRecord) -> None:
vm.updated_at = int(self._clock())
with self._state_store.transaction() as state:
if vm.id not in state.vms:
raise UvmError(f"VM not found: {vm.id}")
raise NotFoundError(f"VM not found: {vm.id}")
state.vms[vm.id] = vm
def _replace_vm_if_present(self, vm: VmRecord) -> None:
with self._state_store.transaction() as state:
if vm.id in state.vms:
state.vms[vm.id] = vm
def _record_failure(self, vm: VmRecord, message: str) -> None:
vm.status, vm.last_error = "failed", message
self._save(vm)
def _remove_vm_if_present(self, vm_id: str) -> None:
def _remove_record(self, vm_id: str) -> None:
with self._state_store.transaction() as state:
state.vms.pop(vm_id, None)
def _rollback_create(self, vm: VmRecord, tap_created: bool) -> list[str]:
errors: list[str] = []
if vm.pid is not None:
try:
self._process.terminate(vm)
except Exception as error:
errors.append(f"process: {error}")
if tap_created:
try:
self._network.delete_tap(vm.tap)
except Exception as error:
errors.append(f"network: {error}")
try:
shutil.rmtree(self._settings.vm_dir(vm.id))
except FileNotFoundError:
pass
except OSError as error:
errors.append(f"files: {error}")
return errors
def _now(self) -> int:
return int(self._clock())
+66 -1
View File
@@ -9,7 +9,8 @@ from ipaddress import IPv4Address
from .config import Settings
from .domain import VmRecord
from .errors import TapCreationError, UvmError, ValidationError
from .errors import ConflictError, TapCreationError, UvmError, ValidationError
from .firewall import FirewallManager
from .system import CommandRunner
@@ -19,6 +20,7 @@ class NetworkManager:
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
self._settings = settings
self._runner = runner
self.firewall = FirewallManager(settings, runner)
def allocate_ip(
self,
@@ -95,6 +97,69 @@ class NetworkManager:
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)
+24 -11
View File
@@ -2,12 +2,12 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, status
from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response
from ..app import Application
from ..domain import VmSpec
from ..validation import parse_cpu, parse_password, parse_ram, parse_username
from ..domain import PortRule, VmSpec
from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username
from .dependencies import get_authorized_application
@@ -19,7 +19,11 @@ def list_vms(
application: Application = Depends(get_authorized_application),
) -> list[VmResponse]:
return [
vm_response(listed.vm, observed_status=listed.observed_status)
vm_response(
listed.vm,
observed_status=listed.observed_status,
policy_status=listed.policy_status,
)
for listed in application.lifecycle.list_vms()
]
@@ -29,6 +33,9 @@ def create_vm(
request: VmCreateRequest,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports or ()])
if not request.no_default_ports and not any(rule.port == 22 for rule in ports):
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
vm = application.lifecycle.create(
VmSpec(
cpu=parse_cpu(str(request.cpu)),
@@ -36,9 +43,14 @@ def create_vm(
guest_ip=request.guest_ip,
username=parse_username(request.username),
password=parse_password(request.password.get_secret_value()),
name=parse_name(request.name) if request.name is not None else None,
ports=ports,
)
)
return vm_response(vm)
policy_status = "unmanaged" if vm.ports is None else "unknown"
if vm.ports is not None and vm.status == "running" and vm.operation is None:
policy_status = "enforced"
return vm_response(vm, policy_status=policy_status)
@router.post("/{vm_id}/stop", response_model=VmResponse)
@@ -46,7 +58,7 @@ def stop_vm(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
return vm_response(application.lifecycle.stop(vm_id))
return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive")
@router.delete("/{vm_id}", response_model=DestroyResponse)
@@ -63,8 +75,9 @@ def get_vm(
identifier: str,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
for listed in application.lifecycle.list_vms():
vm = listed.vm
if identifier in {vm.id, vm.guest_ip}:
return vm_response(vm, observed_status=listed.observed_status)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="VM not found")
listed = application.lifecycle.inspect(identifier)
return vm_response(
listed.vm,
observed_status=listed.observed_status,
policy_status=listed.policy_status,
)
+6 -7
View File
@@ -8,7 +8,7 @@ import ssl
from typing import Any
from .app import Application, build_application
from .errors import ConfigurationError, UvmError, ValidationError
from .errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
@@ -115,13 +115,12 @@ def _validate_server_settings(
def _http_status_for(error: UvmError) -> int:
if isinstance(error, NotFoundError):
return 404
if isinstance(error, ConflictError):
return 409
if isinstance(error, (ConfigurationError, ValidationError)):
return 422
message = str(error)
if message.startswith("VM not found:"):
return 404
if "operation is already in progress" in message:
return 409
if "needs root" in message:
if "needs root" in str(error):
return 403
return 500
+18 -9
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import fcntl
import json
import os
import re
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
@@ -12,7 +13,7 @@ from pathlib import Path
from .config import Settings
from .domain import State, VmRecord
from .errors import StateError, UvmError
from .errors import ConflictError, NotFoundError, StateError
from .system import ensure_data_directories
@@ -63,16 +64,24 @@ class StateStore:
try:
return state.vms[vm_id]
except KeyError as error:
raise UvmError(f"VM not found: {vm_id}") from error
raise NotFoundError(f"VM not found: {vm_id}") from error
def find_by_id_or_ip(self, identifier: str) -> VmRecord:
state = self.load()
if identifier in state.vms:
return state.vms[identifier]
for vm in state.vms.values():
if vm.guest_ip == identifier:
return vm
raise UvmError(f"VM not found: {identifier}")
return self.resolve(self.load(), identifier)
@staticmethod
def resolve(state: State, identifier: str) -> VmRecord:
if re.fullmatch(r"vm-[0-9a-f]{32}", identifier):
try:
return state.vms[identifier]
except KeyError as error:
raise NotFoundError(f"VM not found: {identifier}") from error
matches = [vm for vm in state.vms.values() if identifier in {vm.id, vm.name, vm.guest_ip}]
if len(matches) > 1:
raise ConflictError(f"ambiguous VM identifier: {identifier}; use an exact VM ID")
if not matches:
raise NotFoundError(f"VM not found: {identifier}")
return matches[0]
@contextmanager
def _locked(self) -> Iterator[None]:
+2 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import shlex
import subprocess
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -47,7 +48,7 @@ class CommandRunner:
check=False,
text=True,
input=input_text,
stdout=subprocess.PIPE if capture else None,
stdout=subprocess.PIPE if capture else sys.stderr,
stderr=subprocess.PIPE if capture else None,
timeout=timeout,
)
+34
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import math
import re
from collections.abc import Sequence
from decimal import Decimal, InvalidOperation
from .errors import ValidationError
from .domain import PortRule
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
@@ -81,3 +83,35 @@ def parse_password(value: str) -> str:
if any(ord(character) < 32 or ord(character) == 127 for character in value):
raise ValidationError("password must not contain control characters")
return value
def parse_name(value: str) -> str:
if re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None:
raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter")
if re.fullmatch(r"vm-[0-9a-f]{32}", value):
raise ValidationError("VM names must not impersonate generated VM IDs")
return value
def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]:
ports: dict[int, PortRule] = {}
for value in values:
for item in value.split(","):
match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE)
if match is None:
raise ValidationError(f"invalid private port: {item!r}; use PORT[:TCP|HTTP|HTTPS]")
rule = PortRule(int(match.group(1)), match.group(2) or "TCP")
if rule.port in ports and ports[rule.port] != rule:
raise ValidationError(f"conflicting labels for TCP port {rule.port}")
ports[rule.port] = rule
return tuple(ports[number] for number in sorted(ports))
def parse_timeout(value: str) -> float:
try:
timeout = float(value)
except (ValueError, TypeError) as error:
raise ValidationError("timeout must be a finite non-negative number of seconds") from error
if not math.isfinite(timeout) or timeout < 0:
raise ValidationError("timeout must be a finite non-negative number of seconds")
return timeout