353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""Guest asset lookup and per-VM writable root filesystem handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .config import Settings
|
|
from .errors import UvmError
|
|
from .integrity import load_manifest, verify_file
|
|
from .system import CommandRunner
|
|
|
|
|
|
_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+)")
|
|
_HOST_KEY_TYPES = (
|
|
("rsa", "3072"),
|
|
("ecdsa", "256"),
|
|
("ed25519", None),
|
|
)
|
|
|
|
|
|
@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
|
|
|
|
|
|
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._settings.kernel_image
|
|
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,
|
|
"guest root filesystem",
|
|
"UVM_ROOTFS_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
|
|
|
def create_vm_disk(self, source: Path, destination: Path) -> Path:
|
|
"""Copy the template before Firecracker opens it read-write for a VM."""
|
|
|
|
if destination.exists():
|
|
raise UvmError(f"VM disk already exists: {destination}")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
shutil.copy2(source, destination)
|
|
destination.chmod(0o600)
|
|
except OSError as error:
|
|
try:
|
|
destination.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
|
return destination
|
|
|
|
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
|
|
"""Set an existing guest account password in an offline ext4 disk."""
|
|
|
|
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:
|
|
raise UvmError(f"guest user does not exist in rootfs: {username}")
|
|
|
|
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)
|
|
|
|
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 _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,
|
|
) -> _GuestFile | None:
|
|
stat_result = self._runner.run(
|
|
("debugfs", "-R", f"stat {guest_path}", disk),
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
|
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
|
if mode_match is None or owner_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}")
|
|
return _GuestFile(
|
|
path=destination,
|
|
mode=int(mode_match.group(1), 8),
|
|
uid=int(owner_match.group(1)),
|
|
gid=int(owner_match.group(2)),
|
|
)
|
|
|
|
def _write_guest_file(self, disk: Path, guest_path: str, source: _GuestFile) -> None:
|
|
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,
|
|
)
|
|
|
|
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
|
|
):
|
|
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 _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 = {"hostkey", "passwordauthentication", "permitrootlogin"}
|
|
unmanaged_lines = []
|
|
for line in contents.splitlines():
|
|
stripped = line.lstrip()
|
|
directive = stripped.split(None, 1)[0].lower() if stripped else ""
|
|
if stripped.startswith("#") or directive not in managed_directives:
|
|
unmanaged_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",
|
|
]
|
|
if username == "root":
|
|
directives.append("PermitRootLogin yes")
|
|
return (
|
|
"# Managed by uvm\n"
|
|
+ "\n".join(directives)
|
|
+ f"\nMatch User {username}\n PasswordAuthentication yes\nMatch all\n"
|
|
+ "\n".join(unmanaged_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
|