Enhance UVM installation and configuration for Ubuntu 22.04 root filesystem
- 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.
This commit is contained in:
+5
-4
@@ -74,8 +74,9 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
install = subcommands.add_parser(
|
||||
"install",
|
||||
description=(
|
||||
"Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 "
|
||||
"to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
|
||||
"Set UVM_FIRECRACKER_SHA256 and UVM_KERNEL_SHA256 to trusted digests. "
|
||||
"The default Ubuntu rootfs is built from signed packages. A custom "
|
||||
"UVM_ROOTFS_URL also requires UVM_ROOTFS_SHA256. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
|
||||
"development-only opt-out."
|
||||
),
|
||||
help="install dependencies and assets; SHA-256 values are required by default",
|
||||
@@ -83,7 +84,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
install.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
|
||||
help="refresh downloads and rebuild the default Ubuntu rootfs even when assets exist",
|
||||
)
|
||||
|
||||
create = subcommands.add_parser("create", help="create and boot a microVM detached")
|
||||
@@ -111,7 +112,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
create.add_argument(
|
||||
"--username",
|
||||
default=application.settings.default_ssh_user,
|
||||
help="guest login account; created if missing (defaults to root)",
|
||||
help="guest administrator account; created if missing, with sudo access (defaults to root)",
|
||||
)
|
||||
credentials = create.add_mutually_exclusive_group()
|
||||
credentials.add_argument(
|
||||
|
||||
+1
-3
@@ -31,7 +31,7 @@ class Settings:
|
||||
default_vcpu: float = 1
|
||||
default_ssh_user: str = "root"
|
||||
kernel_url: str = ""
|
||||
rootfs_url: str = ""
|
||||
rootfs_url: str = "" # Empty selects the locally built, package-managed Ubuntu image.
|
||||
firecracker_sha256: str | None = None
|
||||
firecracker_binary_sha256: str | None = None
|
||||
kernel_sha256: str | None = None
|
||||
@@ -56,8 +56,6 @@ class Settings:
|
||||
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
|
||||
if not self.kernel_url:
|
||||
object.__setattr__(self, "kernel_url", f"{asset_base}/kernels/vmlinux.bin")
|
||||
if not self.rootfs_url:
|
||||
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
|
||||
|
||||
if self.gateway not in self.network:
|
||||
raise ConfigurationError(
|
||||
|
||||
+37
-3
@@ -226,6 +226,8 @@ class ImageStore:
|
||||
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(
|
||||
@@ -244,6 +246,31 @@ class ImageStore:
|
||||
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,
|
||||
@@ -562,12 +589,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
||||
}
|
||||
if username == "root":
|
||||
managed_directives.add("permitrootlogin")
|
||||
unmanaged_lines = []
|
||||
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:
|
||||
unmanaged_lines.append(line)
|
||||
(match_lines if in_match else global_lines).append(line)
|
||||
|
||||
directives = [
|
||||
"HostKey /etc/ssh/ssh_host_rsa_key",
|
||||
@@ -579,13 +610,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
||||
]
|
||||
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(unmanaged_lines).rstrip()
|
||||
+ "\n".join(match_lines).rstrip()
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
+156
-16
@@ -14,11 +14,24 @@ from pathlib import Path
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .images import GuestAssets
|
||||
from .integrity import require_checksum, sha256_file, verify_file, write_manifest
|
||||
from .integrity import load_manifest, require_checksum, sha256_file, verify_file, write_manifest
|
||||
from .state import StateStore
|
||||
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
|
||||
|
||||
|
||||
_UBUNTU_RELEASE = "jammy"
|
||||
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v1\n"
|
||||
_GUEST_PACKAGES = (
|
||||
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
|
||||
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl",
|
||||
)
|
||||
# Keep the image mountable by the older Firecracker quickstart guest kernels.
|
||||
_EXT4_FEATURES = (
|
||||
"none,has_journal,ext_attr,resize_inode,dir_index,filetype,extent,64bit,"
|
||||
"flex_bg,sparse_super,large_file,huge_file,dir_nlink,extra_isize,metadata_csum"
|
||||
)
|
||||
|
||||
|
||||
class Installer:
|
||||
"""Install exactly the local host dependencies used by the uvm CLI."""
|
||||
|
||||
@@ -68,12 +81,18 @@ class Installer:
|
||||
"UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
require_checksum(
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
if self._settings.rootfs_url:
|
||||
require_checksum(
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
elif self._settings.rootfs_sha256:
|
||||
raise UvmError(
|
||||
"UVM_ROOTFS_SHA256 requires UVM_ROOTFS_URL for a prebuilt image; "
|
||||
"unset UVM_ROOTFS_SHA256 to build the default Ubuntu rootfs"
|
||||
)
|
||||
|
||||
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
|
||||
write_manifest(
|
||||
@@ -88,7 +107,7 @@ class Installer:
|
||||
(
|
||||
self._settings.firecracker_sha256,
|
||||
self._settings.kernel_sha256,
|
||||
self._settings.rootfs_sha256,
|
||||
self._settings.rootfs_sha256 if self._settings.rootfs_url else True,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -108,6 +127,8 @@ class Installer:
|
||||
"openssl",
|
||||
"openssh-client",
|
||||
)
|
||||
if not self._settings.rootfs_url:
|
||||
packages += ("debootstrap", "ubuntu-keyring")
|
||||
self._runner.run(("apt-get", "update"))
|
||||
self._runner.run(("apt-get", "install", "-y", *packages))
|
||||
|
||||
@@ -183,7 +204,9 @@ class Installer:
|
||||
label="guest kernel",
|
||||
environment_name="UVM_KERNEL_SHA256",
|
||||
)
|
||||
if force or not rootfs.exists():
|
||||
if not self._settings.rootfs_url:
|
||||
self._install_ubuntu_rootfs(rootfs, force=force)
|
||||
elif force or not rootfs.exists():
|
||||
self.download(
|
||||
self._settings.rootfs_url,
|
||||
rootfs,
|
||||
@@ -198,15 +221,132 @@ class Installer:
|
||||
"UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
verify_file(
|
||||
rootfs,
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
if self._settings.rootfs_url:
|
||||
verify_file(
|
||||
rootfs,
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
||||
|
||||
def _install_ubuntu_rootfs(self, destination: Path, *, force: bool) -> None:
|
||||
if destination.is_file() and not force:
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
checksum = manifest.checksums.get("rootfs")
|
||||
if manifest.verified and checksum:
|
||||
verify_file(destination, checksum, "Ubuntu rootfs", "integrity manifest", allow_unverified=False)
|
||||
marker = self._runner.run(
|
||||
("debugfs", "-R", "cat /etc/uvm-image", destination), capture=True,
|
||||
)
|
||||
if marker.stdout == _ROOTFS_RECIPE:
|
||||
return
|
||||
# Rebuild old demo images and unverified caches rather than promoting their trust.
|
||||
self._build_ubuntu_rootfs(destination)
|
||||
|
||||
def _build_ubuntu_rootfs(self, destination: Path) -> None:
|
||||
architecture = "arm64" if self._settings.architecture == "aarch64" else "amd64"
|
||||
mirror = (
|
||||
"https://ports.ubuntu.com/ubuntu-ports" if architecture == "arm64"
|
||||
else "https://archive.ubuntu.com/ubuntu"
|
||||
)
|
||||
if self._emit is not None:
|
||||
self._emit("Building Ubuntu 22.04 rootfs with sudo, OpenSSH, and apt/dpkg metadata")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name:
|
||||
temporary = Path(name)
|
||||
root = temporary / "root"
|
||||
policy = root / "usr/sbin/policy-rc.d"
|
||||
policy.parent.mkdir(parents=True)
|
||||
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
|
||||
policy.chmod(0o755)
|
||||
# Package scripts run natively, with private mounts/PIDs and service startup disabled.
|
||||
self._runner.run((
|
||||
"unshare", "--mount", "--propagation", "private", "--pid", "--fork",
|
||||
"--kill-child", "--mount-proc", "env", "DEBIAN_FRONTEND=noninteractive",
|
||||
"debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr",
|
||||
"--components=main,universe", f"--include={','.join(_GUEST_PACKAGES)}",
|
||||
f"--extra-suites={_UBUNTU_RELEASE}-updates,{_UBUNTU_RELEASE}-security",
|
||||
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
|
||||
_UBUNTU_RELEASE, root, mirror,
|
||||
))
|
||||
policy.unlink()
|
||||
self._configure_ubuntu_rootfs(root, mirror)
|
||||
self._validate_ubuntu_rootfs(root)
|
||||
|
||||
disk = temporary / "rootfs.ext4"
|
||||
with disk.open("wb") as image:
|
||||
image.truncate(2 * 1024 * 1024 * 1024)
|
||||
disk.chmod(0o600)
|
||||
self._runner.run((
|
||||
"mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES,
|
||||
"-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk,
|
||||
), capture=True)
|
||||
self._runner.run(("e2fsck", "-f", "-n", disk), capture=True)
|
||||
with disk.open("rb") as image:
|
||||
os.fsync(image.fileno())
|
||||
os.replace(disk, destination)
|
||||
directory = os.open(destination.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from error
|
||||
|
||||
def _configure_ubuntu_rootfs(self, root: Path, mirror: str) -> None:
|
||||
files = {
|
||||
"etc/apt/sources.list": "".join(
|
||||
f"deb {mirror} {suite} main universe\n"
|
||||
for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security")
|
||||
),
|
||||
"etc/fstab": "/dev/vda / ext4 defaults 0 1\n",
|
||||
"etc/hostname": "uvm\n",
|
||||
"etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n",
|
||||
"etc/resolv.conf": "nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
|
||||
"etc/machine-id": "",
|
||||
"etc/uvm-image": _ROOTFS_RECIPE,
|
||||
"etc/systemd/network/10-uvm.network": (
|
||||
"[Match]\nName=eth0\n\n[Network]\n"
|
||||
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
||||
),
|
||||
}
|
||||
for relative, contents in files.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.unlink(missing_ok=True) # In particular, replace a resolver symlink with guest DNS.
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
path.chmod(0o644)
|
||||
machine_id = root / "var/lib/dbus/machine-id"
|
||||
machine_id.parent.mkdir(parents=True, exist_ok=True)
|
||||
machine_id.unlink(missing_ok=True)
|
||||
machine_id.symlink_to("../../../etc/machine-id")
|
||||
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
|
||||
key.unlink()
|
||||
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service"), capture=True)
|
||||
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)
|
||||
|
||||
@staticmethod
|
||||
def _validate_ubuntu_rootfs(root: Path) -> None:
|
||||
for relative in (
|
||||
"etc/passwd", "etc/shadow", "etc/group", "etc/sudoers", "etc/ssh/sshd_config",
|
||||
"usr/bin/sudo", "usr/bin/apt-get", "usr/sbin/sshd", "usr/lib/systemd/systemd",
|
||||
"var/lib/dpkg/status", "var/lib/dpkg/info/apt.list", "var/lib/dpkg/info/sudo.list",
|
||||
):
|
||||
if not (root / relative).is_file():
|
||||
raise UvmError(f"built Ubuntu rootfs is missing /{relative}")
|
||||
installed = set()
|
||||
for paragraph in (root / "var/lib/dpkg/status").read_text(encoding="utf-8").split("\n\n"):
|
||||
fields = dict(line.split(": ", 1) for line in paragraph.splitlines() if ": " in line)
|
||||
if fields.get("Status") == "install ok installed":
|
||||
installed.add(fields.get("Package"))
|
||||
if not {"apt", "sudo", "openssh-server", "systemd-sysv"}.issubset(installed):
|
||||
raise UvmError("built Ubuntu rootfs has incomplete apt/dpkg package metadata")
|
||||
if not (root / "usr/bin/sudo").stat().st_mode & 0o4000:
|
||||
raise UvmError("built Ubuntu rootfs sudo is missing its setuid permission")
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
|
||||
Reference in New Issue
Block a user