- Introduced a standalone guest execution agent to facilitate command execution within the guest VM over Firecracker's vsock. - Enhanced FirecrackerClient to support vsock configuration, validating guest CID and UDS path. - Updated configuration to include vsock settings by default. - Refactored FirecrackerProcessManager to manage runtime sockets more effectively. - Implemented guest agent installation and provisioning in the image store. - Added API endpoints for executing commands in the VM and installing the guest agent. - Improved error handling for guest execution failures and agent availability.
904 lines
40 KiB
Python
904 lines
40 KiB
Python
"""Guest asset lookup and per-VM writable root filesystem handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from ipaddress import IPv4Address
|
|
from pathlib import Path
|
|
|
|
from . import guest_agent
|
|
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
|
from .errors import UvmError, ValidationError
|
|
from .integrity import load_manifest, verify_file
|
|
from .system import CommandRunner
|
|
from .validation import parse_username
|
|
|
|
|
|
_FIRECRACKER_DEMO_PUBLIC_KEY = (
|
|
"ssh-rsa "
|
|
"AAAAB3NzaC1yc2EAAAADAQABAAABAQCirWKrc1zDyvZufHGinIRNoeIot+C3idANxtqZyDHL9mYm"
|
|
"NeQzx9CjbjMgSDJ3xhIPP9mu3MP4Py/u3X5Wey98zN3EPboKdGRf6T2fFviK4i0q85LueDtsK0"
|
|
"IoWR459w87tC9NMwPb27C8jPqFod6nWfccdhEdM+veKkFh4Dk5TrPfYHDayXDPFEdz7jl0GedEH"
|
|
"fP9w11LPfa66D7731CdD3tMHAWLYxYmeXo58RXUaP6AgUK8uF/hL+E21q+wgTNPOQuRn2ekOjdu"
|
|
"J34oHkJ2i48tLqKdGKU6RwfFc3rW3TkeYTUqi3UY9EnwNWFJicez+nZ5bhr5KvRsfSZ2QvBj"
|
|
)
|
|
_MODE_PATTERN = re.compile(r"Mode:\s+0*([0-7]+)")
|
|
_OWNER_PATTERN = re.compile(r"User:\s+(\d+)\s+Group:\s+(\d+)")
|
|
_FLAGS_PATTERN = re.compile(r"Flags:\s+0x([0-9a-fA-F]+)")
|
|
_LINKS_PATTERN = re.compile(r"Links:\s+(\d+)")
|
|
_HOST_KEY_TYPES = (
|
|
("rsa", "3072"),
|
|
("ecdsa", "256"),
|
|
("ed25519", None),
|
|
)
|
|
|
|
# Keep the kernel's proto-boot default route until networkd takes over. On
|
|
# Ubuntu 22.04, KeepConfiguration=static preserves addresses but drops that route.
|
|
GUEST_NETWORK_TEMPLATE = (
|
|
"[Match]\nName=eth0\n\n[Network]\n"
|
|
"KeepConfiguration=yes\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
|
)
|
|
_LEGACY_GUEST_NETWORK_TEMPLATE = GUEST_NETWORK_TEMPLATE.replace(
|
|
"KeepConfiguration=yes", "KeepConfiguration=static",
|
|
)
|
|
|
|
GUEST_AGENT_SERVICE = """\
|
|
[Unit]
|
|
Description=UVM guest command execution agent
|
|
After=local-fs.target network.target
|
|
ConditionPathExists=/etc/uvm-agent.json
|
|
StartLimitIntervalSec=0
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStartPre=/bin/sh /usr/lib/uvm/prepare-agent.sh
|
|
ExecStart=/usr/bin/python3 -I /usr/lib/uvm/agent.py
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
TimeoutStartSec=600
|
|
TimeoutStopSec=5
|
|
KillMode=control-group
|
|
Delegate=yes
|
|
UMask=0077
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
"""
|
|
|
|
# Runs only INSIDE the VM. Old Jammy guests can acquire the runtime without SSH
|
|
# or executing a retained guest's binaries in a privileged host chroot.
|
|
GUEST_AGENT_PREPARE = """\
|
|
set -eu
|
|
check_python() {
|
|
/usr/bin/python3 -I -c 'import sys, json, math, pwd, selectors, socket, struct, subprocess, threading, uuid; assert sys.version_info >= (3, 10)' 2>/dev/null
|
|
}
|
|
if check_python; then
|
|
exit 0
|
|
fi
|
|
export DEBIAN_FRONTEND=noninteractive LC_ALL=C
|
|
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -o APT::Update::Error-Mode=any update
|
|
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -y --no-install-recommends install python3
|
|
check_python
|
|
"""
|
|
|
|
|
|
def guest_agent_files() -> dict[str, bytes]:
|
|
"""The same standalone payload is used by the builder and offline upgrades."""
|
|
return {
|
|
"/usr/lib/uvm/agent.py": Path(guest_agent.__file__).read_bytes(),
|
|
"/usr/lib/uvm/prepare-agent.sh": GUEST_AGENT_PREPARE.encode("utf-8"),
|
|
"/etc/systemd/system/uvm-agent.service": GUEST_AGENT_SERVICE.encode("utf-8"),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class GuestAssets:
|
|
kernel: Path
|
|
rootfs: Path
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _GuestFile:
|
|
path: Path
|
|
mode: int
|
|
uid: int
|
|
gid: int
|
|
flags: int | None = None
|
|
links: int = 1
|
|
has_extended_attributes: bool = False
|
|
|
|
|
|
class ImageStore:
|
|
"""Treat downloaded guest assets as templates, never as writable VM disks."""
|
|
|
|
def __init__(self, settings: Settings, runner: CommandRunner | None = None) -> None:
|
|
self._settings = settings
|
|
self._runner = runner or CommandRunner()
|
|
|
|
def installed_assets(self) -> GuestAssets:
|
|
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)
|
|
rootfs_checksum = self._settings.rootfs_sha256
|
|
if manifest.verified or self._settings.allow_unverified_downloads:
|
|
rootfs_checksum = rootfs_checksum or manifest.checksums.get("rootfs")
|
|
verify_file(
|
|
rootfs,
|
|
rootfs_checksum,
|
|
"guest root filesystem",
|
|
"UVM_ROOTFS_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
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, *, storage_mib: int = MIN_STORAGE_MIB,
|
|
) -> Path:
|
|
"""Copy the template before Firecracker opens it read-write for a VM."""
|
|
|
|
self._validate_storage(storage_mib)
|
|
if destination.exists():
|
|
raise UvmError(f"VM disk already exists: {destination}")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
self._copy_sparse(source, destination)
|
|
destination.chmod(0o600)
|
|
self._resize_filesystem(destination, storage_mib)
|
|
except BaseException as error:
|
|
try:
|
|
destination.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
if isinstance(error, OSError):
|
|
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
|
raise
|
|
return destination
|
|
|
|
def disk_size_bytes(self, disk: Path) -> int:
|
|
"""Read logical disk capacity, including a resize published before state was saved."""
|
|
try:
|
|
return disk.stat().st_size
|
|
except OSError as error:
|
|
raise UvmError(f"could not inspect private disk capacity: {disk}: {error}") from error
|
|
|
|
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
|
|
"""Resize a proven-offline private disk on a copy, then publish atomically."""
|
|
self._validate_storage(storage_mib)
|
|
metadata = disk.lstat()
|
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
|
or disk.resolve() == self._settings.rootfs_image.resolve()):
|
|
raise UvmError(f"private VM disk is unsafe to resize: {disk}")
|
|
target_bytes = storage_mib * 1024 * 1024
|
|
if metadata.st_size == target_bytes and self._filesystem_size(disk) == target_bytes:
|
|
return # A previous resize may have published its disk before saving metadata.
|
|
|
|
descriptor, name = tempfile.mkstemp(prefix=".rootfs-resize-", suffix=".ext4", dir=disk.parent)
|
|
os.close(descriptor)
|
|
temporary = Path(name)
|
|
try:
|
|
self._copy_sparse(disk, temporary)
|
|
temporary.chmod(0o600)
|
|
self._resize_filesystem(temporary, storage_mib)
|
|
os.replace(temporary, disk)
|
|
directory = os.open(disk.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
except OSError as error:
|
|
raise UvmError(f"could not resize VM disk {disk}: {error}") from error
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
def configure_guest_network(self, disk: Path, guest_ip: str) -> bool:
|
|
"""Specialize an unedited UVM profile on a proven-offline private disk.
|
|
|
|
Lifecycle owns the operation lock and confirms process exit. Stage and
|
|
check a sparse copy so failed migrations retain the original disk. A
|
|
completed migration, custom image, or guest-edited profile is a no-op.
|
|
"""
|
|
try:
|
|
address = IPv4Address(guest_ip)
|
|
except ValueError as error:
|
|
raise ValidationError("guest network configuration requires a valid IPv4 address") from error
|
|
if address not in self._settings.network or address in {
|
|
self._settings.network.network_address,
|
|
self._settings.network.broadcast_address,
|
|
self._settings.gateway,
|
|
}:
|
|
raise ValidationError("guest network configuration requires a usable IP inside the UVM subnet")
|
|
|
|
try:
|
|
metadata = disk.lstat()
|
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
|
or disk.resolve() == self._settings.rootfs_image.resolve()):
|
|
raise UvmError(f"private VM disk is unsafe to configure: {disk}")
|
|
with tempfile.TemporaryDirectory(prefix="uvm-guest-network-") as name:
|
|
files = Path(name)
|
|
if self._unconfigured_guest_network(disk, files) is None:
|
|
return False
|
|
|
|
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-network-", suffix=".ext4", dir=disk.parent)
|
|
os.close(descriptor)
|
|
temporary = Path(candidate)
|
|
try:
|
|
self._copy_sparse(disk, temporary)
|
|
temporary.chmod(0o600)
|
|
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
|
|
if checked.returncode not in (0, 1):
|
|
detail = checked.stderr.strip() or checked.stdout.strip()
|
|
raise UvmError(f"filesystem check failed before guest network configuration: {detail}")
|
|
# Journal replay may reveal guest edits not visible to the
|
|
# initial debugfs read. Recheck ownership on the recovered copy.
|
|
profile = self._unconfigured_guest_network(temporary, files)
|
|
if profile is None:
|
|
return False
|
|
profile.path.write_text(
|
|
GUEST_NETWORK_TEMPLATE
|
|
+ f"Address={address}/{self._settings.network.prefixlen}\n"
|
|
+ f"Gateway={self._settings.gateway}\n",
|
|
encoding="utf-8",
|
|
)
|
|
self._write_guest_file(temporary, "/etc/systemd/network/10-uvm.network", profile)
|
|
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
|
|
with temporary.open("rb") as image:
|
|
os.fsync(image.fileno())
|
|
os.replace(temporary, disk)
|
|
directory = os.open(disk.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
return True
|
|
except (OSError, UnicodeError) as error:
|
|
raise UvmError(f"could not configure guest networking: {error}") from error
|
|
|
|
def _unconfigured_guest_network(self, disk: Path, temporary: Path) -> _GuestFile | None:
|
|
marker = self._read_guest_file(
|
|
disk, "/etc/uvm-image", temporary / "marker", required=False, regular_only=True,
|
|
)
|
|
if marker is None or marker.path.read_text(encoding="utf-8") not in {
|
|
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
|
|
"uvm-ubuntu-jammy-v4\n",
|
|
}:
|
|
return None
|
|
profile = self._read_guest_file(
|
|
disk, "/etc/systemd/network/10-uvm.network", temporary / "network", required=False, regular_only=True,
|
|
)
|
|
if (profile is None or (profile.uid, profile.gid, profile.mode, profile.links) != (0, 0, 0o644, 1)
|
|
or profile.has_extended_attributes):
|
|
return None
|
|
if profile.path.read_text(encoding="utf-8") not in {
|
|
_LEGACY_GUEST_NETWORK_TEMPLATE, GUEST_NETWORK_TEMPLATE,
|
|
}:
|
|
return None
|
|
return profile
|
|
|
|
def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool:
|
|
"""Configure new templates, or explicitly install on a proven-offline disk.
|
|
|
|
The lifecycle lock and confirmed VMM exit are required. A checked sparse
|
|
copy preserves the original disk on failure and makes retries atomic.
|
|
"""
|
|
parse_username(username)
|
|
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None:
|
|
raise ValidationError("invalid VM ID for guest agent configuration")
|
|
try:
|
|
metadata = disk.lstat()
|
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
|
or disk.resolve() == self._settings.rootfs_image.resolve()):
|
|
raise UvmError("private VM disk is unsafe for guest agent installation")
|
|
with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name:
|
|
files = Path(name)
|
|
if not install:
|
|
marker = self._read_guest_file(disk, "/etc/uvm-image", files / "marker",
|
|
required=False, regular_only=True)
|
|
if marker is None or marker.path.read_text(encoding="utf-8") != "uvm-ubuntu-jammy-v4\n":
|
|
return False
|
|
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", suffix=".ext4", dir=disk.parent)
|
|
os.close(descriptor)
|
|
temporary = Path(candidate)
|
|
try:
|
|
self._copy_sparse(disk, temporary)
|
|
temporary.chmod(0o600)
|
|
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
|
|
if checked.returncode not in (0, 1):
|
|
raise UvmError("filesystem check failed before guest agent installation")
|
|
# All ownership/content checks happen AFTER journal replay.
|
|
self._install_agent_files(temporary, files, vm_id, username)
|
|
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
|
|
with temporary.open("rb") as image:
|
|
os.fsync(image.fileno())
|
|
os.replace(temporary, disk)
|
|
directory = os.open(disk.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
return True
|
|
except (OSError, UnicodeError) as error:
|
|
raise UvmError(f"could not provision guest agent: {error}") from error
|
|
|
|
def _install_agent_files(self, disk: Path, files: Path, vm_id: str, username: str) -> None:
|
|
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
|
|
result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True)
|
|
if re.search(r"Type:\s+regular\b", result.stdout) is None:
|
|
raise UvmError("guest agent installation requires a systemd/apt image (Ubuntu 22.04 or newer)")
|
|
for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd",
|
|
"/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"):
|
|
self._agent_directory(disk, directory)
|
|
payloads = guest_agent_files()
|
|
payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8")
|
|
manifest_path = "/etc/uvm-agent-files.json"
|
|
previous = self._agent_file(disk, manifest_path, files / "manifest")
|
|
old_hashes = {}
|
|
if previous is not None:
|
|
try:
|
|
old_hashes = json.loads(previous.path.read_bytes())
|
|
except (ValueError, UnicodeError) as error:
|
|
raise UvmError("invalid guest agent ownership manifest") from error
|
|
if (not isinstance(old_hashes, dict) or set(old_hashes) - set(payloads)
|
|
or any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None
|
|
for value in old_hashes.values())):
|
|
raise UvmError("invalid guest agent ownership manifest")
|
|
hashes = {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}
|
|
for index, (path, content) in enumerate(payloads.items()):
|
|
existing = self._agent_file(disk, path, files / f"existing-{index}")
|
|
if existing is not None:
|
|
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
|
|
if digest not in {hashes[path], old_hashes.get(path)}:
|
|
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
|
|
source = files / f"payload-{index}"
|
|
source.write_bytes(content)
|
|
self._write_guest_file(disk, path, _GuestFile(source, 0o600 if path.endswith(".json") else 0o644, 0, 0))
|
|
source = files / "new-manifest"
|
|
source.write_text(json.dumps(hashes, sort_keys=True) + "\n", encoding="utf-8")
|
|
self._write_guest_file(disk, manifest_path, _GuestFile(source, 0o600, 0, 0))
|
|
link = "/etc/systemd/system/multi-user.target.wants/uvm-agent.service"
|
|
target = "/etc/systemd/system/uvm-agent.service"
|
|
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
|
|
if "file not found" in (result.stdout + result.stderr).lower():
|
|
self._runner.run(("debugfs", "-w", "-R", f"symlink {link} {target}", disk), capture=True)
|
|
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
|
|
if (re.search(r"Type:\s+symlink\b", result.stdout) is None
|
|
or f'Fast link dest: "{target}"' not in result.stdout):
|
|
raise UvmError("could not enable guest agent without replacing a customized service link")
|
|
|
|
def _agent_directory(self, disk: Path, path: str) -> None:
|
|
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
|
|
if "file not found" in (result.stdout + result.stderr).lower():
|
|
self._create_guest_directory(disk, path, 0o755, 0, 0)
|
|
return
|
|
mode, owner = _MODE_PATTERN.search(result.stdout), _OWNER_PATTERN.search(result.stdout)
|
|
if (re.search(r"Type:\s+directory\b", result.stdout) is None or mode is None or owner is None
|
|
or int(owner.group(1)) != 0 or int(mode.group(1), 8) & 0o022):
|
|
raise UvmError(f"unsafe guest agent directory: {path}")
|
|
|
|
def _agent_file(self, disk: Path, path: str, destination: Path) -> _GuestFile | None:
|
|
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
|
|
if "file not found" in (result.stdout + result.stderr).lower():
|
|
return None
|
|
if re.search(r"Type:\s+regular\b", result.stdout) is None:
|
|
raise UvmError(f"unsafe guest agent file: {path}")
|
|
source = self._read_guest_file(disk, path, destination)
|
|
if (source.uid != 0 or source.mode & 0o022 or source.links != 1 or source.has_extended_attributes):
|
|
raise UvmError(f"unsafe guest agent file: {path}")
|
|
return source
|
|
|
|
@staticmethod
|
|
def _validate_storage(storage_mib: int) -> None:
|
|
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
|
|
raise ValidationError("storage must be a supported whole-MiB capacity of at least 30 GiB")
|
|
|
|
def _copy_sparse(self, source: Path, destination: Path) -> None:
|
|
self._runner.run(("cp", "--reflink=auto", "--sparse=always", "--", source, destination), capture=True)
|
|
|
|
def _filesystem_size(self, disk: Path) -> int:
|
|
result = self._runner.run(("env", "LC_ALL=C", "dumpe2fs", "-h", disk), capture=True)
|
|
blocks = re.search(r"^Block count:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
|
|
block_size = re.search(r"^Block size:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
|
|
if blocks is None or block_size is None:
|
|
raise UvmError(f"could not determine ext4 filesystem capacity: {disk}")
|
|
return int(blocks.group(1)) * int(block_size.group(1))
|
|
|
|
def _resize_filesystem(self, disk: Path, storage_mib: int) -> None:
|
|
"""Grow the file before ext4; shrink ext4 before truncating the file."""
|
|
checked = self._runner.run(("e2fsck", "-f", "-p", disk), check=False, capture=True)
|
|
if checked.returncode not in (0, 1):
|
|
detail = checked.stderr.strip() or checked.stdout.strip()
|
|
raise UvmError(f"filesystem check failed before resize ({checked.returncode}): {detail}")
|
|
target_bytes = storage_mib * 1024 * 1024
|
|
if disk.stat().st_size < target_bytes:
|
|
with disk.open("r+b") as image:
|
|
image.truncate(target_bytes)
|
|
self._runner.run(("resize2fs", disk, f"{storage_mib}M"), capture=True)
|
|
if self._filesystem_size(disk) != target_bytes:
|
|
raise UvmError("resized ext4 filesystem does not match the requested capacity")
|
|
with disk.open("r+b") as image:
|
|
image.truncate(target_bytes)
|
|
image.flush()
|
|
os.fsync(image.fileno())
|
|
|
|
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
|
|
"""Create a missing guest account and set its password in an offline ext4 disk."""
|
|
|
|
parse_username(username)
|
|
password_hash = self._password_hash(password)
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
|
|
temporary = Path(temporary_name)
|
|
passwd = self._read_guest_file(disk, "/etc/passwd", temporary / "passwd")
|
|
shadow = self._read_guest_file(disk, "/etc/shadow", temporary / "shadow")
|
|
sshd_config = self._read_guest_file(
|
|
disk,
|
|
"/etc/ssh/sshd_config",
|
|
temporary / "sshd_config",
|
|
)
|
|
authorized_keys = self._read_guest_file(
|
|
disk,
|
|
"/root/.ssh/authorized_keys",
|
|
temporary / "authorized_keys",
|
|
required=False,
|
|
)
|
|
assert passwd is not None
|
|
assert shadow is not None
|
|
assert sshd_config is not None
|
|
|
|
account_names = {
|
|
line.split(":", 1)[0]
|
|
for line in passwd.path.read_text(encoding="utf-8").splitlines()
|
|
if ":" in line
|
|
}
|
|
if username not in account_names:
|
|
self._create_guest_user(disk, username, passwd, shadow, temporary)
|
|
|
|
shadow.path.write_text(
|
|
_set_shadow_password(
|
|
shadow.path.read_text(encoding="utf-8"),
|
|
username,
|
|
password_hash,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
shadow.path.chmod(shadow.mode)
|
|
if username != "root":
|
|
self._provision_guest_sudo(disk, username, temporary)
|
|
|
|
sshd_config.path.write_text(
|
|
_enable_ssh_password_authentication(
|
|
sshd_config.path.read_text(encoding="utf-8"),
|
|
username,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
sshd_config.path.chmod(sshd_config.mode)
|
|
|
|
self._write_guest_file(disk, "/etc/shadow", shadow)
|
|
self._write_guest_file(disk, "/etc/ssh/sshd_config", sshd_config)
|
|
self._replace_guest_host_keys(disk, temporary)
|
|
if authorized_keys is not None:
|
|
self._remove_firecracker_demo_key(disk, authorized_keys)
|
|
except (OSError, UnicodeError) as error:
|
|
raise UvmError(f"could not provision guest credentials: {error}") from error
|
|
|
|
def _provision_guest_sudo(self, disk: Path, username: str, temporary: Path) -> None:
|
|
sudoers = self._read_guest_file(disk, "/etc/sudoers", temporary / "sudoers", required=False)
|
|
sudo = self._runner.run(("debugfs", "-R", "stat /usr/bin/sudo", disk), capture=True)
|
|
mode = _MODE_PATTERN.search(sudo.stdout)
|
|
owner = _OWNER_PATTERN.search(sudo.stdout)
|
|
if (
|
|
sudoers is None
|
|
or sudoers.uid != 0 or sudoers.mode & 0o022
|
|
or re.search(r"Type:\s+regular\b", sudo.stdout) is None
|
|
or mode is None or not int(mode.group(1), 8) & 0o4000
|
|
or not int(mode.group(1), 8) & 0o111
|
|
or owner is None or int(owner.group(1)) != 0
|
|
):
|
|
raise UvmError(
|
|
"guest image needs setuid-root sudo and /etc/sudoers for a custom administrator; "
|
|
"run uvm install --force to build the complete Ubuntu image"
|
|
)
|
|
contents = sudoers.path.read_text(encoding="utf-8").rstrip()
|
|
sudoers.path.write_text(
|
|
contents + "\n\n# Managed by uvm: guest administrator\n"
|
|
+ f"Defaults:{username} !rootpw, !runaspw, !targetpw\n"
|
|
+ f"{username} ALL=(ALL:ALL) PASSWD: ALL\n",
|
|
encoding="utf-8",
|
|
)
|
|
sudoers.path.chmod(sudoers.mode)
|
|
self._write_guest_file(disk, "/etc/sudoers", sudoers)
|
|
|
|
def _create_guest_user(
|
|
self,
|
|
disk: Path,
|
|
username: str,
|
|
passwd: _GuestFile,
|
|
shadow: _GuestFile,
|
|
temporary: Path,
|
|
) -> None:
|
|
if username == "root":
|
|
raise UvmError("guest rootfs is missing the root account")
|
|
group = self._read_guest_file(disk, "/etc/group", temporary / "group")
|
|
gshadow = self._read_guest_file(
|
|
disk, "/etc/gshadow", temporary / "gshadow", required=False,
|
|
)
|
|
assert group is not None
|
|
passwd_entries = [
|
|
line.split(":")
|
|
for line in passwd.path.read_text(encoding="utf-8").splitlines()
|
|
if ":" in line
|
|
]
|
|
group_entries = [
|
|
line.split(":")
|
|
for line in group.path.read_text(encoding="utf-8").splitlines()
|
|
if ":" in line
|
|
]
|
|
try:
|
|
used_uids = {int(fields[2]) for fields in passwd_entries}
|
|
used_gids = {int(fields[3]) for fields in passwd_entries}
|
|
used_gids.update(int(fields[2]) for fields in group_entries)
|
|
except (IndexError, ValueError) as error:
|
|
raise UvmError("guest rootfs has invalid passwd or group entries") from error
|
|
|
|
uid = _next_guest_id(used_uids)
|
|
existing_group = next((fields for fields in group_entries if fields[0] == username), None)
|
|
if existing_group is not None:
|
|
gid = int(existing_group[2])
|
|
else:
|
|
gid = uid if uid not in used_gids else _next_guest_id(used_gids)
|
|
|
|
home = f"/home/{username}"
|
|
updates = [
|
|
("/etc/passwd", passwd, f"{username}:x:{uid}:{gid}::{home}:/bin/sh"),
|
|
("/etc/shadow", shadow, f"{username}:!:{int(time.time() // 86400)}:0:99999:7:::"),
|
|
]
|
|
if existing_group is None:
|
|
updates.append(("/etc/group", group, f"{username}:x:{gid}:"))
|
|
if gshadow is not None:
|
|
updates.append(("/etc/gshadow", gshadow, f"{username}:!::"))
|
|
for guest_path, source, entry in updates:
|
|
contents = source.path.read_text(encoding="utf-8")
|
|
if any(line.split(":", 1)[0] == username for line in contents.splitlines()):
|
|
raise UvmError(f"guest rootfs already has an entry for {username} in {guest_path}")
|
|
separator = "" if not contents or contents.endswith("\n") else "\n"
|
|
source.path.write_text(f"{contents}{separator}{entry}\n", encoding="utf-8")
|
|
source.path.chmod(source.mode)
|
|
|
|
self._create_guest_directory(disk, "/home", 0o755, 0, 0, exist_ok=True)
|
|
self._create_guest_directory(disk, home, 0o700, uid, gid)
|
|
for guest_path, source, _entry in updates:
|
|
if guest_path != "/etc/shadow": # Published with its password below.
|
|
self._write_guest_file(disk, guest_path, source)
|
|
|
|
def _create_guest_directory(
|
|
self,
|
|
disk: Path,
|
|
guest_path: str,
|
|
mode: int,
|
|
uid: int,
|
|
gid: int,
|
|
*,
|
|
exist_ok: bool = False,
|
|
) -> None:
|
|
command = ("debugfs", "-R", f"stat {guest_path}", disk)
|
|
existing = self._runner.run(command, check=False, capture=True)
|
|
if exist_ok and re.search(r"Type:\s+directory\b", existing.stdout):
|
|
return
|
|
if "file not found" not in f"{existing.stdout}\n{existing.stderr}".lower():
|
|
raise UvmError(f"cannot create guest rootfs directory: {guest_path}")
|
|
|
|
self._runner.run(("debugfs", "-w", "-R", f"mkdir {guest_path}", disk), capture=True)
|
|
for field, value in (
|
|
("mode", f"0{0o40000 | mode:o}"),
|
|
("uid", str(uid)),
|
|
("gid", str(gid)),
|
|
):
|
|
self._runner.run(
|
|
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", disk),
|
|
capture=True,
|
|
)
|
|
|
|
# debugfs can report command failures while still returning exit status zero.
|
|
created = self._runner.run(command, capture=True)
|
|
mode_match = _MODE_PATTERN.search(created.stdout)
|
|
owner_match = _OWNER_PATTERN.search(created.stdout)
|
|
if (
|
|
re.search(r"Type:\s+directory\b", created.stdout) is None
|
|
or mode_match is None
|
|
or owner_match is None
|
|
or int(mode_match.group(1), 8) != mode
|
|
or (int(owner_match.group(1)), int(owner_match.group(2))) != (uid, gid)
|
|
):
|
|
raise UvmError(f"could not verify guest rootfs directory: {guest_path}")
|
|
|
|
def _password_hash(self, password: str) -> str:
|
|
result = self._runner.run(
|
|
("openssl", "passwd", "-6", "-stdin"),
|
|
capture=True,
|
|
input_text=f"{password}\n",
|
|
sensitive=True,
|
|
)
|
|
password_hash = result.stdout.strip()
|
|
if not password_hash.startswith("$6$") or any(
|
|
character in password_hash for character in ("\n", "\r", ":")
|
|
):
|
|
raise UvmError("openssl returned an invalid guest password hash")
|
|
return password_hash
|
|
|
|
def _read_guest_file(
|
|
self,
|
|
disk: Path,
|
|
guest_path: str,
|
|
destination: Path,
|
|
*,
|
|
required: bool = True,
|
|
regular_only: bool = False,
|
|
) -> _GuestFile | None:
|
|
stat_result = self._runner.run(
|
|
("debugfs", "-R", f"stat {guest_path}", disk),
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
file_type = re.search(r"Type:\s+(\S+)", stat_result.stdout)
|
|
if regular_only and file_type is not None and file_type.group(1) != "regular":
|
|
return None
|
|
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
|
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
|
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
|
|
links_match = _LINKS_PATTERN.search(stat_result.stdout)
|
|
if (
|
|
mode_match is None
|
|
or owner_match is None
|
|
or flags_match is None
|
|
or links_match is None
|
|
):
|
|
output = f"{stat_result.stdout}\n{stat_result.stderr}".lower()
|
|
if not required and "file not found" in output:
|
|
return None
|
|
if required and "file not found" in output:
|
|
raise UvmError(f"guest rootfs is missing required file: {guest_path}")
|
|
raise UvmError(f"could not inspect guest rootfs file: {guest_path}")
|
|
|
|
self._runner.run(
|
|
("debugfs", "-R", f"dump {guest_path} {destination}", disk),
|
|
capture=True,
|
|
)
|
|
if not destination.is_file():
|
|
raise UvmError(f"could not read guest rootfs file: {guest_path}")
|
|
attributes = self._runner.run(
|
|
("debugfs", "-R", f"ea_list {guest_path}", disk),
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
diagnostics = [
|
|
line
|
|
for line in attributes.stderr.splitlines()
|
|
if line and not line.startswith("debugfs ")
|
|
]
|
|
if attributes.returncode != 0 or diagnostics:
|
|
raise UvmError(f"could not inspect guest rootfs metadata: {guest_path}")
|
|
return _GuestFile(
|
|
path=destination,
|
|
mode=int(mode_match.group(1), 8),
|
|
uid=int(owner_match.group(1)),
|
|
gid=int(owner_match.group(2)),
|
|
flags=int(flags_match.group(1), 16),
|
|
links=int(links_match.group(1)),
|
|
has_extended_attributes=bool(attributes.stdout.strip()),
|
|
)
|
|
|
|
def _write_guest_file(self, disk: Path, guest_path: str, source: _GuestFile) -> None:
|
|
if source.links != 1:
|
|
raise UvmError(f"cannot safely replace hard-linked guest file: {guest_path}")
|
|
if source.has_extended_attributes:
|
|
raise UvmError(
|
|
f"cannot safely replace guest file with extended attributes: {guest_path}"
|
|
)
|
|
self._runner.run(("debugfs", "-w", "-R", f"rm {guest_path}", disk), capture=True)
|
|
self._runner.run(
|
|
("debugfs", "-w", "-R", f"write {source.path} {guest_path}", disk),
|
|
capture=True,
|
|
)
|
|
for field, value in (
|
|
("mode", f"0{0o100000 | source.mode:o}"),
|
|
("uid", str(source.uid)),
|
|
("gid", str(source.gid)),
|
|
):
|
|
self._runner.run(
|
|
(
|
|
"debugfs",
|
|
"-w",
|
|
"-R",
|
|
f"set_inode_field {guest_path} {field} {value}",
|
|
disk,
|
|
),
|
|
capture=True,
|
|
)
|
|
if source.flags is not None:
|
|
self._runner.run(
|
|
(
|
|
"debugfs",
|
|
"-w",
|
|
"-R",
|
|
f"set_inode_field {guest_path} flags 0x{source.flags:x}",
|
|
disk,
|
|
),
|
|
capture=True,
|
|
)
|
|
|
|
verification = source.path.with_name(f"{source.path.name}.verify")
|
|
written = self._read_guest_file(disk, guest_path, verification)
|
|
assert written is not None
|
|
if (
|
|
verification.read_bytes() != source.path.read_bytes()
|
|
or written.mode != source.mode
|
|
or written.uid != source.uid
|
|
or written.gid != source.gid
|
|
or (source.flags is not None and written.flags != source.flags)
|
|
):
|
|
raise UvmError(f"could not verify updated guest rootfs file: {guest_path}")
|
|
|
|
def _replace_guest_host_keys(self, disk: Path, temporary: Path) -> None:
|
|
for key_type, bits in _HOST_KEY_TYPES:
|
|
name = f"ssh_host_{key_type}_key"
|
|
private_path = temporary / name
|
|
command: list[str | Path] = [
|
|
"ssh-keygen",
|
|
"-q",
|
|
"-t",
|
|
key_type,
|
|
"-N",
|
|
"",
|
|
"-C",
|
|
"",
|
|
"-f",
|
|
private_path,
|
|
]
|
|
if bits is not None:
|
|
command[4:4] = ["-b", bits]
|
|
self._runner.run(command, capture=True)
|
|
|
|
for suffix, default_mode in (("", 0o600), (".pub", 0o644)):
|
|
guest_path = f"/etc/ssh/{name}{suffix}"
|
|
existing = self._read_guest_file(
|
|
disk,
|
|
guest_path,
|
|
temporary / f"existing-{name}{suffix}",
|
|
required=False,
|
|
)
|
|
generated = private_path.with_name(f"{name}{suffix}")
|
|
source = _GuestFile(
|
|
path=generated,
|
|
mode=existing.mode if existing is not None else default_mode,
|
|
uid=existing.uid if existing is not None else 0,
|
|
gid=existing.gid if existing is not None else 0,
|
|
)
|
|
self._write_guest_file(disk, guest_path, source)
|
|
|
|
def _remove_firecracker_demo_key(self, disk: Path, authorized_keys: _GuestFile) -> None:
|
|
contents = authorized_keys.path.read_text(encoding="utf-8")
|
|
updated = _without_firecracker_demo_key(contents)
|
|
if updated == contents:
|
|
return
|
|
if updated is not None:
|
|
authorized_keys.path.write_text(updated, encoding="utf-8")
|
|
authorized_keys.path.chmod(authorized_keys.mode)
|
|
self._write_guest_file(
|
|
disk,
|
|
"/root/.ssh/authorized_keys",
|
|
authorized_keys,
|
|
)
|
|
return
|
|
|
|
self._runner.run(
|
|
("debugfs", "-w", "-R", "rm /root/.ssh/authorized_keys", disk),
|
|
capture=True,
|
|
)
|
|
if self._read_guest_file(
|
|
disk,
|
|
"/root/.ssh/authorized_keys",
|
|
authorized_keys.path.with_name("authorized_keys.verify"),
|
|
required=False,
|
|
) is not None:
|
|
raise UvmError("could not remove the insecure Firecracker demo SSH key")
|
|
|
|
|
|
def _next_guest_id(used: set[int]) -> int:
|
|
for candidate in range(1000, 60001):
|
|
if candidate not in used:
|
|
return candidate
|
|
raise UvmError("guest rootfs has no available regular user/group IDs")
|
|
|
|
|
|
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
|
|
lines = contents.splitlines()
|
|
for index, line in enumerate(lines):
|
|
fields = line.split(":")
|
|
if fields[0] == username and len(fields) >= 2:
|
|
fields[1] = password_hash
|
|
lines[index] = ":".join(fields)
|
|
return "\n".join(lines) + "\n"
|
|
raise UvmError(f"guest rootfs has no shadow entry for user: {username}")
|
|
|
|
|
|
def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
|
managed_directives = {
|
|
"challengeresponseauthentication",
|
|
"hostkey",
|
|
"kbdinteractiveauthentication",
|
|
"passwordauthentication",
|
|
}
|
|
if username == "root":
|
|
managed_directives.add("permitrootlogin")
|
|
global_lines = []
|
|
match_lines = []
|
|
in_match = False
|
|
for line in contents.splitlines():
|
|
stripped = line.lstrip()
|
|
directive = stripped.split(None, 1)[0].lower() if stripped else ""
|
|
if directive == "match":
|
|
in_match = True
|
|
if stripped.startswith("#") or directive not in managed_directives:
|
|
(match_lines if in_match else global_lines).append(line)
|
|
|
|
directives = [
|
|
"HostKey /etc/ssh/ssh_host_rsa_key",
|
|
"HostKey /etc/ssh/ssh_host_ecdsa_key",
|
|
"HostKey /etc/ssh/ssh_host_ed25519_key",
|
|
"PasswordAuthentication no",
|
|
"KbdInteractiveAuthentication no",
|
|
"ChallengeResponseAuthentication no",
|
|
]
|
|
if username == "root":
|
|
directives.append("PermitRootLogin yes")
|
|
# Global-only directives (e.g. UsePAM and Subsystem) must precede every Match block.
|
|
globals_text = "\n".join(global_lines).strip("\n")
|
|
return (
|
|
"# Managed by uvm\n"
|
|
+ "\n".join(directives)
|
|
+ (f"\n{globals_text}" if globals_text else "")
|
|
+ f"\nMatch User {username}\n"
|
|
+ " PasswordAuthentication yes\n"
|
|
+ "Match all\n"
|
|
+ "\n".join(match_lines).rstrip()
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
def _without_firecracker_demo_key(contents: str) -> str | None:
|
|
lines = contents.splitlines()
|
|
retained = [
|
|
line
|
|
for line in lines
|
|
if " ".join(line.split()[:2]) != _FIRECRACKER_DEMO_PUBLIC_KEY
|
|
]
|
|
if len(retained) == len(lines):
|
|
return contents
|
|
return "\n".join(retained) + "\n" if retained else None
|