- Update README and USAGE documentation to reflect changes in rootfs handling, including the default local build from Ubuntu 22.04 packages. - Modify the installer to support building the rootfs locally with authenticated packages and to require SHA-256 checksums for prebuilt images. - Implement checks for setuid permissions on sudo and update the provisioning logic for guest users to ensure proper sudo access. - Adjust tests to validate the new rootfs building process and ensure that existing functionality is preserved. - Refactor code to improve clarity and maintainability, including the introduction of constants for Ubuntu release and guest packages.
637 lines
26 KiB
Python
637 lines
26 KiB
Python
"""Guest asset lookup and per-VM writable root filesystem handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import stat
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
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),
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
@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 re.search(r"Type:\s+regular\b", sudo.stdout) is None
|
|
or mode is None or not int(mode.group(1), 8) & 0o4000
|
|
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,
|
|
) -> _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)
|
|
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
|