From 4bf4c0853c42cdf1df3b0492f5e8a0211e67b921 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Tue, 22 Sep 2026 00:33:05 +0530 Subject: [PATCH 1/9] 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. --- DEVELOPER.md | 48 ++++++++++-- README.md | 49 +++++++++--- USAGE.md | 55 ++++++++++---- tests/test_config.py | 4 +- tests/test_images.py | 43 ++++++++++- tests/test_install.py | 162 ++++++++++++++++++++++++++++++++++++++- uvm/cli.py | 9 ++- uvm/config.py | 4 +- uvm/images.py | 40 +++++++++- uvm/install.py | 172 ++++++++++++++++++++++++++++++++++++++---- 10 files changed, 523 insertions(+), 63 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index bb5dc44..c3bfa11 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -545,6 +545,13 @@ The new `/home/USERNAME` directory is mode `0700`, owned by the new UID/GID; Directory ownership/modes and account-file writes are read back before the disk can be marked ready. The default `root` account must already exist. +Custom login users are VM administrators. Provisioning requires a root-owned, +setuid `/usr/bin/sudo` and `/etc/sudoers`, and appends a user-scoped `PASSWD: ALL` +rule with root/target/runas-password defaults disabled. Sudo authenticates with +the login user's password; root's separate shadow entry is not changed. Global +sshd directives precede managed and existing `Match` blocks so standard Ubuntu +`UsePAM`/`Subsystem` settings remain valid. + UVM replaces conventional RSA, ECDSA, and Ed25519 host keys in each private disk and removes the known public login key from Firecracker's bionic image. A VM must never rely on reusable SSH private keys from a shared template. @@ -557,14 +564,19 @@ A VM must never rely on reusable SSH private keys from a shared template. - A local `integrity.json` manifest recording artifact hashes and whether the installation was verified. -Strict installation requires all of these values: +Strict installation requires these values for downloaded artifacts: ```text UVM_FIRECRACKER_SHA256 UVM_KERNEL_SHA256 -UVM_ROOTFS_SHA256 ``` +The default rootfs is built from signature-verified Ubuntu packages and its +resulting digest is recorded in the same manifest. `UVM_ROOTFS_URL` explicitly +selects a prebuilt ext4 download and additionally requires `UVM_ROOTFS_SHA256`. +A rootfs checksum without a URL is rejected during installation to avoid using +a stale Bionic-image checksum for a local build. + `UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` is a local-development escape hatch. It writes a manifest marked `verified: false`; a later strict run will not treat that manifest as publisher trust. Use `install --force` with trusted digests to @@ -694,12 +706,30 @@ handling, downloads, and integrity-manifest publication. `Settings` detects and normalizes the host architecture at construction time (`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures. The same architecture selects the Firecracker release archive, guest kernel, -rootfs, and architecture-specific boot arguments. Default guest URLs use -Firecracker's `img/quickstart_guide//` assets: `kernels/vmlinux.bin` -and `rootfs/bionic.rootfs.ext4`. Kernel and rootfs URL overrides are independent; -custom assets and trusted digests must match the host architecture. ARM64 +native rootfs packages, and architecture-specific boot arguments. The default +kernel URL uses Firecracker's `img/quickstart_guide//kernels/vmlinux.bin`. +An empty `Settings.rootfs_url` selects the built Ubuntu 22.04 (Jammy) template. +Explicit kernel/rootfs URLs and trusted digests must match the host architecture. ARM64 uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs. +The default rootfs builder installs `debootstrap` and `ubuntu-keyring`, uses +`--force-check-gpg`, and includes main/universe plus updates/security suites. +amd64 uses `archive.ubuntu.com/ubuntu`; arm64 uses `ports.ubuntu.com/ubuntu-ports`. +Package scripts execute in private mount/PID namespaces with a temporary +`policy-rc.d` suppressing service startup. No mounts survive the build process. +The staged tree retains `/var/lib/dpkg` and apt metadata, validates installed +apt/sudo/sshd/systemd packages, enables SSH/networkd, preserves kernel-provided +static networking, and supplies guest DNS. Template host keys and machine IDs +are cleared for per-VM initialization. `mkfs.ext4 -d` creates a sparse 2 GiB +template with a guest-kernel-compatible feature set; `e2fsck` and fsync precede +atomic publication. Create still grows each private disk to at least 30 GiB. + +Only a checksum-verified manifest and matching `/etc/uvm-image` recipe marker +permit reuse of a built template. Old demo images and unverified caches are +rebuilt, and `install --force` always rebuilds. A failed staging build retains +the old template. Installation holds the existing operation lock throughout; +existing VM disks are never upgraded or reprovisioned by installation. + The host package list includes `nftables` and `conntrack` as well as the existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux tools. UVM adds no reconciliation daemon or tested external firewall-manager @@ -892,6 +922,9 @@ sparse ext4 images when the tools are installed: default 30 GiB creation, growth, shrinking, geometry, and retained data/password-hash/host-key fixtures. `test_images.py` uses temporary ext4 images to verify custom-user creation, UID/GID allocation, account/home metadata, existing accounts, and password hashes. +`test_install.py` simulates package bootstrap and exercises real ext4 formatting +and readback for retained dpkg metadata, sudo modes, template configuration, +staging failures, and checksum provenance. It does not run debootstrap or boot a VM. Lifecycle resize tests use fake processes/networking to verify ordering, single-lock ownership, identity preservation, pending intent, and failure recovery. @@ -926,7 +959,8 @@ on an isolated Linux host: 1. Strict checked installation. 2. `create` and successful guest boot. 3. Guest IP reachability and egress. -4. SSH with unique guest host keys. +4. SSH with unique guest host keys, `sudo -i` for a custom login, and + `sudo apt update && sudo apt install htop` in a newly built Ubuntu guest. 5. Stop/start/restart with retained guest data, SSH keys, and credentials. 6. Live private-port close in both directions, empty policy, and explicit sync after deliberately lost rules inside the isolated setup. diff --git a/README.md b/README.md index b0ff9fe..3c9499a 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,10 @@ before downloading VM artifacts. Boot operations additionally check that the native nftables bridge policy is supported; installing packages alone is not a connectivity or isolation test. -Firecracker, the guest kernel, and the Ubuntu rootfs are selected automatically -for the host architecture. On NVIDIA DGX Spark, UVM uses the ARM64 assets. +Firecracker and the guest kernel are selected automatically for the host +architecture. The default rootfs is built locally from Ubuntu 22.04 (Jammy) +packages with OpenSSH, sudo, and a complete apt/dpkg database. On NVIDIA DGX +Spark, UVM uses ARM64 assets and packages. `arm64` and `amd64` architecture names are normalized to `aarch64` and `x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides must point to assets compatible with the host architecture. @@ -64,21 +66,35 @@ the privileged installer: sudo env \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ - UVM_ROOTFS_SHA256='' \ ./uvm.py install ``` -Use `--force` to redownload Firecracker and guest assets when replacing an -image or refreshing artifacts: +The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures, +and native packages. UVM installs the builder prerequisites and records the +built ext4 image's SHA-256 in the integrity manifest. Building requires root +with mount/PID namespace support and internet access to Ubuntu mirrors. + +Use `--force` to refresh downloads and rebuild the Ubuntu template: ```sh sudo env \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ - UVM_ROOTFS_SHA256='' \ ./uvm.py install --force ``` +To use a prebuilt ext4 image instead, set both `UVM_ROOTFS_URL` and its trusted +`UVM_ROOTFS_SHA256`. Leave both unset for the default Ubuntu build. + +### Upgrade from the Stripped Bionic Demo Image + +Run `install --force` with the updated code and with `UVM_ROOTFS_URL` and +`UVM_ROOTFS_SHA256` unset, then create a new VM. The replacement template +includes sudo and the package database that the old demo image lacked. +Existing VM disks retain their original OS and credentials; installation +updates the template used for future VMs. Restart a running API server to load +the updated provisioner before creating VMs through the dashboard. + For local experimentation only, the old unverified quick-start behavior is available through an explicit opt-out: @@ -360,13 +376,28 @@ defaults are username `root` and password `root`. A custom image must provide: - `sshd` - `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config` - `/etc/group` and `/bin/sh` when creating a new user +- A root-owned, setuid `/usr/bin/sudo` and `/etc/sudoers` for custom login users + +Custom login users receive password-authenticated sudo access. Inside a new VM: + +```sh +sudo -i # Enter the same password used for this user's SSH login. +sudo apt update +sudo apt install htop +``` + +`su -` asks for root's separate password; creating a custom login does not set +root's password. Use `sudo -i` for an administrative shell. The default Ubuntu +template retains apt's package metadata and enables `main` and `universe`, +including updates/security repositories. Guest DNS defaults to `1.1.1.1` and +`8.8.8.8` in `/etc/resolv.conf`. Existing accounts retain their UID, groups, home directory, and shell. A new user reuses a same-named group if present; otherwise UVM creates one with an unused GID. `/etc/gshadow` is updated when present. The default `root` account must already exist in the image. -The known public demo key bundled in Firecracker's default bionic image is +The known public demo key bundled in Firecracker's older Bionic image is removed from each private disk. UVM also replaces the conventional RSA, ECDSA, and Ed25519 SSH host keys in every private disk so cloned VMs have distinct host identities. Never rely on SSH private keys baked into a shared template. @@ -466,10 +497,10 @@ Host and API settings can be overridden through environment variables: | `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` | | `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths | | `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL | -| `UVM_ROOTFS_URL` | Override the host-architecture default guest rootfs URL | +| `UVM_ROOTFS_URL` | Optional prebuilt ext4 image URL; unset to build Ubuntu 22.04 locally | | `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 | | `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 | -| `UVM_ROOTFS_SHA256` | Trusted guest rootfs SHA-256 | +| `UVM_ROOTFS_SHA256` | Trusted prebuilt rootfs SHA-256; use with `UVM_ROOTFS_URL` | | `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 | | `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development | | `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` | diff --git a/USAGE.md b/USAGE.md index d5dc2a4..55fdef6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -138,21 +138,22 @@ not disable confirmations, and errors may still be plain text on stderr. ## 4. Install UVM Assets UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest -rootfs template. It requires SHA-256 checksums by default because it downloads -artifacts that will be used by a privileged process. +rootfs template. The default rootfs is built locally from authenticated Ubuntu +22.04 packages, including OpenSSH, sudo, and complete apt/dpkg metadata. The +downloaded Firecracker archive and kernel require trusted SHA-256 checksums. ### ARM64 Hosts (NVIDIA DGX Spark) -UVM detects the host architecture and selects matching Firecracker, kernel, -and rootfs downloads. NVIDIA DGX Spark uses the `aarch64` assets; the `arm64` -architecture alias is also accepted. The default ARM64 guest downloads are: +UVM detects the host architecture and selects matching Firecracker and kernel +downloads and native Ubuntu packages. NVIDIA DGX Spark uses `aarch64` artifacts +and `arm64` packages; the `arm64` architecture alias is also accepted: - Kernel: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/kernels/vmlinux.bin` -- Rootfs: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/rootfs/bionic.rootfs.ext4` +- Rootfs: Ubuntu 22.04 built with `debootstrap` from `ports.ubuntu.com/ubuntu-ports`. -Use the installation commands below with checksums for those ARM64 artifacts -and the ARM64 Firecracker release archive. `UVM_KERNEL_URL` and -`UVM_ROOTFS_URL` can override either download independently. Custom images must +Use the installation commands below with checksums for the ARM64 kernel +and Firecracker release archive. `UVM_KERNEL_URL` overrides the kernel download; +`UVM_ROOTFS_URL` selects a prebuilt ext4 image instead of the local build. Custom images must match the host architecture and the [guest image contract](README.md#guest-image-contract). On ARM64, the kernel must be an uncompressed ARM64 Linux `Image` (PE format), and the rootfs must be an ARM64 ext4 filesystem. UVM adds `keep_bootcon` to the @@ -166,7 +167,6 @@ Obtain trusted checksums independently, then run: sudo env \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ - UVM_ROOTFS_SHA256='' \ ./uvm.py install ``` @@ -174,6 +174,12 @@ Installation prints the Firecracker, kernel, and rootfs paths under `/var/lib/uvm`. Checksums and verification provenance are recorded in `/var/lib/uvm/integrity.json`. +The default build installs `debootstrap` and `ubuntu-keyring` on the host, +checks Ubuntu archive signatures, and builds inside private mount/PID +namespaces. It requires native root execution with namespace support. Leave +`UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt +ext4 image, supply both its URL and SHA-256 instead. + ### Scenario: Refresh Firecracker or Guest Assets Use `--force` after changing a URL, replacing an image, or rotating checksums: @@ -182,12 +188,17 @@ Use `--force` after changing a URL, replacing an image, or rotating checksums: sudo env \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ - UVM_ROOTFS_SHA256='' \ ./uvm.py install --force ``` -`--force` downloads to temporary files and only replaces the existing artifact -after checksum verification succeeds. +`--force` stages downloads and builds in temporary paths. Downloaded artifacts +are checksum-verified; the built rootfs is checked for installed packages, +sudo permissions, and filesystem consistency before replacement. + +To upgrade from the old stripped Bionic demo rootfs, run this refresh with +`UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset, then create a new VM. Restart a +running API server to load the new provisioner. Existing VM disks retain their +installed OS, files, and credentials. ### Scenario: Local Experimentation Without Checksums @@ -215,6 +226,7 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM | `checksum is required` | Provide the requested `UVM_*_SHA256` variable. | | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. | | `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. | +| `unshare: Operation not permitted` | Run the builder as root on a host permitting private mount/PID namespaces. | ## 5. Create VMs @@ -245,7 +257,8 @@ create DNS records, and identifiers are not matched by ID prefix. `--username` selects the guest login account. If a custom username is missing, UVM creates it on the private disk with a primary group, `/home/USERNAME`, and -the `/bin/sh` login shell. Existing accounts keep their identity and home. +the `/bin/sh` login shell. Custom logins receive sudo access using their guest +password. Existing accounts keep their identity and home. A `--password` value is visible in process arguments and may enter shell history. Prefer `--password-stdin`, which reads one line, strips the trailing @@ -415,6 +428,20 @@ guest image must include: - An SSH server. - Standard passwd and shadow files, plus `/etc/ssh/sshd_config`. - `/etc/group` and `/bin/sh` for new users; the default `root` account must exist. +- Root-owned setuid `/usr/bin/sudo` and `/etc/sudoers` for a custom login user. + +For a custom login, run these commands inside the VM: + +```sh +sudo -i # Authenticate with the custom user's SSH password. +sudo apt update +sudo apt install htop +``` + +`su -` requests root's password, which is separate from the custom user's +password. Use `sudo -i` for administrative access. The default Ubuntu image +includes an intact `/var/lib/dpkg` database; an old Bionic demo image missing +that database needs a replacement template and a new VM for package management. UVM replaces conventional RSA, ECDSA, and Ed25519 SSH host keys in the private disk during creation and removes Firecracker's publicly known demo login key. diff --git a/tests/test_config.py b/tests/test_config.py index a52f79a..062d558 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -32,7 +32,7 @@ class ArchitectureSettingsTests(unittest.TestCase): ) self.assertEqual(settings.architecture, architecture) self.assertEqual(settings.kernel_url, f"{base}/kernels/vmlinux.bin") - self.assertEqual(settings.rootfs_url, f"{base}/rootfs/bionic.rootfs.ext4") + self.assertEqual(settings.rootfs_url, "") def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None: kernel = "https://example.com/custom-arm64-kernel" @@ -56,7 +56,7 @@ class ArchitectureSettingsTests(unittest.TestCase): ) self.assertEqual( settings.rootfs_url, - overrides.get("UVM_ROOTFS_URL", f"{base}/rootfs/bionic.rootfs.ext4"), + overrides.get("UVM_ROOTFS_URL", ""), ) def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None: diff --git a/tests/test_images.py b/tests/test_images.py index b0eea24..c89e0ac 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -130,6 +130,19 @@ class ImageStoreTests(unittest.TestCase): self.assertIn("Match User service\n PasswordAuthentication yes", updated) self.assertNotIn("ChallengeResponseAuthentication yes", updated) + def test_full_ubuntu_sshd_global_directives_stay_outside_match_blocks(self) -> None: + updated = _enable_ssh_password_authentication( + "Include /etc/ssh/sshd_config.d/*.conf\n" + "UsePAM yes\nSubsystem sftp /usr/lib/openssh/sftp-server\n" + "Match Group restricted\n X11Forwarding no\n PasswordAuthentication no\n", + "codemate-user", + ) + + self.assertLess(updated.index("UsePAM yes"), updated.index("Match User codemate-user")) + self.assertLess(updated.index("Subsystem sftp"), updated.index("Match User codemate-user")) + self.assertLess(updated.index("Match User codemate-user"), updated.index("Match Group restricted")) + self.assertIn("Match Group restricted\n X11Forwarding no", updated) + def test_removes_only_the_public_firecracker_demo_key(self) -> None: own_key = "ssh-ed25519 AAAA-own-key developer@example" @@ -241,6 +254,8 @@ class OfflineGuestCredentialsTests(unittest.TestCase): ), "/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n", "/etc/gshadow": "root:*::\nshadow:*::\nubuntu:!::\nreserved:!::\nnogroup:*::\n", + "/etc/sudoers": "Defaults env_reset\nroot ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n", + "/usr/bin/sudo": "sudo binary fixture\n", "/etc/ssh/sshd_config": "PasswordAuthentication no\nPermitRootLogin no\n", "/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n", } @@ -251,7 +266,8 @@ class OfflineGuestCredentialsTests(unittest.TestCase): path = root / guest_path.lstrip("/") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(contents, encoding="utf-8") - path.chmod(0o640 if guest_path in ("/etc/shadow", "/etc/gshadow") else 0o644) + mode = {"/etc/shadow": 0o640, "/etc/gshadow": 0o640, "/etc/sudoers": 0o440, "/usr/bin/sudo": 0o4755} + path.chmod(mode.get(guest_path, 0o644)) with self.template.open("wb") as image: image.truncate(16 * 1024 * 1024) self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True) @@ -305,6 +321,10 @@ class OfflineGuestCredentialsTests(unittest.TestCase): self.assertLessEqual(int(shadow[2]), int(time.time() // 86400)) self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""]) self.assert_password("codemate-user", "custom-password") + self.assertIn("codemate-user ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers")) + self.assertIn("Defaults:codemate-user !rootpw, !runaspw, !targetpw\n", self.contents("/etc/sudoers")) + self.assert_metadata("/etc/sudoers", 0o440, 0, 0) + self.assert_metadata("/usr/bin/sudo", 0o4755, 0, 0) for guest_path in ("/etc/passwd", "/etc/group"): self.assert_metadata(guest_path, 0o644, 0, 0) for guest_path in ("/etc/shadow", "/etc/gshadow"): @@ -332,6 +352,10 @@ class OfflineGuestCredentialsTests(unittest.TestCase): self.assertEqual(self.contents(guest_path), self.files[guest_path]) self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:]) self.assert_password(username, "changed-password") + if username != "root": + self.assertIn(f"{username} ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers")) + else: + self.assertEqual(self.contents("/etc/sudoers"), self.files["/etc/sudoers"]) for line in self.files["/etc/shadow"].splitlines(): if line.split(":", 1)[0] != username: self.assertIn(line + "\n", self.contents("/etc/shadow")) @@ -375,3 +399,20 @@ class OfflineGuestCredentialsTests(unittest.TestCase): self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"]) self.assertEqual(self.contents("/etc/passwd"), self.files["/etc/passwd"]) self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"]) + + def test_custom_users_fail_with_an_actionable_error_on_stripped_images(self) -> None: + del self.files["/usr/bin/sudo"] + del self.files["/etc/sudoers"] + self.make_disk() + + with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo.*uvm install --force"): + self.store.provision_credentials(self.disk, "codemate-user", "custom-password") + + self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"]) + + def test_sudo_without_setuid_permission_does_not_produce_a_broken_administrator(self) -> None: + self.make_disk() + self.runner.run(("debugfs", "-w", "-R", "set_inode_field /usr/bin/sudo mode 0100755", self.disk), capture=True) + + with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo"): + self.store.provision_credentials(self.disk, "codemate-user", "custom-password") diff --git a/tests/test_install.py b/tests/test_install.py index 2576a15..018e501 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import io import os +import shutil import tarfile import tempfile import unittest @@ -12,11 +13,12 @@ from types import SimpleNamespace from unittest.mock import Mock, patch from uvm.config import Settings -from uvm.errors import UvmError -from uvm.install import Installer -from uvm.integrity import load_manifest +from uvm.errors import CommandError, UvmError +from uvm.images import ImageStore +from uvm.install import Installer, _ROOTFS_RECIPE +from uvm.integrity import load_manifest, write_manifest from uvm.state import StateStore -from uvm.system import CommandRunner +from uvm.system import CommandResult, CommandRunner class InstallerArchitectureTests(unittest.TestCase): @@ -56,6 +58,10 @@ class InstallerArchitectureTests(unittest.TestCase): firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(), kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(), rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(), + rootfs_url=( + "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" + f"{architecture}/rootfs/bionic.rootfs.ext4" + ), ) guest_base = ( "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" @@ -111,3 +117,151 @@ class InstallerArchitectureTests(unittest.TestCase): check_kvm.assert_not_called() download.assert_not_called() self.assertFalse(settings.integrity_manifest_path.exists()) + + def test_default_build_records_the_authenticated_local_image_digest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + settings = Settings( + base=Path(temporary), firecracker_sha256="1" * 64, + kernel_sha256=hashlib.sha256(b"kernel").hexdigest(), + ) + settings.bin_dir.mkdir() + settings.firecracker_binary.write_bytes(b"firecracker") + settings.jailer_binary.write_bytes(b"jailer") + installer = Installer(settings, Mock(spec=CommandRunner), StateStore(settings), emit=None) + with ( + patch("uvm.install.require_root"), patch("uvm.install.check_kvm"), + patch.object(installer, "install_apt_packages"), + patch.object(installer, "install_firecracker_binary", return_value=settings.firecracker_binary), + patch.object(installer, "download", side_effect=lambda _url, path, **_kwargs: path.write_bytes(b"kernel")), + patch.object(installer, "_build_ubuntu_rootfs", side_effect=lambda path: path.write_bytes(b"full Ubuntu")) as build, + ): + installer.install() + + build.assert_called_once_with(settings.rootfs_image) + manifest = load_manifest(settings.integrity_manifest_path) + self.assertTrue(manifest.verified) + self.assertEqual(manifest.checksums["rootfs"], hashlib.sha256(b"full Ubuntu").hexdigest()) + self.assertEqual(ImageStore(settings).installed_assets().rootfs, settings.rootfs_image) + + def test_old_rootfs_checksum_requires_an_explicit_prebuilt_url_before_mutations(self) -> None: + settings = Settings(firecracker_sha256="1" * 64, kernel_sha256="2" * 64, rootfs_sha256="3" * 64) + runner = Mock(spec=CommandRunner) + installer = Installer(settings, runner, Mock(spec=StateStore), emit=None) + with self.assertRaisesRegex(UvmError, "unset UVM_ROOTFS_SHA256"): + installer._validate_integrity_policy() + runner.run.assert_not_called() + + def test_only_verified_current_recipe_images_are_reused(self) -> None: + for verified, marker, force in ( + (True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False), + (True, "", False), (True, _ROOTFS_RECIPE, True), + ): + with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary: + settings = Settings(base=Path(temporary)) + settings.images_dir.mkdir() + settings.rootfs_image.write_bytes(b"image") + write_manifest(settings.integrity_manifest_path, + {"rootfs": hashlib.sha256(b"image").hexdigest()}, verified=verified) + runner = Mock(spec=CommandRunner) + runner.run.return_value = CommandResult(("debugfs",), 0, stdout=marker) + installer = Installer(settings, runner, StateStore(settings), emit=None) + with patch.object(installer, "_build_ubuntu_rootfs") as build: + installer._install_ubuntu_rootfs(settings.rootfs_image, force=force) + self.assertEqual(build.called, not (verified and marker == _ROOTFS_RECIPE and not force)) + + +class UbuntuBuildRunner: + """Simulate package installation; exercise real ext4 formatting and readback.""" + + def __init__(self, *, failure: str | None = None) -> None: + self.calls: list[tuple[str, ...]] = [] + self.failure = failure + self.runner = CommandRunner(emit=None) + + def run(self, command, **options) -> CommandResult: + args = tuple(map(str, command)) + self.calls.append(args) + if "debootstrap" in args: + root = Path(args[-2]) + if self.failure == "bootstrap": + raise CommandError("bootstrap interrupted") + status = "\n\n".join( + f"Package: {package}\nStatus: install ok installed\nVersion: 1.0" + for package in ("apt", "sudo", "openssh-server", "systemd-sysv") + ) + "\n" + files = { + "etc/passwd": "root:x:0:0::/root:/bin/bash\n", + "etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n", + "etc/sudoers": "root ALL=(ALL:ALL) ALL\n", + "etc/ssh/sshd_config": "UsePAM yes\n", + "etc/ssh/ssh_host_rsa_key": "template host key", + "usr/bin/sudo": "sudo binary", "usr/bin/apt-get": "apt binary", + "usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary", + "var/lib/dpkg/status": status, + "var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n", + "var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n", + } + if self.failure == "metadata": + files["var/lib/dpkg/status"] = "" + for relative, contents in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + (root / "usr/bin/sudo").chmod(0o4755) + (root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf") + return CommandResult(args, 0) + if args[0] == "systemctl": + return CommandResult(args, 0) + return self.runner.run(command, **options) + + +@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")), + "e2fsprogs is required for offline Ubuntu image checks") +class UbuntuRootfsBuildTests(unittest.TestCase): + def test_build_keeps_package_metadata_and_sudo_permissions_with_native_signed_bootstrap(self) -> None: + for architecture, deb_arch, mirror in ( + ("aarch64", "arm64", "https://ports.ubuntu.com/ubuntu-ports"), + ("x86_64", "amd64", "https://archive.ubuntu.com/ubuntu"), + ): + with self.subTest(architecture=architecture), tempfile.TemporaryDirectory() as temporary: + settings = Settings(base=Path(temporary), architecture=architecture) + runner = UbuntuBuildRunner() + installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type] + installer._build_ubuntu_rootfs(settings.rootfs_image) + + bootstrap = next(call for call in runner.calls if "debootstrap" in call) + self.assertIn(f"--arch={deb_arch}", bootstrap) + self.assertEqual(bootstrap[-1], mirror) + self.assertIn("--force-check-gpg", bootstrap) + self.assertIn("--extra-suites=jammy-updates,jammy-security", bootstrap) + self.assertIn("--mount-proc", bootstrap) + self.assertIn("--kill-child", bootstrap) + self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600) + self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image]) + + def guest(command): + return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True) + + self.assertIn("Package: apt\nStatus: install ok installed", guest("cat /var/lib/dpkg/status").stdout) + self.assertEqual(guest("cat /var/lib/dpkg/info/sudo.list").stdout, "/usr/bin/sudo\n") + self.assertRegex(guest("stat /usr/bin/sudo").stdout, r"Mode:\s+0?4755") + self.assertEqual(guest("cat /etc/uvm-image").stdout, _ROOTFS_RECIPE) + self.assertIn("nameserver 1.1.1.1", guest("cat /etc/resolv.conf").stdout) + self.assertIn("KeepConfiguration=static", guest("cat /etc/systemd/network/10-uvm.network").stdout) + self.assertIn("jammy-security main universe", guest("cat /etc/apt/sources.list").stdout) + for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"): + self.assertIn("File not found", guest(f"stat {path}").stderr) + + def test_failed_bootstrap_or_missing_package_metadata_preserves_the_old_template(self) -> None: + for failure in ("bootstrap", "metadata"): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary: + settings = Settings(base=Path(temporary)) + settings.images_dir.mkdir() + settings.rootfs_image.write_bytes(b"old template") + runner = UbuntuBuildRunner(failure=failure) + installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type] + with self.assertRaises(UvmError): + installer._build_ubuntu_rootfs(settings.rootfs_image) + self.assertEqual(settings.rootfs_image.read_bytes(), b"old template") + self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image]) + self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls)) diff --git a/uvm/cli.py b/uvm/cli.py index b29c269..948da7f 100644 --- a/uvm/cli.py +++ b/uvm/cli.py @@ -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( diff --git a/uvm/config.py b/uvm/config.py index e5633b1..a7242af 100644 --- a/uvm/config.py +++ b/uvm/config.py @@ -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( diff --git a/uvm/images.py b/uvm/images.py index f9842e4..bc39aff 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -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" ) diff --git a/uvm/install.py b/uvm/install.py index bcc923b..0be99ea 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -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, -- 2.54.0 From 1d3644ab7c6ab49b989b6e31021ed37f68c0bfae Mon Sep 17 00:00:00 2001 From: kstyagi Date: Tue, 22 Sep 2026 00:33:34 +0530 Subject: [PATCH 2/9] feat: update UVM installation and usage documentation; enhance image handling tests --- USAGE.md | 5 ++--- tests/test_images.py | 30 ++++++++++++++++++++++++++++++ uvm/dashboard/static/index.html | 4 ++-- uvm/images.py | 2 ++ uvm/install.py | 14 +++++++------- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/USAGE.md b/USAGE.md index 55fdef6..f79bc77 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1067,7 +1067,6 @@ sudo env \ UVM_BRIDGE='uvm50' \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ - UVM_ROOTFS_SHA256='' \ ./uvm.py install ``` @@ -1086,10 +1085,10 @@ automatically detects every conflict with host routes or other managers. | `UVM_BRIDGE` | Linux bridge name; default `uvm0` | | `UVM_KNOWN_HOSTS_FILES` | Override deletion's local SSH cache files; colon-separated absolute paths | | `UVM_KERNEL_URL` | Override the host-architecture default kernel URL | -| `UVM_ROOTFS_URL` | Override the host-architecture default rootfs URL | +| `UVM_ROOTFS_URL` | Optional prebuilt ext4 URL; unset to build Ubuntu 22.04 locally | | `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum | | `UVM_KERNEL_SHA256` | Strict kernel checksum | -| `UVM_ROOTFS_SHA256` | Strict rootfs checksum | +| `UVM_ROOTFS_SHA256` | Strict prebuilt rootfs checksum, used with `UVM_ROOTFS_URL` | | `UVM_FIRECRACKER_BINARY_SHA256` | Optional local binary checksum | | `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for local development | | `UVM_API_TOKEN` | Required API token | diff --git a/tests/test_images.py b/tests/test_images.py index c89e0ac..3df063e 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -143,6 +143,31 @@ class ImageStoreTests(unittest.TestCase): self.assertLess(updated.index("Match User codemate-user"), updated.index("Match Group restricted")) self.assertIn("Match Group restricted\n X11Forwarding no", updated) + @unittest.skipUnless( + shutil.which("sshd") and shutil.which("ssh-keygen") and Path("/run/sshd").is_dir(), + "OpenSSH server tools and the privilege-separation directory are required", + ) + def test_openssh_accepts_the_full_config_and_limits_password_auth_to_the_guest_user(self) -> None: + runner = CommandRunner(emit=None) + with tempfile.TemporaryDirectory() as temporary: + key = Path(temporary) / "host_key" + runner.run(("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key), capture=True) + contents = _enable_ssh_password_authentication( + "UsePAM yes\nSubsystem sftp internal-sftp\nPermitRootLogin no\n", + "codemate-user", + ) + for key_type in ("rsa", "ecdsa", "ed25519"): + contents = contents.replace(f"/etc/ssh/ssh_host_{key_type}_key", str(key)) + config = Path(temporary) / "sshd_config" + config.write_text(contents, encoding="utf-8") + for username, expected in (("codemate-user", "yes"), ("root", "no")): + with self.subTest(username=username): + result = runner.run(( + shutil.which("sshd"), "-T", "-f", config, + "-C", f"user={username},host=localhost,addr=127.0.0.1", + ), capture=True) + self.assertIn(f"passwordauthentication {expected}\n", result.stdout) + def test_removes_only_the_public_firecracker_demo_key(self) -> None: own_key = "ssh-ed25519 AAAA-own-key developer@example" @@ -325,6 +350,11 @@ class OfflineGuestCredentialsTests(unittest.TestCase): self.assertIn("Defaults:codemate-user !rootpw, !runaspw, !targetpw\n", self.contents("/etc/sudoers")) self.assert_metadata("/etc/sudoers", 0o440, 0, 0) self.assert_metadata("/usr/bin/sudo", 0o4755, 0, 0) + if shutil.which("visudo"): + sudoers = self.base / "sudoers" + sudoers.write_text(self.contents("/etc/sudoers"), encoding="utf-8") + sudoers.chmod(0o440) + self.runner.run(("visudo", "-c", "-f", sudoers), capture=True) for guest_path in ("/etc/passwd", "/etc/group"): self.assert_metadata(guest_path, 0o644, 0, 0) for guest_path in ("/etc/shadow", "/etc/gshadow"): diff --git a/uvm/dashboard/static/index.html b/uvm/dashboard/static/index.html index 23d444f..9c093ef 100644 --- a/uvm/dashboard/static/index.html +++ b/uvm/dashboard/static/index.html @@ -193,7 +193,7 @@

01A name and a little space

Give your new machine a shape.
Start with a size
-

02A way to connect

Private networking and guest access.

Missing users are created with a home directory. Credentials are applied to the machine’s private disk.

+

02A way to connect

Private networking and guest access.

Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.

03Open the right doors

Choose which private TCP services are allowed.
@@ -202,7 +202,7 @@ diff --git a/uvm/images.py b/uvm/images.py index bc39aff..2bcfa81 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -253,8 +253,10 @@ class ImageStore: 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( diff --git a/uvm/install.py b/uvm/install.py index 0be99ea..ded2449 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -204,6 +204,13 @@ class Installer: label="guest kernel", environment_name="UVM_KERNEL_SHA256", ) + verify_file( + kernel, + self._settings.kernel_sha256, + "guest kernel", + "UVM_KERNEL_SHA256", + allow_unverified=self._settings.allow_unverified_downloads, + ) if not self._settings.rootfs_url: self._install_ubuntu_rootfs(rootfs, force=force) elif force or not rootfs.exists(): @@ -214,13 +221,6 @@ class Installer: label="guest root filesystem", environment_name="UVM_ROOTFS_SHA256", ) - verify_file( - kernel, - self._settings.kernel_sha256, - "guest kernel", - "UVM_KERNEL_SHA256", - allow_unverified=self._settings.allow_unverified_downloads, - ) if self._settings.rootfs_url: verify_file( rootfs, -- 2.54.0 From bd92573158d03db8c6af7e8774d39b740b7f2a8a Mon Sep 17 00:00:00 2001 From: kstyagi Date: Tue, 22 Sep 2026 09:30:55 +0530 Subject: [PATCH 3/9] feat: enhance Ubuntu rootfs build process with improved logging and error handling --- DEVELOPER.md | 26 ++++++-- README.md | 11 +++- USAGE.md | 19 +++++- tests/test_install.py | 140 ++++++++++++++++++++++++++++++++++++------ uvm/install.py | 139 +++++++++++++++++++++++++++++++---------- 5 files changed, 273 insertions(+), 62 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index c3bfa11..f786681 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -211,7 +211,8 @@ Default runtime storage is `/var/lib/uvm`: │ └── jailer ├── images/ │ ├── vmlinux -│ └── ubuntu.ext4 +│ ├── ubuntu.ext4 +│ └── ubuntu-build.log ├── vms/ │ └── vm-/ │ ├── rootfs.ext4 @@ -713,10 +714,17 @@ Explicit kernel/rootfs URLs and trusted digests must match the host architecture uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs. The default rootfs builder installs `debootstrap` and `ubuntu-keyring`, uses -`--force-check-gpg`, and includes main/universe plus updates/security suites. +`--force-check-gpg`, and bootstraps only the base release's main component with +CA certificates and the Ubuntu keyring. Do not pass updates/security as +`--extra-suites`: debootstrap can download base archives but import newer +pre-dependency metadata, producing an empty `dpkg --install` invocation. +After bootstrap, apt sees main/universe plus updates/security, runs update +with `APT::Update::Error-Mode=any`, upgrades the base, and installs guest packages. amd64 uses `archive.ubuntu.com/ubuntu`; arm64 uses `ports.ubuntu.com/ubuntu-ports`. -Package scripts execute in private mount/PID namespaces with a temporary -`policy-rc.d` suppressing service startup. No mounts survive the build process. +Both phases execute in private mount/PID namespaces. The apt phase has private +dev/proc and read-only sys mounts, and recreates `policy-rc.d` to suppress service +startup after Ubuntu's debootstrap script removes it. Cleanup tolerates an +already-removed policy file. No mounts survive the build process. The staged tree retains `/var/lib/dpkg` and apt metadata, validates installed apt/sudo/sshd/systemd packages, enables SSH/networkd, preserves kernel-provided static networking, and supplies guest DNS. Template host keys and machine IDs @@ -729,6 +737,11 @@ permit reuse of a built template. Old demo images and unverified caches are rebuilt, and `install --force` always rebuilds. A failed staging build retains the old template. Installation holds the existing operation lock throughout; existing VM disks are never upgraded or reprovisioned by installation. +Stage command output and debootstrap's detailed log are atomically saved to +`images/ubuntu-build.log` (mode `0600`) on success or failure before deleting +staging files. Errors name that durable log; the last lines contain the latest +stage's diagnostics and failure summary. The recipe marker is bumped whenever +the build workflow changes so an earlier cached template cannot skip it. The host package list includes `nftables` and `conntrack` as well as the existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux @@ -924,7 +937,10 @@ growth, shrinking, geometry, and retained data/password-hash/host-key fixtures. UID/GID allocation, account/home metadata, existing accounts, and password hashes. `test_install.py` simulates package bootstrap and exercises real ext4 formatting and readback for retained dpkg metadata, sudo modes, template configuration, -staging failures, and checksum provenance. It does not run debootstrap or boot a VM. +staging failures, durable build diagnostics, and checksum provenance. The apt +shell script runs against stub mount/chroot commands to check argument handling +and stop-on-error behavior. Tests simulate Ubuntu removing `policy-rc.d` after +bootstrap. They do not run debootstrap or boot a VM. Lifecycle resize tests use fake processes/networking to verify ordering, single-lock ownership, identity preservation, pending intent, and failure recovery. diff --git a/README.md b/README.md index 3c9499a..a258277 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,10 @@ sudo env \ ``` The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures, -and native packages. UVM installs the builder prerequisites and records the -built ext4 image's SHA-256 in the integrity manifest. Building requires root +and native packages. It bootstraps the base release first, then uses apt to +apply updates/security fixes and install the guest packages. UVM installs the +builder prerequisites and records the built ext4 image's SHA-256 in the +integrity manifest. Building requires root with mount/PID namespace support and internet access to Ubuntu mirrors. Use `--force` to refresh downloads and rebuild the Ubuntu template: @@ -95,6 +97,11 @@ Existing VM disks retain their original OS and credentials; installation updates the template used for future VMs. Restart a running API server to load the updated provisioner before creating VMs through the dashboard. +If the build fails, its detailed bootstrap/package output is retained in +`/var/lib/uvm/images/ubuntu-build.log` (mode `0600`). Correct the build failure +and rerun installation before creating a custom-user VM; a failed build leaves +the previous template installed. + For local experimentation only, the old unverified quick-start behavior is available through an explicit opt-out: diff --git a/USAGE.md b/USAGE.md index f79bc77..c4ae9ee 100644 --- a/USAGE.md +++ b/USAGE.md @@ -175,9 +175,10 @@ Installation prints the Firecracker, kernel, and rootfs paths under `/var/lib/uvm/integrity.json`. The default build installs `debootstrap` and `ubuntu-keyring` on the host, -checks Ubuntu archive signatures, and builds inside private mount/PID -namespaces. It requires native root execution with namespace support. Leave -`UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt +checks Ubuntu archive signatures, bootstraps the base release, then uses apt +to apply updates and install the guest packages. It builds inside private +mount/PID namespaces and requires native root execution with namespace support. +Leave `UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt ext4 image, supply both its URL and SHA-256 instead. ### Scenario: Refresh Firecracker or Guest Assets @@ -227,6 +228,18 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. | | `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. | | `unshare: Operation not permitted` | Run the builder as root on a host permitting private mount/PID namespaces. | +| `Ubuntu rootfs build failed` | Read `images/ubuntu-build.log` under `UVM_BASE`, fix the reported bootstrap/apt error, and rerun installation. | + +The latest image-build log is retained after temporary build directories are +cleaned up, including detailed debootstrap and apt output: + +```sh +sudo tail -n 100 /var/lib/uvm/images/ubuntu-build.log +``` + +A failed build leaves the previous rootfs template installed. If creation then +reports missing sudo, complete the image installation successfully before +retrying creation. The username itself does not cause that error. ## 5. Create VMs diff --git a/tests/test_install.py b/tests/test_install.py index 018e501..b774a9e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2,8 +2,10 @@ from __future__ import annotations import hashlib import io +import json import os import shutil +import sys import tarfile import tempfile import unittest @@ -13,9 +15,9 @@ from types import SimpleNamespace from unittest.mock import Mock, patch from uvm.config import Settings -from uvm.errors import CommandError, UvmError +from uvm.errors import UvmError from uvm.images import ImageStore -from uvm.install import Installer, _ROOTFS_RECIPE +from uvm.install import Installer, _GUEST_APT_SCRIPT, _ROOTFS_RECIPE from uvm.integrity import load_manifest, write_manifest from uvm.state import StateStore from uvm.system import CommandResult, CommandRunner @@ -154,7 +156,7 @@ class InstallerArchitectureTests(unittest.TestCase): def test_only_verified_current_recipe_images_are_reused(self) -> None: for verified, marker, force in ( (True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False), - (True, "", False), (True, _ROOTFS_RECIPE, True), + (True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True), ): with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary: settings = Settings(base=Path(temporary)) @@ -184,36 +186,109 @@ class UbuntuBuildRunner: if "debootstrap" in args: root = Path(args[-2]) if self.failure == "bootstrap": - raise CommandError("bootstrap interrupted") + self.write_files(root, {"debootstrap/debootstrap.log": "dpkg: --install needs at least one package archive file argument\n"}) + return CommandResult(args, 1, stderr="W: Failure while unpacking the base system\n") + self.write_files(root, { + "etc/passwd": "root:x:0:0::/root:/bin/bash\n", + "etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n", + "etc/apt/sources.list": "deb https://ports.ubuntu.com/ubuntu-ports jammy main\n", + "usr/bin/apt-get": "apt binary", + "var/lib/dpkg/status": "Package: apt\nStatus: install ok installed\nVersion: 1.0\n", + "var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n", + "var/log/bootstrap.log": "Base system installed successfully.\n", + }) + for relative in ("dev", "proc", "sys", "usr/sbin"): + (root / relative).mkdir(parents=True, exist_ok=True) + (root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf") + # Ubuntu's real script removes this, even if it existed before bootstrap. + (root / "usr/sbin/policy-rc.d").unlink(missing_ok=True) + return CommandResult(args, 0, stdout="I: Base system installed successfully.\n") + if "uvm-guest-packages" in args: + root = Path(args[args.index("uvm-guest-packages") + 1]) + if (root / "usr/sbin/policy-rc.d").read_text() != "#!/bin/sh\nexit 101\n": + raise AssertionError("guest services must be disabled during apt provisioning") + sources = (root / "etc/apt/sources.list").read_text() + if "jammy-updates main universe" not in sources or "jammy-security main universe" not in sources: + raise AssertionError("apt must see updates and security before installing guest packages") + if self.failure == "packages": + return CommandResult(args, 100, stdout="Reading package lists...\n", stderr="E: unmet guest dependencies\n") status = "\n\n".join( f"Package: {package}\nStatus: install ok installed\nVersion: 1.0" for package in ("apt", "sudo", "openssh-server", "systemd-sysv") ) + "\n" files = { - "etc/passwd": "root:x:0:0::/root:/bin/bash\n", - "etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n", "etc/sudoers": "root ALL=(ALL:ALL) ALL\n", "etc/ssh/sshd_config": "UsePAM yes\n", "etc/ssh/ssh_host_rsa_key": "template host key", - "usr/bin/sudo": "sudo binary", "usr/bin/apt-get": "apt binary", + "usr/bin/sudo": "sudo binary", "usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary", "var/lib/dpkg/status": status, - "var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n", "var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n", } if self.failure == "metadata": files["var/lib/dpkg/status"] = "" - for relative, contents in files.items(): - path = root / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") + self.write_files(root, files) (root / "usr/bin/sudo").chmod(0o4755) - (root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf") - return CommandResult(args, 0) + return CommandResult(args, 0, stdout="Configured guest packages.\n") if args[0] == "systemctl": return CommandResult(args, 0) return self.runner.run(command, **options) + @staticmethod + def write_files(root: Path, files: dict[str, str]) -> None: + for relative, contents in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + + +class UbuntuAptStageTests(unittest.TestCase): + def test_apt_script_preserves_arguments_and_stops_after_an_update_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + root = base / "guest with spaces" + policy = root / "usr/sbin/policy-rc.d" + policy.parent.mkdir(parents=True) + policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8") + tools = base / "tools" + tools.mkdir() + for name in ("mount", "chroot"): + tool = tools / name + tool.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\nfrom pathlib import Path\n" + "name = Path(sys.argv[0]).name\n" + "print(json.dumps([name, *sys.argv[1:]]))\n" + "if name == 'chroot':\n" + " assert (Path(sys.argv[1]) / 'usr/sbin/policy-rc.d').is_file()\n" + " if os.environ.get('FAIL_UPDATE') == '1' and sys.argv[-1] == 'update':\n" + " sys.exit(100)\n", + encoding="utf-8", + ) + tool.chmod(0o755) + runner = CommandRunner(emit=None) + for fail in (False, True): + with self.subTest(fail=fail): + result = runner.run(( + "env", f"PATH={tools}", f"FAIL_UPDATE={int(fail)}", + "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT, + "uvm-guest-packages", root, "sudo", "openssh-server", + ), check=False, capture=True) + events = [json.loads(line) for line in result.stdout.splitlines()] + mounts = [event for event in events if event[0] == "mount"] + self.assertEqual([event[-1] for event in mounts], [ + str(root / "dev"), str(root / "dev"), str(root / "proc"), str(root / "sys"), + ]) + calls = [event for event in events if event[0] == "chroot"] + self.assertEqual(result.returncode, 100 if fail else 0) + self.assertEqual(len(calls), 1 if fail else 3) + self.assertEqual(calls[0][-3:], ["-o", "APT::Update::Error-Mode=any", "update"]) + for call in calls: + self.assertEqual(call[1:6], [str(root), "/usr/bin/env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get"]) + if not fail: + self.assertEqual(calls[1][-1], "dist-upgrade") + self.assertEqual(calls[2][-3:], ["install", "sudo", "openssh-server"]) + @unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")), "e2fsprogs is required for offline Ubuntu image checks") @@ -233,11 +308,23 @@ class UbuntuRootfsBuildTests(unittest.TestCase): self.assertIn(f"--arch={deb_arch}", bootstrap) self.assertEqual(bootstrap[-1], mirror) self.assertIn("--force-check-gpg", bootstrap) - self.assertIn("--extra-suites=jammy-updates,jammy-security", bootstrap) + self.assertIn("--components=main", bootstrap) + self.assertIn("--include=ca-certificates,ubuntu-keyring", bootstrap) + self.assertFalse(any(part.startswith("--extra-suites") for part in bootstrap)) self.assertIn("--mount-proc", bootstrap) self.assertIn("--kill-child", bootstrap) + packages = next(call for call in runner.calls if "uvm-guest-packages" in call) + self.assertLess(runner.calls.index(bootstrap), runner.calls.index(packages)) + self.assertIn("sudo", packages) + self.assertIn("openssh-server", packages) + self.assertIn("--mount-proc", packages) + self.assertIn("--kill-child", packages) self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600) - self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image]) + log = settings.images_dir / "ubuntu-build.log" + self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log}) + self.assertEqual(log.stat().st_mode & 0o777, 0o600) + self.assertIn("Base system installed successfully.", log.read_text()) + self.assertIn("Configured guest packages.", log.read_text()) def guest(command): return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True) @@ -252,16 +339,29 @@ class UbuntuRootfsBuildTests(unittest.TestCase): for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"): self.assertIn("File not found", guest(f"stat {path}").stderr) - def test_failed_bootstrap_or_missing_package_metadata_preserves_the_old_template(self) -> None: - for failure in ("bootstrap", "metadata"): + def test_failed_build_preserves_the_old_template_and_retains_diagnostics(self) -> None: + for failure in ("bootstrap", "packages", "metadata"): with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary: settings = Settings(base=Path(temporary)) settings.images_dir.mkdir() settings.rootfs_image.write_bytes(b"old template") runner = UbuntuBuildRunner(failure=failure) installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type] - with self.assertRaises(UvmError): + log = settings.images_dir / "ubuntu-build.log" + with self.assertRaisesRegex(UvmError, "details saved to .*ubuntu-build.log"): installer._build_ubuntu_rootfs(settings.rootfs_image) self.assertEqual(settings.rootfs_image.read_bytes(), b"old template") - self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image]) + self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log}) + self.assertEqual(log.stat().st_mode & 0o777, 0o600) + details = log.read_text() + if failure == "bootstrap": + self.assertIn("--install needs at least one package archive", details) + self.assertIn("Failure while unpacking", details) + self.assertFalse(any("uvm-guest-packages" in call for call in runner.calls)) + elif failure == "packages": + self.assertIn("Reading package lists...", details) + self.assertIn("E: unmet guest dependencies", details) + self.assertLess(details.index("Base system installed successfully."), details.index("E: unmet guest dependencies")) + else: + self.assertIn("incomplete apt/dpkg package metadata", details) self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls)) diff --git a/uvm/install.py b/uvm/install.py index ded2449..a1b9fda 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shlex import shutil import tarfile import tempfile @@ -20,7 +21,7 @@ from .system import CommandRunner, check_kvm, ensure_data_directories, require_r _UBUNTU_RELEASE = "jammy" -_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v1\n" +_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v2\n" _GUEST_PACKAGES = ( "ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev", "dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", @@ -30,6 +31,24 @@ _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" ) +_ROOTFS_NAMESPACE = ( + "unshare", "--mount", "--propagation", "private", "--pid", "--fork", + "--kill-child", "--mount-proc", +) +_GUEST_APT_SCRIPT = """\ +root=$1 +shift +mount --rbind /dev "$root/dev" +mount --make-rslave "$root/dev" +mount -t proc proc "$root/proc" +mount -t sysfs -o ro sysfs "$root/sys" +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\ + apt-get -o APT::Update::Error-Mode=any update +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\ + apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\ + apt-get -y --no-install-recommends install "$@" +""" class Installer: @@ -258,33 +277,36 @@ class Installer: 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) + build_log = temporary / "build.log" + try: + # Bootstrap one consistent release; apt resolves updated package versions later. + self._run_rootfs_stage("Ubuntu bootstrap", ( + *_ROOTFS_NAMESPACE, "env", "DEBIAN_FRONTEND=noninteractive", + "debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr", + "--components=main", "--include=ca-certificates,ubuntu-keyring", + "--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg", + _UBUNTU_RELEASE, root, mirror, + ), build_log) + self._install_ubuntu_packages(root, mirror, build_log) + self._configure_ubuntu_rootfs(root) + 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) + disk = temporary / "rootfs.ext4" + with disk.open("wb") as image: + image.truncate(2 * 1024 * 1024 * 1024) + disk.chmod(0o600) + self._run_rootfs_stage("Ubuntu ext4 formatting", ( + "mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES, + "-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk, + ), build_log) + self._run_rootfs_stage("Ubuntu filesystem check", ("e2fsck", "-f", "-n", disk), build_log) + except (UvmError, OSError, UnicodeError) as error: + try: + log = self._save_rootfs_build_log(destination, root, build_log, error=error) + except OSError as log_error: + raise UvmError(f"Ubuntu rootfs build failed: {error}; could not save diagnostics: {log_error}") from error + raise UvmError(f"Ubuntu rootfs build failed: {error}; details saved to {log}") from error + self._save_rootfs_build_log(destination, root, build_log) with disk.open("rb") as image: os.fsync(image.fileno()) os.replace(disk, destination) @@ -296,12 +318,65 @@ class Installer: 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: + def _install_ubuntu_packages(self, root: Path, mirror: str, build_log: Path) -> None: + sources = root / "etc/apt/sources.list" + sources.write_text("".join( + f"deb {mirror} {suite} main universe\n" + for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security") + ), encoding="utf-8") + sources.chmod(0o644) + # Ubuntu's debootstrap script removes its own policy-rc.d on success. + policy = root / "usr/sbin/policy-rc.d" + policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8") + policy.chmod(0o755) + try: + self._run_rootfs_stage("Ubuntu guest package installation", ( + *_ROOTFS_NAMESPACE, "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT, + "uvm-guest-packages", root, *_GUEST_PACKAGES, + ), build_log) + finally: + policy.unlink(missing_ok=True) + + def _run_rootfs_stage(self, stage: str, command: tuple[str | Path, ...], log: Path) -> None: + if self._emit is not None: + self._emit(stage) + with log.open("a", encoding="utf-8") as output: + output.write(f"\n=== {stage} ===\n+ {shlex.join(map(str, command))}\n") + result = self._runner.run(command, check=False, capture=True) + output.write(result.stdout) + output.write(result.stderr) + if result.returncode != 0: + raise UvmError(f"{stage} failed (exit {result.returncode})") + + @staticmethod + def _save_rootfs_build_log(destination: Path, root: Path, build_log: Path, *, error: Exception | None = None) -> Path: + log = destination.with_name("ubuntu-build.log") + descriptor, name = tempfile.mkstemp(prefix=".ubuntu-log-", dir=destination.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "wb") as output: + # Completed bootstrap details precede apt output; failed bootstrap details follow it. + for source in (root / "var/log/bootstrap.log", build_log, root / "debootstrap/debootstrap.log"): + if source.is_file(): + output.write(f"\n=== {source.name} ===\n".encode("utf-8")) + with source.open("rb") as content: + shutil.copyfileobj(content, output) + if error is not None: + output.write(f"\nUbuntu rootfs build failed: {error}\n".encode("utf-8", errors="replace")) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, log) + directory = os.open(log.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + temporary.unlink(missing_ok=True) + return log + + def _configure_ubuntu_rootfs(self, root: Path) -> 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", -- 2.54.0 From 534a78f2a947819477324a7d8420f213456f49f0 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 23 Sep 2026 06:23:36 +0530 Subject: [PATCH 4/9] feat: enhance network management and lifecycle handling with improved egress setup and validation --- tests/integration/test_private_ports.py | 78 ++++++++++++-- tests/test_lifecycle_commands.py | 27 ++++- tests/test_network.py | 114 ++++++++++++++------- uvm/lifecycle.py | 8 +- uvm/network.py | 131 ++++++------------------ 5 files changed, 206 insertions(+), 152 deletions(-) diff --git a/tests/integration/test_private_ports.py b/tests/integration/test_private_ports.py index 263aa1c..5239397 100644 --- a/tests/integration/test_private_ports.py +++ b/tests/integration/test_private_ports.py @@ -2,16 +2,18 @@ Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v -Needs Linux, nft, ip, bridge, conntrack, sysctl and permission to create network +Needs Linux, nft, iptables, ip, bridge, conntrack, sysctl and permission to create network namespaces (root, or unprivileged user namespaces). No sudo, installation, /run/netns mounts, host firewall changes or br_netfilter sysctls are used. -Bridge-connected veths stand in for guest TAPs. A third, routed namespace tests -guest egress without NAT. Ordinary test runs skip without executing commands. +Bridge-connected veths stand in for guest TAPs. A third namespace with two +uplinks tests NAT, offline startup, reconnects and host route changes without +using the real internet. Ordinary test runs skip without executing commands. """ from __future__ import annotations import contextlib +import errno import json import os import re @@ -62,6 +64,13 @@ class SocketEndpoint: except TimeoutError: sock.close() return False + except OSError as error: + sock.close() + if operation == "connect" and error.errno in { + errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN, errno.ECONNREFUSED, + }: + return False + raise except BaseException: sock.close() raise @@ -206,6 +215,7 @@ def _sandbox(parent_fd): from uvm.domain import PortRule, VmRecord from uvm.errors import UvmError from uvm.firewall import FirewallManager + from uvm.network import NetworkManager from uvm.system import CommandRunner runner = CommandRunner(emit=None) @@ -220,11 +230,12 @@ def _sandbox(parent_fd): manager = FirewallManager(settings, runner) manager.check_available() run("ip", "link", "set", "lo", "up") - run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0") - run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge) - run("ip", "link", "set", settings.bridge, "up") - # This IPv4 sysctl belongs only to the freshly verified network namespace. - run("sysctl", "-w", "net.ipv4.ip_forward=1") + # All changes are inside the freshly verified namespace. A pre-existing + # terminal drop catches regressions that append ineffective forward rules. + run("iptables", "-P", "FORWARD", "DROP") + run("iptables", "-A", "FORWARD", "-j", "DROP") + assert not run("ip", "route", "show", "default").strip() + NetworkManager(settings, runner).ensure_bridge() metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway), socket="", config="", log="", network=str(settings.network), bridge=settings.bridge) @@ -263,6 +274,16 @@ def _sandbox(parent_fd): run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000") endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)]) + run("ip", "link", "add", "fw-up2", "type", "veth", "peer", "name", "fw-end2") + run("ip", "link", "set", "fw-end2", "netns", str(outside.process.pid)) + run("ip", "addr", "add", "172.30.74.1/24", "dev", "fw-up2") + run("ip", "link", "set", "fw-up2", "up") + outside.request("run", command=["ip", "link", "set", "fw-end2", "name", "eth1"]) + outside.request("run", command=["ip", "addr", "add", "172.30.74.2/24", "dev", "eth1"]) + outside.request("run", command=["ip", "link", "set", "eth1", "up"]) + internet_ip = "203.0.113.73" + outside.request("run", command=["ip", "addr", "add", f"{internet_ip}/32", "dev", "lo"]) + manager.apply(vm) manager.apply(peer_vm) for tap in (vm.tap, peer_vm.tap): @@ -328,6 +349,7 @@ def _sandbox(parent_fd): guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query") query = outside.request("udp_recv", name="dns") assert query[0] == "dns-query" + assert query[1][0] == "172.30.73.1", "guest egress must use the host uplink's address" outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply") assert guest.request("udp_recv", name="dns")[0] == "dns-reply" guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054) @@ -338,6 +360,44 @@ def _sandbox(parent_fd): assert endpoint.request("ping", ip=target), (endpoint, target) print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True) + # No UVM reconfiguration after this point: the kernel must follow the + # host routes for both TCP and UDP/53, including after offline creation. + outside.request("listen", name="internet", ip=internet_ip, port=9443) + outside.request("udp_bind", name="internet-dns", ip=internet_ip, port=53) + + def offline(label): + assert not host.request("connect", name=f"host-{label}", ip=internet_ip, port=9443) + assert not guest.request("connect", name=f"guest-{label}", ip=internet_ip, port=9443) + assert guest.request("ping", ip=str(settings.gateway)), "local networking must still work offline" + + def online(label, source): + for endpoint, role in ((host, "host"), (guest, "guest")): + name = f"{role}-{label}" + assert endpoint.request("connect", name=name, ip=internet_ip, port=9443) + assert outside.request("accept", listener="internet", name=name) + transfer(endpoint, outside, name, "request") + transfer(outside, endpoint, name, "response") + guest.request("udp_bind", name=label, ip=vm.guest_ip, port=0) + guest.request("udp_send", name=label, ip=internet_ip, port=53, data=label) + query = outside.request("udp_recv", name="internet-dns") + assert query == [label, [source, query[1][1]]], query + outside.request("udp_send", name="internet-dns", ip=source, port=query[1][1], data="dns-reply") + assert guest.request("udp_recv", name=label)[0] == "dns-reply" + + offline("initially-offline") + run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up") + online("first-uplink", "172.30.73.1") + run("ip", "route", "replace", "default", "via", "172.30.74.2", "dev", "fw-up2") + online("changed-uplink", "172.30.74.1") + run("ip", "route", "del", "default") + run("ip", "link", "set", "fw-up", "down") + run("ip", "link", "set", "fw-up2", "down") + offline("disconnected") + run("ip", "link", "set", "fw-up", "up") + run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up") + online("reconnected", "172.30.73.1") + print("PASS: host/guest NAT egress follows offline/online, uplink changes and reconnects", flush=True) + # Spoofed source MACs must not reach FDB learning, including the local # link-local path which can skip bridge prerouting entirely. spoof = bytes.fromhex("02aabbccddee") @@ -393,7 +453,7 @@ class PrivatePortNamespaceTests(unittest.TestCase): probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5) if probe.returncode: self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}") - missing = [name for name in ("ip", "bridge", "nft", "conntrack", "sysctl") if shutil.which(name) is None] + missing = [name for name in ("ip", "bridge", "nft", "iptables", "conntrack", "sysctl") if shutil.which(name) is None] if missing: self.skipTest(f"integration tools unavailable: {', '.join(missing)}") with open("/proc/self/ns/net", "rb") as parent: diff --git a/tests/test_lifecycle_commands.py b/tests/test_lifecycle_commands.py index 8ef7c55..6c936dd 100644 --- a/tests/test_lifecycle_commands.py +++ b/tests/test_lifecycle_commands.py @@ -385,7 +385,7 @@ class LifecyclePortTests(LifecycleTestCase): self.assertEqual(self.settings.state_path.read_bytes(), before) self.assertEqual(self.host.events, []) - def test_start_of_running_vm_repairs_policy_without_reboot_or_reprovision(self) -> None: + def test_start_of_running_vm_repairs_policy_and_egress_without_reboot_or_reprovision(self) -> None: vm = self.create_vm() del self.network.firewall.tables[vm.id] self.host.events.clear() @@ -395,9 +395,34 @@ class LifecyclePortTests(LifecycleTestCase): self.assertNotIn("process.start", self.host.actions(vm.id)) self.assertNotIn("process.terminate", self.host.actions(vm.id)) self.assertNotIn("client.configure", self.host.actions(vm.id)) + self.assertIn(("network.ensure_bridge", None), self.host.events) + self.assertLess(self.host.events.index(("firewall.apply", vm.id)), + self.host.events.index(("network.ensure_bridge", None))) self.assertEqual(self.provision.call_count, 1) self.assertEqual(self.copy_disk.call_count, 1) + def test_live_start_rejects_changed_network_settings_before_repairing_network(self) -> None: + vm = self.create_vm() + self.service._settings = replace(self.settings, bridge="another-bridge") + before = self.settings.state_path.read_bytes() + self.host.events.clear() + with self.assertRaisesRegex(ConflictError, "retained network"): + self.service.start(vm.id) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertNotIn(("network.ensure_bridge", None), self.host.events) + self.assertNotIn("firewall.apply", self.host.actions(vm.id)) + + def test_live_start_can_retry_an_egress_setup_failure_with_the_same_process(self) -> None: + vm = self.create_vm() + self.host.failures["network.ensure_bridge", None] = UvmError("egress setup failed") + with self.assertRaisesRegex(UvmError, "egress setup failed"): + self.service.start(vm.id) + self.assertEqual(self.process.observe(self.store.get(vm.id)), "alive") + self.assertEqual(self.network.firewall.status(vm), "enforced") + self.assertEqual(self.service.start(vm.id).pid, vm.pid) + self.assertEqual(len(self.process.started), 1) + self.assertEqual(self.provision.call_count, 1) + class LifecycleBulkTests(LifecycleTestCase): def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None: diff --git a/tests/test_network.py b/tests/test_network.py index fa583f2..7f4c931 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -5,6 +5,7 @@ import unittest from copy import deepcopy from dataclasses import replace from ipaddress import IPv4Address, IPv4Network +from unittest.mock import patch from uvm.config import Settings from uvm.domain import PortRule, VmRecord @@ -22,6 +23,10 @@ class FakeRunner: self.links: dict[str, dict] = {} self.inventory: str | None = None self.failures: dict[tuple[str, ...], BaseException] = {} + self.default_routes = "default via 192.0.2.1 dev eth0\n" + self.rules: dict[tuple[str, str], list[tuple[str, ...]]] = { + ("nat", "POSTROUTING"): [], ("filter", "FORWARD"): [], + } def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult: del capture, timeout @@ -68,11 +73,21 @@ class FakeRunner: elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"): pass elif args[0] == "iptables": - return CommandResult(args=args, returncode=1 if "-C" in args else 0) + if args[1:4] != ("--wait", "5", "-t"): + raise AssertionError(f"unexpected iptables options: {args}") + table, operation, chain, *rule = args[4:] + rules = self.rules[table, chain] + if operation == "-C": + return CommandResult(args=args, returncode=0 if tuple(rule) in rules else 1) + if operation == "-I": + position, *rule = rule + rules.insert(int(position) - 1, tuple(rule)) + else: + raise AssertionError(f"unexpected iptables mutation: {args}") elif args != ("ip", "route", "show", "default"): raise AssertionError(f"unexpected host command: {args}") if args == ("ip", "route", "show", "default"): - return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n") + return CommandResult(args=args, returncode=0, stdout=self.default_routes) return CommandResult(args=args, returncode=0) @@ -127,44 +142,61 @@ class NetworkManagerTests(unittest.TestCase): ("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"), self.runner.calls, ) - self.assertIn( - ( - "iptables", - "-A", - "FORWARD", - "-i", - "uvm0", - "-o", - "eth0", - "-s", - "10.42.0.0/24", - "-j", - "ACCEPT", - ), - self.runner.calls, - ) - checks = [ - call - for call in self.runner.calls - if call[:5] == ("iptables", "-t", "nat", "-C", "POSTROUTING") + self.assertIn(("sysctl", "-w", "net.ipv4.ip_forward=1"), self.runner.calls) + nat, = self.runner.rules["nat", "POSTROUTING"] + self.assertEqual(nat[:6], ("-s", "10.42.0.0/24", "!", "-o", "uvm0", "-m")) + self.assertEqual(nat[-2:], ("-j", "MASQUERADE")) + inbound, outbound = self.runner.rules["filter", "FORWARD"] + self.assertEqual(outbound[:4], ("-i", "uvm0", "-s", "10.42.0.0/24")) + self.assertEqual(inbound[:8], ( + "-o", "uvm0", "-d", "10.42.0.0/24", "-m", "conntrack", + "--ctstate", "ESTABLISHED,RELATED", + )) + self.assertEqual(inbound[-2:], ("-j", "ACCEPT")) + self.assertEqual(outbound[-2:], ("-j", "ACCEPT")) + + def test_egress_is_installed_offline_and_survives_uplink_changes_without_extra_rules(self) -> None: + self.runner.default_routes = "" + self.network.ensure_bridge() + offline_rules = deepcopy(self.runner.rules) + self.assertEqual([len(rules) for rules in offline_rules.values()], [1, 2]) + + for route in ( + "default via 192.0.2.1 dev eth0\n", + "default via 198.51.100.1 dev wlan0\n", + "default dev tun0\ndefault via 198.51.100.1 dev wlan0 metric 600\n", + "", + ): + with self.subTest(route=route): + self.runner.default_routes = route + self.runner.calls.clear() + self.network.ensure_bridge() + self.assertEqual(self.runner.rules, offline_rules) + self.assertFalse(any("-I" in call for call in self.runner.calls)) + self.assertFalse(any("eth0" in rule or "wlan0" in rule or "tun0" in rule + for rules in offline_rules.values() for rule in rules)) + + def test_egress_exceptions_precede_existing_drops_and_preserve_foreign_and_legacy_rules(self) -> None: + existing = [ + ("-j", "HOST-FIREWALL"), ("-j", "DROP"), + ("-i", "uvm0", "-o", "eth0", "-s", "10.42.0.0/24", "-j", "ACCEPT"), ] - self.assertEqual(len(checks), 1) - self.assertIn( - ( - "iptables", - "-t", - "nat", - "-A", - "POSTROUTING", - "-s", - "10.42.0.0/24", - "-o", - "eth0", - "-j", - "MASQUERADE", - ), - self.runner.calls, - ) + self.runner.rules["filter", "FORWARD"] = list(existing) + self.network.ensure_bridge() + rules = list(self.runner.rules["filter", "FORWARD"]) + self.assertEqual(rules[2:], existing) + self.assertTrue(all("uvm-egress" in rule and rule[-1] == "ACCEPT" for rule in rules[:2])) + self.network.ensure_bridge() + self.assertEqual(self.runner.rules["filter", "FORWARD"], rules) + + def test_iptables_inspection_errors_are_not_treated_as_missing_rules(self) -> None: + check = ("iptables", "--wait", "5", "-t", "nat", "-C", "POSTROUTING", + "-s", "10.42.0.0/24", "!", "-o", "uvm0", + "-m", "comment", "--comment", "uvm-egress", "-j", "MASQUERADE") + with patch.object(self.runner, "run", return_value=CommandResult(check, 4, stderr="permission denied")) as run: + with self.assertRaisesRegex(CommandError, "could not inspect.*permission denied"): + self.network._ensure_masquerade() + self.assertEqual(run.call_count, 1) def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None: settings = Settings( @@ -178,6 +210,10 @@ class NetworkManagerTests(unittest.TestCase): ("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"), runner.calls, ) + for rules in runner.rules.values(): + for rule in rules: + self.assertIn("10.50.0.0/16", rule) + self.assertNotIn("10.42.0.0/24", rule) def test_refuses_to_adopt_an_existing_tap_device(self) -> None: for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"): diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index b1231a4..6fc20da 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -177,7 +177,10 @@ class LifecycleService: raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting") 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) + self._validate_retained_network(vm) + vm = self._sync_ports_locked(vm) + self._network.ensure_bridge() + return vm self._validate_resume(vm) if kernel is None: self._check_boot() @@ -276,6 +279,9 @@ class LifecycleService: disk = Path(vm.disk) if not vm.disk or 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}") + self._validate_retained_network(vm) + + def _validate_retained_network(self, vm: VmRecord) -> None: 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}): diff --git a/uvm/network.py b/uvm/network.py index 3d779d5..22e07c8 100644 --- a/uvm/network.py +++ b/uvm/network.py @@ -9,7 +9,7 @@ from ipaddress import IPv4Address from .config import Settings from .domain import VmRecord -from .errors import ConflictError, UvmError, ValidationError +from .errors import CommandError, ConflictError, UvmError, ValidationError from .firewall import FirewallManager from .system import CommandRunner @@ -178,111 +178,38 @@ class NetworkManager: raise UvmError(f"could not delete TAP device {name}{suffix}") def _ensure_masquerade(self) -> None: - route = self._runner.run(("ip", "route", "show", "default"), capture=True) - match = re.search(r"\bdev\s+(\S+)", route.stdout) - if match is None: - return - uplink = match.group(1) - rule = ( - "iptables", - "-t", - "nat", - "-C", - "POSTROUTING", - "-s", - str(self._settings.network), - "-o", - uplink, - "-j", - "MASQUERADE", - ) - present = self._runner.run(rule, check=False) - if present.returncode != 0: - self._runner.run( - ( - "iptables", - "-t", - "nat", - "-A", - "POSTROUTING", - "-s", - str(self._settings.network), - "-o", - uplink, - "-j", - "MASQUERADE", - ) - ) + # Match the guest network, not a snapshot of the host's default uplink. + # Routing selects the current interface for each destination, even after + # an offline boot, a reconnect, or a switch between Wi-Fi/Ethernet/VPN. + bridge = self._settings.bridge + network = str(self._settings.network) + owner = ("-m", "comment", "--comment", "uvm-egress") self._ensure_iptables_rule( - ( - "iptables", - "-C", - "FORWARD", - "-i", - self._settings.bridge, - "-o", - uplink, - "-s", - str(self._settings.network), - "-j", - "ACCEPT", - ), - ( - "iptables", - "-A", - "FORWARD", - "-i", - self._settings.bridge, - "-o", - uplink, - "-s", - str(self._settings.network), - "-j", - "ACCEPT", - ), + "nat", "POSTROUTING", + ("-s", network, "!", "-o", bridge, *owner, "-j", "MASQUERADE"), + ) + # Native bridge policy still checks guest identity and private ports. + # This also permits peer traffic when br_netfilter is already enabled. + self._ensure_iptables_rule( + "filter", "FORWARD", + ("-i", bridge, "-s", network, *owner, "-j", "ACCEPT"), ) self._ensure_iptables_rule( - ( - "iptables", - "-C", - "FORWARD", - "-i", - uplink, - "-o", - self._settings.bridge, - "-d", - str(self._settings.network), - "-m", - "conntrack", - "--ctstate", - "ESTABLISHED,RELATED", - "-j", - "ACCEPT", - ), - ( - "iptables", - "-A", - "FORWARD", - "-i", - uplink, - "-o", - self._settings.bridge, - "-d", - str(self._settings.network), - "-m", - "conntrack", - "--ctstate", - "ESTABLISHED,RELATED", - "-j", - "ACCEPT", - ), + "filter", "FORWARD", + ("-o", bridge, "-d", network, "-m", "conntrack", + "--ctstate", "ESTABLISHED,RELATED", *owner, "-j", "ACCEPT"), ) def _ensure_iptables_rule( - self, - check_rule: tuple[str, ...], - add_rule: tuple[str, ...], + self, table: str, chain: str, rule: tuple[str, ...], ) -> None: - present = self._runner.run(check_rule, check=False) - if present.returncode != 0: - self._runner.run(add_rule) + command = ("iptables", "--wait", "5", "-t", table) + present = self._runner.run((*command, "-C", chain, *rule), check=False, capture=True) + if present.returncode == 0: + return + if present.returncode != 1: + detail = present.stderr.strip() or present.stdout.strip() + raise CommandError(f"could not inspect UVM {table}/{chain} rule ({present.returncode}): {detail}") + # Scoped exceptions must precede terminal drops in the host's chains. + # Keep unrelated rules and the global forwarding policy intact. + self._runner.run((*command, "-I", chain, "1", *rule), capture=True) -- 2.54.0 From ed9c5306af518ec696eb1de4ee405415a3343a45 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 23 Sep 2026 06:23:49 +0530 Subject: [PATCH 5/9] feat: enhance networking capabilities with improved egress handling and automatic host sharing rules --- DEVELOPER.md | 56 +++++++++++++++++++++++++++++++++++----------------- README.md | 49 +++++++++++++++++++++++++++++++++------------ USAGE.md | 43 +++++++++++++++++++++++++++++++++------- 3 files changed, 110 insertions(+), 38 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index f786681..2c94b58 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -373,8 +373,10 @@ not needed. It never recopies the rootfs, reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes survive stop/start; guest RAM does not. -Start on a healthy running VM synchronizes policy without spawning another -VMM. An ambiguous process or interrupted live boot/stop is not adopted as a +Start on a healthy running VM validates its retained network, synchronizes +policy, then restores shared bridge/NAT/forwarding rules without spawning another +VMM. A shared-network setup failure retains the running VM for retry. An ambiguous +process or interrupted live boot/stop is not adopted as a successful start. Failed resume retains the private disk for retry. Restart performs stop and start under one operation lock, not two independent public calls with a race between them. @@ -605,7 +607,7 @@ Linux bridge: uvm0 (10.42.0.1/24 by default) | iptables forwarding + MASQUERADE | -host default uplink +host IPv4 route for the destination (current uplink) | internet ``` @@ -620,7 +622,7 @@ internet - Bridge creation or validation that an existing interface is actually a Linux bridge. - Gateway address setup and IPv4 forwarding. -- One egress NAT rule and forward/return rules for the default uplink. +- Egress NAT and forward/return rules scoped to the guest subnet/bridge. - TAP ownership aliases (`uvm:`), preparation while down, activation, deactivation, cleanup, and error reporting. @@ -629,6 +631,23 @@ it may belong to another VM. A create-time name collision is an error. Cleanup checks the TAP kind and ownership alias, with a narrow unaliased-TAP allowance for legacy records; it does not treat an arbitrary interface name as ownership. +Internet sharing is configured even with no host default route. Rules never +pin an uplink interface: Linux routing selects it, and MASQUERADE uses that +interface's address. Offline-to-online transitions, reconnects and route changes +therefore work without another UVM command or a monitoring daemon. Existing +connections may need to reconnect after the upstream address changes. Guest +DNS in the Ubuntu template uses the public resolvers documented in README. + +`uvm-egress`-commented iptables rules are checked before insertion, with a bounded +xtables-lock wait. Scoped exceptions are inserted at the start of the host chains +so pre-existing terminal drops cannot shadow them. Outbound forwarding requires +the UVM bridge and source subnet; routed replies require the destination bridge, +subnet and established/related conntrack state. Native bridge policy still checks +identity, service ports and permitted protocols. Outbound forwarding also covers +peer traffic if the host already uses `br_netfilter`, while MASQUERADE excludes +the UVM bridge to retain private peer source addresses. Global chain policies +and unrelated rules remain administrator-owned. + ### Native Bridge Policy The supported path is a **software Linux bridge** with STP disabled. Each @@ -685,16 +704,16 @@ including on conntrack-tools 1.4.8, where `--list` is not a supported option. ### Coexistence and Recovery -Existing `iptables` egress MASQUERADE/forwarding remains unchanged. The new -policy adds no DNAT, public listener, or host-port mapping and does not replace -the host firewall manager. Public connectivity, DNS, guest reverse proxies/TLS, +Shared `iptables` egress MASQUERADE/forwarding is separate from private bridge +policy. The policy adds no DNAT, public listener, or host-port mapping and does not replace +the host firewall manager. Public inbound connectivity, DNS publication, guest reverse proxies/TLS, and integration with UFW/firewalld/Docker/libvirt remain out of scope. An administrator's external routing/NAT can still affect reachability; do not infer source-CIDR restrictions or complete public-exposure protection from the use of private addresses. Operators must choose a non-conflicting topology. -If an external actor loses or changes UVM rules, recovery is explicit -`ports sync`, not a promised daemon repair. An `enforced` snapshot cannot +If an external actor loses or changes private policy, recovery is explicit +`ports sync`; `start` also restores shared networking for a running VM. An `enforced` snapshot cannot prevent later root-level changes or prove that other rules allow traffic. [FIREWALL.md](FIREWALL.md) retains the original proposal/backend evaluation; do not import its unimplemented topology checks into current documentation. @@ -950,17 +969,18 @@ The opt-in packet harness must run **only in disposable isolated user/network namespaces**. It verifies namespace separation before creating links or changing rules, and never falls back to the parent host namespace. It may require privileges (or enabled unprivileged user namespaces) plus `unshare`, -`ip`, `bridge`, `nft`, `conntrack`, and `sysctl`: +`ip`, `bridge`, `nft`, `iptables`, `conntrack`, and `sysctl`: ```sh UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v ``` -The harness uses bridge-connected veths in place of guest TAPs and routed -egress without NAT. It exercises host/peer service traffic, both-direction -close of existing streams while preserving conntrack/egress, IPv6/spoof -blocking, quarantine, and offline cleanup. It does not boot a microVM or -validate real TAP/NAT deployment. +The harness uses bridge-connected veths in place of guest TAPs and two simulated +uplinks. It exercises NAT with a pre-existing terminal FORWARD drop, offline +startup, uplink changes and reconnects for host/guest TCP and guest UDP/53. +It also checks host/peer service traffic, both-direction close of existing +streams while preserving conntrack/egress, IPv6/spoof blocking, quarantine, +and offline cleanup. It does not boot a microVM or validate a real TAP deployment. The packet test skipped in this workspace because namespace creation was denied (`Operation not permitted`). Document that permission limitation, not @@ -1011,7 +1031,7 @@ on an isolated Linux host: | Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access | | VM is `dead` in `list` | PID/start token in state, `/proc/`, VM log | | Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID | -| Guest has no egress | `ip route show default`, bridge address, TAP state, iptables rules | +| Guest has no egress | Host connectivity/routes, bridge/TAP state, `uvm-egress` iptables rules; `start` refreshes networking on a running VM | | Policy missing/drift/unknown | Actual nft JSON and ownership, STP/group mask, permissions; explicit sync after fixing cause | | Retained-disk start fails | `disk_ready`, safe private path, kernel/VMM integrity, process identity, retained network, pending operation | | API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files | @@ -1035,9 +1055,9 @@ The code is intentionally honest about current limits: - Guest health is inferred from VMM process state, not application readiness. - No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy. - No guest snapshots, extra disks, production image builder, or SSH-key injection. -- Per-VM private TCP ingress policy exists, but public connectivity, DNS, +- Per-VM private TCP ingress policy exists, but public inbound connectivity, DNS publication, guest reverse proxy/TLS, and external firewall integration remain out of scope. -- Existing egress `iptables` rules are incremental and unchanged; only the +- Shared egress `iptables` rules are incremental and idempotent; only the private bridge policy is atomically rendered per VM. - No autostart, background reconciliation, or guest-coordinated shutdown. - The JSON inventory is suitable for one host, not distributed coordination. diff --git a/README.md b/README.md index a258277..d223cc8 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ `uvm` is a small, local Firecracker microVM command-line manager for Ubuntu. It creates one Firecracker process per VM, attaches it to a host TAP device, and keeps a private disk and local registry for named VMs. The CLI supports -create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics, and -enforced private-IPv4 TCP port allowlists. +create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics, +host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists. This directory is a self-contained project. All UVM code, tests, packaging metadata, launcher scripts, and documentation live here. @@ -255,7 +255,7 @@ well as new connections. This is packet dropping, not a guaranteed TCP RST or immediate application disconnect. These rules govern access to the **guest private IP**. They do not start a -guest service or provide public connectivity, host-port mappings, DNS, +guest service or provide public inbound connectivity, host-port mappings, DNS publication, reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required ARP/ICMP traffic remain allowed; managed TAPs block IPv6. @@ -420,13 +420,33 @@ Bridge: uvm0 TAPs: uvm- ``` -It creates the bridge when absent, validates an existing bridge before using -it, enables IPv4 forwarding, adds an egress masquerade rule, and adds matching -forward rules for return traffic. Guest IPs, gateway, bridge name, and guest -boot netmask derive from the configured CIDR. +VMs share the host's IPv4 internet connection by default. When the host has +internet access, guests can initiate outbound connections through it. When +the host loses connectivity, guest internet access is lost too; the private +VM network remains usable. Reconnecting the host restores guest egress. -The existing `iptables` egress NAT/forwarding behavior is unchanged. Private -VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`, +UVM creates the bridge when absent, validates an existing bridge before using +it, enables IPv4 forwarding, and installs subnet-scoped `iptables` masquerade +and forward/return rules. These rules are installed even while the host is +offline and follow its current routing table, so switching uplinks does not +require restarting VMs. Existing connections may need application retries +after an outage or upstream address change. Guest IPs, gateway, bridge name, +and guest boot netmask derive from the configured CIDR. + +Forwarding exceptions are inserted ahead of existing host-chain rules and +allow guest-originated traffic and its replies. Masquerading excludes traffic +back onto the UVM bridge, preserving private peer addresses. + +To apply updated sharing rules to an existing running VM, use: + +```sh +sudo ./uvm.py start web +``` + +On a running VM, `start` refreshes its private policy and shared host networking +without rebooting it. New VM boots set up the same networking automatically. + +Private VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`, `172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible gateway, subnet, or bridge changes within the same registry. Private port enforcement uses separate per-VM native nftables `bridge` tables, not @@ -443,8 +463,9 @@ firewalld, Docker, or other external firewall managers remains deferred. Do not offload traffic around the software bridge hooks. There is no autostart service or daemon reconciliation. After externally lost -or changed UVM rules, inspect policy status and explicitly run `ports sync`. -Read-only commands do not repair rules; sync does not boot stopped VMs. +or changed private policy, inspect its status and explicitly run `ports sync`. +Use `start` to restore shared bridge/NAT/forwarding rules as well. Read-only +commands do not repair rules; port sync does not boot stopped VMs. Use one authoritative registry per bridge/subnet. Independent `UVM_BASE` directories do not coordinate IP or MAC allocation with each other. @@ -568,8 +589,10 @@ UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration The packet test was skipped in this workspace because namespace creation was denied (`Operation not permitted`). A skip is not packet-path or real-host -validation. The harness uses veths in place of TAPs and does not boot a real VM; -validate KVM, TAP, NAT, SSH, and guest connectivity on an isolated Linux host. +validation. The harness uses veths in place of TAPs and simulated upstream +networks to check NAT, offline startup, reconnects, and uplink changes alongside +private port policies. It does not boot a real VM; validate KVM, TAP, SSH, and +guest connectivity on an isolated Linux host. ## Current Boundaries diff --git a/USAGE.md b/USAGE.md index c4ae9ee..ca4a173 100644 --- a/USAGE.md +++ b/USAGE.md @@ -29,6 +29,12 @@ Bridge: uvm0 Guest pool: 10.42.0.2 through 10.42.0.254 ``` +Guest IPv4 internet access shares the host's connection automatically. Host +offline means guest internet is offline; reconnecting the host restores guest +egress. UVM installs sharing rules even when booting offline, and follows the +host's current routes when uplinks change. The private network remains usable +without internet. Applications may need to reconnect after a network interruption. + The default locations on the host are under `/var/lib/uvm`. Stop/start and restart reuse the private disk; delete removes it. UVM is a local CLI, not an autostart service or a daemon that continuously repairs VM or firewall state. @@ -1134,7 +1140,10 @@ Firecracker logs may include guest/application secrets; redact them before shari ## 15. Networking Troubleshooting -The existing `iptables` egress MASQUERADE and forwarding rules are unchanged. +UVM enables IPv4 forwarding and installs `uvm-egress`-commented iptables +MASQUERADE and forward/return rules. These match the private subnet and bridge +without fixing an uplink, so guest traffic follows the host's current routes. +Scoped forwarding exceptions are inserted ahead of existing host-chain rules. Private port filtering is a separate native nftables `bridge` policy, not DNAT. It must be installed and read back before the TAP is active. The bridge must have STP disabled and each TAP must have bridge-slave `group_fwd_mask` @@ -1162,11 +1171,30 @@ sudo iptables -t nat -S POSTROUTING sudo iptables -S FORWARD ``` +After upgrading UVM, apply the sharing rules to an already-running VM with: + +```sh +sudo ./uvm.py start web +``` + +`start` on a running VM refreshes its private policy and shared host networking +without a reboot. Use the updated CLI checkout, or restart the API server to +load the updated code before using its start endpoint. Subsequent host +disconnects, reconnects, and route changes require no UVM command. + +Check DNS and HTTPS from the guest: + +```sh +sudo ./uvm.py ssh web -- getent ahostsv4 example.com +sudo ./uvm.py ssh web -- curl -4 --connect-timeout 5 --max-time 15 -I https://example.com +``` + Common issues: | Symptom | First checks | |---|---| -| Guest cannot reach the internet | Bridge is up, TAP is up, host has default route, NAT and FORWARD rules exist. | +| Guest cannot reach the internet | Confirm the host can reach the same destination, bridge/TAP are up, and `uvm-egress` NAT/FORWARD rules exist; use `start VM` to refresh sharing. | +| IP connectivity works but DNS fails | Check guest `/etc/resolv.conf` and DNS reachability. The Ubuntu template uses `1.1.1.1` and `8.8.8.8`; host loopback resolvers such as `127.0.0.53` are not reachable as guest loopback addresses. | | Requested guest IP is rejected | Confirm it is inside the configured subnet and not used, gateway, network, or broadcast. | | Create says TAP already exists | Inspect the interface; do not delete it unless you know it is stale UVM state. | | Existing bridge is rejected | Verify it is a software Linux bridge with STP disabled. | @@ -1187,7 +1215,7 @@ Keep these constraints in mind when deciding whether UVM fits a scenario: - No quotas or cgroup CPU, memory, I/O, or process limits are applied. - Fractional CPU requests are advisory only. - No autostart, background lifecycle/firewall reconciliation, or guest-coordinated stop. -- No UDP/IPv6 service policies, public connectivity setup, host-port mapping, +- No UDP/IPv6 service policies, public inbound connectivity setup, host-port mapping, DNS publication, guest reverse proxy/TLS setup, DHCP, load balancing, snapshots, multi-network tenancy, or multi-host scheduling. - The API uses one shared bearer token, not individual users, roles, or tenants. @@ -1220,7 +1248,7 @@ python3 -B -m unittest discover -s tests -v The opt-in packet test runs **only in disposable isolated user/network namespaces**, which may require privileges. It needs `unshare`, `ip`, -`bridge`, `nft`, `conntrack`, and `sysctl`: +`bridge`, `nft`, `iptables`, `conntrack`, and `sysctl`: ```sh UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v @@ -1228,9 +1256,10 @@ UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration Namespace creation was denied in this workspace (`Operation not permitted`), so the packet test skipped. That is a permission limitation, not real-packet -or real-host validation. The harness uses veths as TAP stand-ins and routed -egress without NAT; it does not boot Firecracker or validate the host's real -TAP/NAT setup. Run actual VM/SSH/egress checks on an isolated Linux host before +or real-host validation. The harness uses veths as TAP stand-ins and simulated +upstreams to check NAT, offline startup, reconnects and uplink changes. It does +not boot Firecracker or validate the host's real TAP setup. Run actual +VM/SSH/egress checks on an isolated Linux host before relying on the deployment. ## 19. Get Help -- 2.54.0 From 0f72ac693acf2f8149f5533c627838e5fd293aa7 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 23 Sep 2026 06:25:35 +0530 Subject: [PATCH 6/9] fix: correct formatting and improve clarity in documentation and test descriptions --- DEVELOPER.md | 21 ++++++++++++--------- README.md | 4 ++-- USAGE.md | 3 +-- tests/integration/test_private_ports.py | 6 +++--- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 2c94b58..990b8a1 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -376,8 +376,8 @@ survive stop/start; guest RAM does not. Start on a healthy running VM validates its retained network, synchronizes policy, then restores shared bridge/NAT/forwarding rules without spawning another VMM. A shared-network setup failure retains the running VM for retry. An ambiguous -process or interrupted live boot/stop is not adopted as a -successful start. Failed resume retains the private disk for retry. Restart +process or interrupted live boot/stop is not adopted as a successful start. +Failed resume retains the private disk for retry. Restart performs stop and start under one operation lock, not two independent public calls with a race between them. @@ -705,16 +705,18 @@ including on conntrack-tools 1.4.8, where `--list` is not a supported option. ### Coexistence and Recovery Shared `iptables` egress MASQUERADE/forwarding is separate from private bridge -policy. The policy adds no DNAT, public listener, or host-port mapping and does not replace -the host firewall manager. Public inbound connectivity, DNS publication, guest reverse proxies/TLS, -and integration with UFW/firewalld/Docker/libvirt remain out of scope. An +policy. The policy adds no DNAT, public listener, or host-port mapping and does +not replace the host firewall manager. Public inbound connectivity, DNS +publication, guest reverse proxies/TLS, and integration with +UFW/firewalld/Docker/libvirt remain out of scope. An administrator's external routing/NAT can still affect reachability; do not infer source-CIDR restrictions or complete public-exposure protection from the use of private addresses. Operators must choose a non-conflicting topology. If an external actor loses or changes private policy, recovery is explicit -`ports sync`; `start` also restores shared networking for a running VM. An `enforced` snapshot cannot -prevent later root-level changes or prove that other rules allow traffic. +`ports sync`; `start` also restores shared networking for a running VM. An +`enforced` snapshot cannot prevent later root-level changes or prove that other +rules allow traffic. [FIREWALL.md](FIREWALL.md) retains the original proposal/backend evaluation; do not import its unimplemented topology checks into current documentation. @@ -1055,8 +1057,9 @@ The code is intentionally honest about current limits: - Guest health is inferred from VMM process state, not application readiness. - No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy. - No guest snapshots, extra disks, production image builder, or SSH-key injection. -- Per-VM private TCP ingress policy exists, but public inbound connectivity, DNS publication, - guest reverse proxy/TLS, and external firewall integration remain out of scope. +- Per-VM private TCP ingress policy exists, but public inbound connectivity, + DNS publication, guest reverse proxy/TLS, and external firewall integration + remain out of scope. - Shared egress `iptables` rules are incremental and idempotent; only the private bridge policy is atomically rendered per VM. - No autostart, background reconciliation, or guest-coordinated shutdown. diff --git a/README.md b/README.md index d223cc8..e1e228f 100644 --- a/README.md +++ b/README.md @@ -255,8 +255,8 @@ well as new connections. This is packet dropping, not a guaranteed TCP RST or immediate application disconnect. These rules govern access to the **guest private IP**. They do not start a -guest service or provide public inbound connectivity, host-port mappings, DNS publication, -reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not +guest service or provide public inbound connectivity, host-port mappings, +DNS publication, reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required ARP/ICMP traffic remain allowed; managed TAPs block IPv6. diff --git a/USAGE.md b/USAGE.md index ca4a173..c608c84 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1259,8 +1259,7 @@ so the packet test skipped. That is a permission limitation, not real-packet or real-host validation. The harness uses veths as TAP stand-ins and simulated upstreams to check NAT, offline startup, reconnects and uplink changes. It does not boot Firecracker or validate the host's real TAP setup. Run actual -VM/SSH/egress checks on an isolated Linux host before -relying on the deployment. +VM/SSH/egress checks on an isolated Linux host before relying on the deployment. ## 19. Get Help diff --git a/tests/integration/test_private_ports.py b/tests/integration/test_private_ports.py index 5239397..2294bee 100644 --- a/tests/integration/test_private_ports.py +++ b/tests/integration/test_private_ports.py @@ -2,8 +2,8 @@ Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v -Needs Linux, nft, iptables, ip, bridge, conntrack, sysctl and permission to create network -namespaces (root, or unprivileged user namespaces). No sudo, installation, +Needs Linux, nft, iptables, ip, bridge, conntrack, sysctl and permission to create +network namespaces (root, or unprivileged user namespaces). No sudo, installation, /run/netns mounts, host firewall changes or br_netfilter sysctls are used. Bridge-connected veths stand in for guest TAPs. A third namespace with two uplinks tests NAT, offline startup, reconnects and host route changes without @@ -380,7 +380,7 @@ def _sandbox(parent_fd): guest.request("udp_bind", name=label, ip=vm.guest_ip, port=0) guest.request("udp_send", name=label, ip=internet_ip, port=53, data=label) query = outside.request("udp_recv", name="internet-dns") - assert query == [label, [source, query[1][1]]], query + assert query[0] == label and query[1][0] == source, query outside.request("udp_send", name="internet-dns", ip=source, port=query[1][1], data="dns-reply") assert guest.request("udp_recv", name=label)[0] == "dns-reply" -- 2.54.0 From eb6119c7971fa1a38cad102e6bab77c8c23a3ecc Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 23 Sep 2026 06:30:58 +0530 Subject: [PATCH 7/9] feat: add graceful shutdown endpoint for UVM API server and update documentation --- DEVELOPER.md | 7 +++++++ README.md | 13 ++++++++++++ USAGE.md | 15 ++++++++++++++ tests/test_server.py | 47 ++++++++++++++++++++++++++++++++++++-------- uvm/api_models.py | 4 ++++ uvm/routers/exit.py | 35 +++++++++++++++++++++++++++++++++ uvm/server.py | 7 ++++++- 7 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 uvm/routers/exit.py diff --git a/DEVELOPER.md b/DEVELOPER.md index 990b8a1..8074151 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -808,6 +808,7 @@ or secret manager when that matters. | `GET` | `/` | `server.py` | Redirect to `/dashboard` | | `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell | | `GET` | `/health` | `health.py` | Unauthenticated liveness response | +| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown | | `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks | | `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `GET` | `/vms` | `vms.py` | List persisted VM records and observed status | @@ -829,6 +830,12 @@ All management routes use `get_authorized_application()` and require: X-UVM-Token: ``` +`POST /exit` is a management route. `run_server()` creates the Uvicorn server, +stores it on the FastAPI application state, and runs it in-process. The route +returns `202` before a background task sets `should_exit`, allowing Uvicorn to +deliver the response and perform its normal graceful shutdown. It does not +stop or delete any managed VM. + Request schemas reject unexpected fields. `UvmError` instances are rendered as JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal 422 response format. Create accepts `name: str | null`, `ports: list | null`, diff --git a/README.md b/README.md index e1e228f..6b8070e 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,7 @@ The server exposes these routes: | `GET` | `/` | Redirect to the local dashboard | | `GET` | `/dashboard` | Serve the local management dashboard | | `GET` | `/health` | Unauthenticated liveness response | +| `POST` | `/exit` | Gracefully stop the UVM API server | | `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `GET` | `/vms` | List VM records and observed status | @@ -364,6 +365,18 @@ curl \ http://127.0.0.1:8000/vms ``` +Stop the API server without stopping its managed VMs: + +```sh +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + http://127.0.0.1:8000/exit +``` + +The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates +the UVM API process. It is a protected management endpoint; it does not stop or +delete managed VMs. + Do not expose this server directly to an untrusted network. For remote access, keep TLS enabled and consider placing it behind an additional authenticated reverse proxy. diff --git a/USAGE.md b/USAGE.md index c608c84..ab95ce8 100644 --- a/USAGE.md +++ b/USAGE.md @@ -141,6 +141,21 @@ go to stderr. VM JSON omits passwords. Lifecycle and sync results are arrays of `changes`). `ports list --json` returns an array even for one VM. JSON does not disable confirmations, and errors may still be plain text on stderr. +### Stop the Management API + +The API can be stopped remotely through its authenticated shutdown endpoint. +This stops only the UVM API process; it does not stop or delete any VM: + +```sh +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + http://127.0.0.1:8000/exit +``` + +The server first returns `202 {"status":"stopping"}`, then performs Uvicorn's +graceful shutdown. Use the actual API host and port when they differ from the +defaults. `GET /exit` is intentionally not supported. + ## 4. Install UVM Assets UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest diff --git a/tests/test_server.py b/tests/test_server.py index fc49cf1..d028ea8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,7 +7,7 @@ import tempfile import unittest from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch from uvm.cli import build_parser, main from uvm.config import Settings @@ -99,19 +99,31 @@ class ServerCliTests(unittest.TestCase): application = fake_application(api_token="test-token") application.settings.api_tls_cert = certificate application.settings.api_tls_key = key - fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None) + api = SimpleNamespace(state=SimpleNamespace()) + server = SimpleNamespace(run=Mock()) + config = object() + fake_uvicorn = SimpleNamespace( + Config=Mock(return_value=config), + Server=Mock(return_value=server), + ) with ( patch("uvm.server._validate_server_settings"), - patch("uvm.server.create_api", return_value=object()), + patch("uvm.server.create_api", return_value=api), patch.dict(sys.modules, {"uvicorn": fake_uvicorn}), - patch.object(fake_uvicorn, "run") as run_mock, ): run_server(application, host="0.0.0.0", port=8443) - run_mock.assert_called_once() - self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate)) - self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key)) + fake_uvicorn.Config.assert_called_once_with( + api, + host="0.0.0.0", + port=8443, + ssl_certfile=str(certificate), + ssl_keyfile=str(key), + ) + fake_uvicorn.Server.assert_called_once_with(config) + self.assertIs(api.state.uvicorn_server, server) + server.run.assert_called_once_with() def test_api_token_rejects_non_ascii_or_whitespace(self) -> None: for token in ("s\u00e9cret", "contains space", ""): @@ -300,7 +312,8 @@ class ApiRouterTests(unittest.TestCase): ) application.state_store = SimpleNamespace(initialize=lambda: None) self.headers = {"X-UVM-Token": "test-token"} - self.client = TestClient(create_api(application, host="127.0.0.1")) + self.api = create_api(application, host="127.0.0.1") + self.client = TestClient(self.api) self.addCleanup(self.client.close) def test_health_is_available_without_credentials(self) -> None: @@ -339,6 +352,7 @@ class ApiRouterTests(unittest.TestCase): ("POST", "/vms/test-vm/ports", {}), ("POST", "/vms/test-vm/ports/sync", {}), ("GET", "/diagnostics", {}), + ("POST", "/exit", {}), ("POST", "/install", {}), ): for headers in ({}, {"X-UVM-Token": "wrong-token"}): @@ -353,6 +367,22 @@ class ApiRouterTests(unittest.TestCase): self.assertEqual(authorized.json()[0]["id"], "vm-test") self.assertNotIn("password", authorized.json()[0]) + def test_exit_requests_a_graceful_authenticated_server_shutdown(self) -> None: + server = SimpleNamespace(should_exit=False) + self.api.state.uvicorn_server = server + + response = self.client.post("/exit", headers=self.headers) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.json(), {"status": "stopping"}) + self.assertTrue(server.should_exit) + + def test_exit_requires_a_running_uvicorn_server(self) -> None: + response = self.client.post("/exit", headers=self.headers) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json(), {"detail": "server shutdown is unavailable outside uvm --serve"}) + def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None: created = self.client.post( "/vms", @@ -752,6 +782,7 @@ class ApiRouterTests(unittest.TestCase): ("/", "get"), ("/dashboard", "get"), ("/health", "get"), + ("/exit", "post"), ("/install", "post"), ("/vms", "get"), ("/vms", "post"), diff --git a/uvm/api_models.py b/uvm/api_models.py index aee7751..2c7317a 100644 --- a/uvm/api_models.py +++ b/uvm/api_models.py @@ -14,6 +14,10 @@ class HealthResponse(BaseModel): status: str = "ok" +class ServerExitResponse(BaseModel): + status: str = "stopping" + + class InstallRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/uvm/routers/exit.py b/uvm/routers/exit.py new file mode 100644 index 0000000..8226021 --- /dev/null +++ b/uvm/routers/exit.py @@ -0,0 +1,35 @@ +"""Authenticated graceful shutdown endpoint for the UVM API server.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status + +from ..api_models import ServerExitResponse +from ..app import Application +from .dependencies import get_authorized_application + + +router = APIRouter(tags=["server"]) + + +@router.post("/exit", response_model=ServerExitResponse, status_code=status.HTTP_202_ACCEPTED) +def exit_server( + background_tasks: BackgroundTasks, + request: Request, + _application: Application = Depends(get_authorized_application), +) -> ServerExitResponse: + """Stop the Uvicorn process after this response has been delivered.""" + server = getattr(request.app.state, "uvicorn_server", None) + if server is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="server shutdown is unavailable outside uvm --serve", + ) + background_tasks.add_task(_request_server_exit, server) + return ServerExitResponse() + + +def _request_server_exit(server: Any) -> None: + server.should_exit = True diff --git a/uvm/server.py b/uvm/server.py index 95b0145..51e9ecd 100644 --- a/uvm/server.py +++ b/uvm/server.py @@ -35,6 +35,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non ) from error from .routers.health import router as health_router + from .routers.exit import router as exit_router from .routers.installation import router as installation_router from .routers.vms import router as vms_router from .routers.diagnostics import router as diagnostics_router @@ -55,6 +56,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non ) api.include_router(health_router) + api.include_router(exit_router) api.include_router(installation_router) api.include_router(vms_router) api.include_router(diagnostics_router) @@ -91,7 +93,10 @@ def run_server(application: Application, *, host: str, port: int) -> None: if settings.api_tls_cert is not None: options["ssl_certfile"] = str(settings.api_tls_cert) options["ssl_keyfile"] = str(settings.api_tls_key) - uvicorn.run(create_api(application, host=host), **options) + api = create_api(application, host=host) + server = uvicorn.Server(uvicorn.Config(api, **options)) + api.state.uvicorn_server = server + server.run() def _is_loopback_host(host: str) -> bool: -- 2.54.0 From dca3cbde2feb96a286c9f90ff3dc7a77c9766a82 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 23 Sep 2026 07:00:22 +0530 Subject: [PATCH 8/9] feat: add host-backed entropy device support and update kernel configuration --- DEVELOPER.md | 18 ++++++++-- README.md | 11 ++++++ USAGE.md | 60 +++++++++++++++++++++++++++++++- tests/test_config.py | 8 ++--- tests/test_firecracker_config.py | 37 ++++++++++++++++++++ tests/test_install.py | 3 +- uvm/config.py | 6 ++-- uvm/firecracker/api.py | 4 +++ uvm/firecracker/config.py | 2 ++ 9 files changed, 139 insertions(+), 10 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 8074151..74064a1 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -490,7 +490,14 @@ The `firecracker/` package has three separate responsibilities. 2. `PUT /boot-source` 3. `PUT /drives/rootfs` 4. `PUT /network-interfaces/eth0` -5. `PUT /actions` with `InstanceStart` +5. `PUT /entropy` with `{}` (a host-backed virtio RNG device) +6. `PUT /actions` with `InstanceStart` + +Every generated config includes `entropy`, including retained-disk boots. +An empty object enables the device with default settings; do not skip it with +a truthiness check. A missing/invalid configuration or an entropy API failure +must prevent `InstanceStart`. Without guest entropy, systemd's random-seed +service and OpenSSH can remain in startup while the VM process and IP respond. The guest receives a static IP via the kernel `ip=` boot argument. The netmask comes from `Settings.network`, not a hard-coded `/24` value. @@ -729,7 +736,14 @@ handling, downloads, and integrity-manifest publication. (`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures. The same architecture selects the Firecracker release archive, guest kernel, native rootfs packages, and architecture-specific boot arguments. The default -kernel URL uses Firecracker's `img/quickstart_guide//kernels/vmlinux.bin`. +kernel URL is pinned to Firecracker's published +`firecracker-ci/v1.15//vmlinux-6.1.155` artifacts. The CI artifact +series is independent of the VMM release. Both architecture configs include +`CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y`; custom kernels need these +built-in drivers for UVM's entropy device. The previous ARM quickstart image +was Linux 4.14.174 without the built-in virtio RNG driver. Updating the device +configuration alone cannot repair that kernel; existing installs need a forced +asset refresh with a matching new trusted kernel checksum, then a fresh VM boot. An empty `Settings.rootfs_url` selects the built Ubuntu 22.04 (Jammy) template. Explicit kernel/rootfs URLs and trusted digests must match the host architecture. ARM64 uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs. diff --git a/README.md b/README.md index 6b8070e..01f94f3 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,17 @@ Spark, UVM uses ARM64 assets and packages. `x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides must point to assets compatible with the host architecture. +The default guest kernel is Linux 6.1.155 from Firecracker's published `v1.15` +CI artifacts, with built-in `virtio-rng` support. UVM attaches a host-backed +entropy device before each guest boot so services such as SSH can initialize +their cryptographic random generator. Custom kernels must include +`CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y` for this device. + +Older installs used a Linux 4.14 ARM demo kernel and supplied no entropy +device. If SSH stays at **Starting OpenBSD Secure Shell server** while ping +works, see [SSH startup and guest entropy](USAGE.md#ssh-startup-and-guest-entropy) +for the kernel-refresh and retained-VM recovery steps. + ## Trusted Installation Installation requires trusted SHA-256 digests by default. Supply digests from diff --git a/USAGE.md b/USAGE.md index ab95ce8..597406f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -169,7 +169,7 @@ UVM detects the host architecture and selects matching Firecracker and kernel downloads and native Ubuntu packages. NVIDIA DGX Spark uses `aarch64` artifacts and `arm64` packages; the `arm64` architecture alias is also accepted: -- Kernel: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/kernels/vmlinux.bin` +- Kernel: `https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64/vmlinux-6.1.155` - Rootfs: Ubuntu 22.04 built with `debootstrap` from `ports.ubuntu.com/ubuntu-ports`. Use the installation commands below with checksums for the ARM64 kernel @@ -180,6 +180,12 @@ On ARM64, the kernel must be an uncompressed ARM64 Linux `Image` (PE format), and the rootfs must be an ARM64 ext4 filesystem. UVM adds `keep_bootcon` to the guest boot arguments for ARM64 console output. +Both architectures default to the pinned Linux 6.1.155 kernel from the published +`v1.15` CI artifact series, independently of the Firecracker binary version. +These kernels have `CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y` built in. +UVM configures Firecracker's entropy device before boot; custom kernels also +need these drivers to receive host-backed randomness. + ### Scenario: Strict First Installation Obtain trusted checksums independently, then run: @@ -1222,6 +1228,58 @@ External firewall integration (including UFW, firewalld, Docker, and libvirt) is deferred. A UVM policy readback does not prove that external rules permit traffic, or that they will not later remove UVM's rules. +### SSH Startup and Guest Entropy + +`running` checks the Firecracker process, and `enforced` checks the private +firewall policy. Neither probes the guest's SSH listener. If ping works but +TCP/22 returns `Connection refused`, inspect the guest boot log: + +```sh +sudo ./uvm.py logs VM --tail 200 +sudo ./uvm.py inspect VM --json +``` + +An SSH service stuck at `Starting OpenBSD Secure Shell server...`, together +with an unfinished `Load/Save Random Seed` service and no `random: crng init done`, +strongly suggests entropy starvation. A login prompt can still appear while +SSH is waiting for its cryptographic random generator. On the old ARM demo +kernel, `random: fast init done` is not full cryptographic RNG initialization. + +Earlier UVM versions downloaded a Linux 4.14.174 ARM quickstart kernel without +the built-in virtio RNG driver and did not attach an entropy device. Recovery +requires **both** the updated UVM boot code and an RNG-capable kernel. A restart +with the old cached kernel is insufficient. + +From the updated checkout, stop the affected VM before refreshing assets. +Stop any other VMs using the same installed Firecracker binary before the +forced reinstall as well. Use the Firecracker archive checksum for the configured +release and a new trusted checksum for the selected architecture's Linux 6.1.155 +kernel; the old kernel checksum will not match: + +```sh +sudo ./uvm.py stop VM +sudo env -u UVM_KERNEL_URL \ + UVM_FIRECRACKER_SHA256='' \ + UVM_KERNEL_SHA256='' \ + ./uvm.py install --force +sudo ./uvm.py start VM +sudo ./uvm.py logs VM --tail 100 +sudo ./uvm.py ssh VM +``` + +This uses the default kernel URL and boots the retained private disk with the +updated device configuration. The VM's identity, files, credentials, and SSH +host keys are retained. `install --force` also refreshes the shared rootfs +template for future creates. If using a custom kernel, supply its URL and +matching checksum with the required drivers instead. For an explicitly +unverified development installation, retain the existing +`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` setting as described in the installation section. + +Restart a running UVM API server to load the updated code before using dashboard +start/restart actions. Look for `Started OpenBSD Secure Shell server` in the new +boot log and confirm an actual SSH connection. `start` on an already-running +VM only refreshes networking; use a stopped VM or `restart` to attach the device. + ## 16. Important Limitations Keep these constraints in mind when deciding whether UVM fits a scenario: diff --git a/tests/test_config.py b/tests/test_config.py index 062d558..1a6e008 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,17 +27,17 @@ class ArchitectureSettingsTests(unittest.TestCase): settings = factory() base = ( - "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" + "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/" f"{architecture}" ) self.assertEqual(settings.architecture, architecture) - self.assertEqual(settings.kernel_url, f"{base}/kernels/vmlinux.bin") + self.assertEqual(settings.kernel_url, f"{base}/vmlinux-6.1.155") self.assertEqual(settings.rootfs_url, "") def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None: kernel = "https://example.com/custom-arm64-kernel" rootfs = "https://example.com/custom-arm64-rootfs.ext4" - base = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64" + base = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64" for overrides in ( {"UVM_KERNEL_URL": kernel}, {"UVM_ROOTFS_URL": rootfs}, @@ -52,7 +52,7 @@ class ArchitectureSettingsTests(unittest.TestCase): self.assertEqual( settings.kernel_url, - overrides.get("UVM_KERNEL_URL", f"{base}/kernels/vmlinux.bin"), + overrides.get("UVM_KERNEL_URL", f"{base}/vmlinux-6.1.155"), ) self.assertEqual( settings.rootfs_url, diff --git a/tests/test_firecracker_config.py b/tests/test_firecracker_config.py index 939cfe6..c65521f 100644 --- a/tests/test_firecracker_config.py +++ b/tests/test_firecracker_config.py @@ -3,9 +3,11 @@ from __future__ import annotations import unittest from ipaddress import IPv4Address, IPv4Network from pathlib import Path +from unittest.mock import patch from uvm.config import Settings from uvm.domain import VmRecord +from uvm.errors import FirecrackerError from uvm.firecracker.api import FirecrackerClient from uvm.firecracker.config import build_config, vcpu_count @@ -45,6 +47,10 @@ class FirecrackerConfigTests(unittest.TestCase): self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64") self.assertIn("console=ttyS0", arguments) self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments) + client = RecordingFirecrackerClient() + client.configure_and_start(config) + self.assertEqual(client.calls[-2], ("PUT", "/entropy", {})) + self.assertEqual(client.calls[-1], ("PUT", "/actions", {"action_type": "InstanceStart"})) def test_builds_a_writable_vm_disk_configuration(self) -> None: vm = VmRecord( @@ -101,6 +107,7 @@ class FirecrackerConfigTests(unittest.TestCase): "/boot-source", "/drives/rootfs", "/network-interfaces/eth0", + "/entropy", "/actions", ], ) @@ -110,6 +117,36 @@ class FirecrackerConfigTests(unittest.TestCase): ) self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"}) + def test_entropy_failure_prevents_guest_boot(self) -> None: + config = { + "machine-config": {"vcpu_count": 1, "mem_size_mib": 512}, + "boot-source": {}, + "drives": [{}], + "network-interfaces": [{}], + "entropy": {}, + } + client = RecordingFirecrackerClient() + + def request(method: str, path: str, body: object) -> None: + client.calls.append((method, path, body)) + if path == "/entropy": + raise FirecrackerError("entropy device unavailable") + + with patch.object(client, "_request", side_effect=request): + with self.assertRaisesRegex(FirecrackerError, "entropy device unavailable"): + client.configure_and_start(config) + + self.assertEqual(client.calls[-1], ("PUT", "/entropy", {})) + self.assertNotIn("/actions", [path for _method, path, _body in client.calls]) + + for entropy in (None, [], False): + with self.subTest(entropy=entropy): + client.calls.clear() + config["entropy"] = entropy + with self.assertRaisesRegex(FirecrackerError, "no valid entropy device"): + client.configure_and_start(config) + self.assertEqual(client.calls, []) + def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None: settings = Settings( network=IPv4Network("10.50.0.0/16"), diff --git a/tests/test_install.py b/tests/test_install.py index b774a9e..6e275be 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -72,7 +72,8 @@ class InstallerArchitectureTests(unittest.TestCase): payloads = { "https://github.com/firecracker-microvm/firecracker/releases/download/" f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes, - f"{guest_base}/kernels/vmlinux.bin": kernel_bytes, + "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/" + f"{architecture}/vmlinux-6.1.155": kernel_bytes, f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes, } runner = Mock(spec=CommandRunner) diff --git a/uvm/config.py b/uvm/config.py index a7242af..b908db6 100644 --- a/uvm/config.py +++ b/uvm/config.py @@ -10,7 +10,9 @@ from pathlib import Path from .errors import ConfigurationError -DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide" +# Pin published kernels with built-in virtio-rng support. The artifact series +# is independent of the VMM release; the old ARM quickstart kernel lacks this driver. +DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15" MIN_STORAGE_MIB = 30 * 1024 MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024) @@ -55,7 +57,7 @@ class Settings: # Resolve defaults at construction time, while preserving individual URL overrides. asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}" if not self.kernel_url: - object.__setattr__(self, "kernel_url", f"{asset_base}/kernels/vmlinux.bin") + object.__setattr__(self, "kernel_url", f"{asset_base}/vmlinux-6.1.155") if self.gateway not in self.network: raise ConfigurationError( diff --git a/uvm/firecracker/api.py b/uvm/firecracker/api.py index 99c7ac1..326e7fc 100644 --- a/uvm/firecracker/api.py +++ b/uvm/firecracker/api.py @@ -35,12 +35,15 @@ class FirecrackerClient: boot = config["boot-source"] drives = config["drives"] interfaces = config["network-interfaces"] + entropy = config.get("entropy") if not isinstance(machine, Mapping) or not isinstance(boot, Mapping): raise FirecrackerError("invalid Firecracker configuration") if not isinstance(drives, list) or not drives: raise FirecrackerError("Firecracker configuration has no root drive") if not isinstance(interfaces, list) or not interfaces: raise FirecrackerError("Firecracker configuration has no network interface") + if not isinstance(entropy, Mapping): + raise FirecrackerError("Firecracker configuration has no valid entropy device") self._request( "PUT", @@ -54,6 +57,7 @@ class FirecrackerClient: self._request("PUT", "/boot-source", boot) self._request("PUT", "/drives/rootfs", drives[0]) self._request("PUT", "/network-interfaces/eth0", interfaces[0]) + self._request("PUT", "/entropy", entropy) self._request("PUT", "/actions", {"action_type": "InstanceStart"}) def _request(self, method: str, path: str, body: object) -> None: diff --git a/uvm/firecracker/config.py b/uvm/firecracker/config.py index bcf7e0d..ff8d80c 100644 --- a/uvm/firecracker/config.py +++ b/uvm/firecracker/config.py @@ -70,6 +70,8 @@ def build_config( "host_dev_name": vm.tap, } ], + # Supply host-backed randomness before guest services such as sshd start. + "entropy": {}, } -- 2.54.0 From 660ba9efeed5db28628fbb068475fccdc4b92811 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 23 Sep 2026 08:20:35 +0530 Subject: [PATCH 9/9] feat: enhance guest network configuration and recovery process for UVM --- DEVELOPER.md | 46 +++++++- README.md | 17 ++- USAGE.md | 43 ++++++++ tests/test_guest_network.py | 213 ++++++++++++++++++++++++++++++++++++ tests/test_install.py | 3 +- tests/test_lifecycle.py | 53 ++++++++- uvm/images.py | 100 +++++++++++++++++ uvm/install.py | 9 +- uvm/lifecycle.py | 2 + 9 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 tests/test_guest_network.py diff --git a/DEVELOPER.md b/DEVELOPER.md index 74064a1..c7d6364 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -336,7 +336,7 @@ a ruleset/attachment snapshot, not application health or a reachability test. 3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`. 4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private disk, resize ext4 to the requested storage capacity, provision credentials/host - keys, and persist `disk_ready=True`. + keys and the managed guest network profile, then persist `disk_ready=True`. 5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**. 6. Apply/read back the nftables bridge policy, then activate the TAP. 7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`, @@ -373,6 +373,14 @@ not needed. It never recopies the rootfs, reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes survive stop/start; guest RAM does not. +After runtime cleanup confirms process exit and any pending resize finishes, +start specializes an original UVM Ubuntu network profile with the retained +guest address/gateway. This one-time migration repairs older guests that lost +their default route. It uses a checked sparse copy of the retained private +disk; it never replaces that disk with the shared template. Guest-edited +profiles are preserved. The image layer's network checks and writes run only +under the lifecycle operation lock, before the TAP and VMM are started. + Start on a healthy running VM validates its retained network, synchronizes policy, then restores shared bridge/NAT/forwarding rules without spawning another VMM. A shared-network setup failure retains the running VM for retry. An ambiguous @@ -500,7 +508,9 @@ must prevent `InstanceStart`. Without guest entropy, systemd's random-seed service and OpenSSH can remain in startup while the VM process and IP respond. The guest receives a static IP via the kernel `ip=` boot argument. The netmask -comes from `Settings.network`, not a hard-coded `/24` value. +comes from `Settings.network`, not a hard-coded `/24` value. The Ubuntu +systemd-networkd profile also receives explicit `Address=` and `Gateway=` +settings so it can maintain the same configuration after boot or reconfiguration. ARM64 guests also receive `keep_bootcon`, as recommended by Firecracker's getting-started guide; both architectures use `console=ttyS0`. @@ -566,6 +576,30 @@ UVM replaces conventional RSA, ECDSA, and Ed25519 host keys in each private disk and removes the known public login key from Firecracker's bionic image. A VM must never rely on reusable SSH private keys from a shared template. +### Guest Network Profile + +`GUEST_NETWORK_TEMPLATE` in `images.py` is shared with the Ubuntu builder. It +uses `KeepConfiguration=yes`: in Ubuntu 22.04's systemd-networkd, `static` +retains permanent addresses and `proto static` routes, but the kernel's +`ip=`-configured default route uses `proto boot` and is otherwise removed. +This can leave SSH to the private IP working while all off-subnet destinations +fail locally with `Network is unreachable`. + +`ImageStore.configure_guest_network()` fills in the assigned address/prefix and +gateway during creation and stopped-disk resume. It only specializes the exact +shipped profile from recognized UVM Jammy v1/v2/v3 images. Missing, customized, +symlinked, hard-linked, or unexpectedly owned/mode-set profiles are left intact. +An already-specialized profile is a no-op. Custom images retain responsibility +for their own guest networking. + +For migration, the image layer sparse-copies the private disk and runs +`e2fsck -f -p` on the copy. It rechecks the profile after journal replay so +committed guest edits cannot be overwritten based on stale offline reads. +The updated profile is read back and the filesystem checked before fsync and +atomic disk replacement. Failed staging keeps the original disk available for +retry. The built template's recipe marker is v3; cached older templates are +also handled by per-VM provisioning without requiring an installation refresh. + ### Integrity Model [`integrity.py`](uvm/integrity.py) has two concepts: @@ -985,6 +1019,10 @@ and stop-on-error behavior. Tests simulate Ubuntu removing `policy-rc.d` after bootstrap. They do not run debootstrap or boot a VM. Lifecycle resize tests use fake processes/networking to verify ordering, single-lock ownership, identity preservation, pending intent, and failure recovery. +`test_guest_network.py` exercises real offline ext4 profiles, explicit guest +addresses/gateways, custom subnets, idempotent migration, journal-replay +rechecks, and retention of guest data, credentials and keys on failures. +Lifecycle tests require confirmed process exit before network-profile repair. ### Isolated Packet Tests @@ -1017,7 +1055,8 @@ on an isolated Linux host: 1. Strict checked installation. 2. `create` and successful guest boot. -3. Guest IP reachability and egress. +3. Guest IP reachability and egress, including the guest's default route after + systemd-networkd startup and reconfiguration. 4. SSH with unique guest host keys, `sudo -i` for a custom login, and `sudo apt update && sudo apt install htop` in a newly built Ubuntu guest. 5. Stop/start/restart with retained guest data, SSH keys, and credentials. @@ -1055,6 +1094,7 @@ on an isolated Linux host: | VM is `dead` in `list` | PID/start token in state, `/proc/`, VM log | | Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID | | Guest has no egress | Host connectivity/routes, bridge/TAP state, `uvm-egress` iptables rules; `start` refreshes networking on a running VM | +| Guest says `Network is unreachable` | Guest `ip -4 route`; the default route must use the UVM bridge gateway. Restart once with updated code to migrate an original Ubuntu profile. | | Policy missing/drift/unknown | Actual nft JSON and ownership, STP/group mask, permissions; explicit sync after fixing cause | | Retained-disk start fails | `disk_ready`, safe private path, kernel/VMM integrity, process identity, retained network, pending operation | | API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files | diff --git a/README.md b/README.md index 01f94f3..a0a8b92 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,19 @@ template retains apt's package metadata and enables `main` and `universe`, including updates/security repositories. Guest DNS defaults to `1.1.1.1` and `8.8.8.8` in `/etc/resolv.conf`. +UVM specializes the Ubuntu guest's `/etc/systemd/network/10-uvm.network` with +its assigned `Address=` and `Gateway=` before boot. `KeepConfiguration=yes` +also retains the kernel-created default route while systemd-networkd starts. +Older templates used `KeepConfiguration=static`, which could retain the guest +IP but remove the default route, producing `ping: connect: Network is unreachable`. + +The updated CLI repairs an older VM's original, unedited UVM profile on its +next stopped-disk boot, including `restart`. The repair is prepared and checked +on a sparse copy before publication; guest-edited profiles and custom images +are preserved. New VMs also receive the fix when using a cached older Ubuntu +template. See [guest route recovery](USAGE.md#guest-reports-network-is-unreachable) +for immediate recovery inside a running guest. + Existing accounts retain their UID, groups, home directory, and shell. A new user reuses a same-named group if present; otherwise UVM creates one with an unused GID. `/etc/gshadow` is updated when present. The default `root` account @@ -469,6 +482,8 @@ sudo ./uvm.py start web On a running VM, `start` refreshes its private policy and shared host networking without rebooting it. New VM boots set up the same networking automatically. +For an older guest missing its default route, use `restart` once to apply the +offline guest-profile repair, or follow the guest route recovery steps above. Private VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`, `172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible @@ -516,7 +531,7 @@ is mode `0600`. Treat it as a secret, do not include it in bug reports, and replace the default `root` password immediately. Credential provisioning applies only to VMs created by this version. Existing -VM disks are not modified automatically. +guest accounts, passwords, and SSH host keys are retained on subsequent boots. Older registries are read and migrated on the next write. A missing/null `ports` policy remains `None`/`unmanaged`, not an empty allowlist or an diff --git a/USAGE.md b/USAGE.md index 597406f..27a7508 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1203,6 +1203,48 @@ without a reboot. Use the updated CLI checkout, or restart the API server to load the updated code before using its start endpoint. Subsequent host disconnects, reconnects, and route changes require no UVM command. +### Guest Reports `Network is Unreachable` + +An immediate `ping: connect: Network is unreachable` inside the VM indicates +that the guest has no usable route to that destination. Inspect the guest's +networking from its SSH shell: + +```sh +ip -4 address show dev eth0 +ip -4 route +``` + +For the default UVM subnet, the route must include `default via 10.42.0.1 dev +eth0`. The guest gateway is the **UVM bridge address**, even when the host's +own uplink uses a different gateway. + +Older UVM Ubuntu images used `KeepConfiguration=static`. Ubuntu's network +manager could preserve the guest address but remove its kernel-created +`proto boot` default route. Inside the running guest, restore it immediately: + +```sh +sudo ip -4 route replace default via 10.42.0.1 dev eth0 proto static +ping -c 3 1.1.1.1 +``` + +For a persistent repair, run the updated CLI **on the host**: + +```sh +sudo ./uvm.py restart 10.42.0.2 +``` + +This stops the VM, repairs its original UVM network profile on the retained +disk, and boots it with explicit address/gateway settings. A stopped VM's +`start` applies the same repair; `start` on an already-running VM refreshes +host rules only. Per-VM provisioning also repairs cached older Ubuntu +templates when creating new VMs. Use the configured `UVM_GATEWAY` and guest IP +in place of these defaults for a custom subnet. + +Guest-edited profiles and custom images are preserved. For those, persist the +correct `Address=` and `Gateway=` in the guest's own network configuration. + +### Verify Guest Internet Access + Check DNS and HTTPS from the guest: ```sh @@ -1215,6 +1257,7 @@ Common issues: | Symptom | First checks | |---|---| | Guest cannot reach the internet | Confirm the host can reach the same destination, bridge/TAP are up, and `uvm-egress` NAT/FORWARD rules exist; use `start VM` to refresh sharing. | +| Guest immediately reports `Network is unreachable` | Inspect the guest's default route and apply the guest route recovery steps above. | | IP connectivity works but DNS fails | Check guest `/etc/resolv.conf` and DNS reachability. The Ubuntu template uses `1.1.1.1` and `8.8.8.8`; host loopback resolvers such as `127.0.0.53` are not reachable as guest loopback addresses. | | Requested guest IP is rejected | Confirm it is inside the configured subnet and not used, gateway, network, or broadcast. | | Create says TAP already exists | Inspect the interface; do not delete it unless you know it is stale UVM state. | diff --git a/tests/test_guest_network.py b/tests/test_guest_network.py new file mode 100644 index 0000000..c669fb9 --- /dev/null +++ b/tests/test_guest_network.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import configparser +import hashlib +import shutil +import tempfile +import unittest +from dataclasses import replace +from ipaddress import IPv4Address, IPv4Network +from pathlib import Path +from unittest.mock import Mock, patch + +from uvm.config import Settings +from uvm.errors import UvmError, ValidationError +from uvm.images import GUEST_NETWORK_TEMPLATE, ImageStore +from uvm.system import CommandResult, CommandRunner + + +PROFILE = "/etc/systemd/network/10-uvm.network" +# The shipped v1/v2 profile that retained the IP but lost the proto-boot route. +LEGACY_PROFILE = ( + "[Match]\nName=eth0\n\n[Network]\n" + "KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n" +) + + +class GuestNetworkValidationTests(unittest.TestCase): + def test_invalid_guest_addresses_fail_before_disk_access(self) -> None: + runner = Mock(spec=CommandRunner) + store = ImageStore(Settings(), runner) + for address in ("invalid", "::1", "192.0.2.2", "10.42.0.0", "10.42.0.255", "10.42.0.1"): + with self.subTest(address=address), self.assertRaises(ValidationError): + store.configure_guest_network(Path("/nonexistent/rootfs.ext4"), address) + runner.run.assert_not_called() + + def test_shared_or_linked_disks_are_rejected_before_guest_commands(self) -> None: + with tempfile.TemporaryDirectory() as name: + settings = Settings(base=Path(name)) + settings.images_dir.mkdir() + settings.rootfs_image.write_bytes(b"shared template") + disk = settings.base / "rootfs.ext4" + disk.write_bytes(b"private disk") + linked = settings.base / "linked.ext4" + linked.hardlink_to(disk) + symlink = settings.base / "symlink.ext4" + symlink.symlink_to(disk) + runner = Mock(spec=CommandRunner) + store = ImageStore(settings, runner) + for candidate in (settings.rootfs_image, disk, linked, symlink): + with self.subTest(candidate=candidate), self.assertRaisesRegex(UvmError, "unsafe"): + store.configure_guest_network(candidate, "10.42.0.2") + runner.run.assert_not_called() + + +@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")), + "e2fsprogs and coreutils are required for offline guest networking checks") +class OfflineGuestNetworkTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.base = Path(temporary.name) + self.settings = Settings(base=self.base / "uvm") + self.settings.images_dir.mkdir(parents=True) + self.disk = self.base / "VM with spaces" / "rootfs.ext4" + self.disk.parent.mkdir() + self.runner = CommandRunner(emit=None) + self.store = ImageStore(self.settings, self.runner) + self.retained = { + "/etc/shadow": "adm:$6$original-password-hash:1:0:99999:7:::\n", + "/etc/ssh/ssh_host_ed25519_key": "original private host key\n", + "/home/adm/data": "retained guest application data\n", + } + + def make_disk(self, *, marker="uvm-ubuntu-jammy-v2\n", profile=LEGACY_PROFILE, symlink=False) -> None: + root = self.base / "tree" + if root.exists(): + shutil.rmtree(root) + files = dict(self.retained) + if marker is not None: + files["/etc/uvm-image"] = marker + if profile is not None: + files["/etc/alternate.network" if symlink else PROFILE] = profile + for path, contents in files.items(): + local = root / path.lstrip("/") + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text(contents, encoding="utf-8") + local.chmod(0o644) + if symlink: + local = root / PROFILE.lstrip("/") + local.parent.mkdir(parents=True, exist_ok=True) + local.symlink_to("/etc/alternate.network") + with self.settings.rootfs_image.open("wb") as image: + image.truncate(32 * 1024 * 1024) + self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True) + if profile is not None: + for field in ("uid", "gid"): + self.runner.run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0", + self.settings.rootfs_image), capture=True) + self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True) + self.disk.chmod(0o600) + + def guest_file(self, path: str) -> str: + return self.runner.run(("debugfs", "-R", f"cat {path}", self.disk), capture=True).stdout + + def snapshot(self) -> tuple[int, bytes]: + return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest() + + def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None: + for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE), + ("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE), + ("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE)): + with self.subTest(marker=marker): + self.make_disk(marker=marker, profile=profile) + original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest() + self.assertTrue(self.store.configure_guest_network(self.disk, "10.42.0.2")) + config = configparser.ConfigParser() + config.read_string(self.guest_file(PROFILE)) + self.assertEqual(config["Match"]["Name"], "eth0") + self.assertEqual(config["Network"]["Address"], "10.42.0.2/24") + self.assertEqual(config["Network"]["Gateway"], "10.42.0.1") + self.assertTrue(config["Network"].getboolean("KeepConfiguration")) + self.assertFalse(config["Network"].getboolean("DHCP")) + for path, contents in self.retained.items(): + self.assertEqual(self.guest_file(path), contents) + self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), original_template) + self.assertEqual(self.disk.stat().st_size, 32 * 1024 * 1024) + self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600) + self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True) + + before = self.snapshot() + with patch.object(self.store, "_copy_sparse") as copy: + self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2")) + copy.assert_not_called() + self.assertEqual(self.snapshot(), before) + + def test_custom_subnets_and_gateways_are_used_in_the_guest_profile(self) -> None: + settings = replace(self.settings, network=IPv4Network("10.50.0.0/16"), gateway=IPv4Address("10.50.1.1")) + self.make_disk() + self.assertTrue(ImageStore(settings, self.runner).configure_guest_network(self.disk, "10.50.8.9")) + config = configparser.ConfigParser() + config.read_string(self.guest_file(PROFILE)) + self.assertEqual(config["Network"]["Address"], "10.50.8.9/16") + self.assertEqual(config["Network"]["Gateway"], "10.50.1.1") + + def test_custom_images_missing_profiles_and_guest_edits_are_left_intact(self) -> None: + for marker, profile, symlink in ( + (None, LEGACY_PROFILE, False), ("custom-image\n", LEGACY_PROFILE, False), + ("uvm-ubuntu-jammy-v2\n", None, False), + ("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "# guest customization\n", False), + ("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "Gateway=10.42.0.9\n", False), + ("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE, True), + ): + with self.subTest(marker=marker, profile=profile, symlink=symlink): + self.make_disk(marker=marker, profile=profile, symlink=symlink) + before = self.snapshot() + with patch.object(self.store, "_copy_sparse") as copy: + self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2")) + copy.assert_not_called() + self.assertEqual(self.snapshot(), before) + + def test_a_failed_or_interrupted_profile_write_keeps_the_original_disk(self) -> None: + for error in (UvmError("profile readback failed"), KeyboardInterrupt()): + with self.subTest(error=type(error).__name__): + self.make_disk() + before = self.snapshot() + write = self.store._write_guest_file + + def fail(candidate, path, contents): + self.assertNotEqual(candidate, self.disk) + write(candidate, path, contents) + raise error + + with patch.object(self.store, "_write_guest_file", side_effect=fail), self.assertRaises(type(error)): + self.store.configure_guest_network(self.disk, "10.42.0.2") + self.assertEqual(self.snapshot(), before) + self.assertEqual(list(self.disk.parent.iterdir()), [self.disk]) + + def test_filesystem_check_failure_prevents_publication(self) -> None: + self.make_disk() + before = self.snapshot() + run = self.runner.run + + def check_failure(command, **options): + if command[0] == "e2fsck": + return CommandResult(tuple(map(str, command)), 4, stderr="unrepaired filesystem errors") + return run(command, **options) + + with patch.object(self.runner, "run", side_effect=check_failure): + with self.assertRaisesRegex(UvmError, "unrepaired filesystem errors"): + self.store.configure_guest_network(self.disk, "10.42.0.2") + self.assertEqual(self.snapshot(), before) + self.assertEqual(list(self.disk.parent.iterdir()), [self.disk]) + + def test_guest_edits_revealed_after_journal_replay_prevent_migration(self) -> None: + self.make_disk() + before = self.snapshot() + edited = self.base / "edited.network" + edited.write_text(LEGACY_PROFILE + "Gateway=10.42.0.9\n", encoding="utf-8") + run = self.runner.run + + def replay(command, **options): + result = run(command, **options) + if command[:3] == ("e2fsck", "-f", "-p"): + run(("debugfs", "-w", "-R", f"rm {PROFILE}", command[-1]), capture=True) + run(("debugfs", "-w", "-R", f"write {edited} {PROFILE}", command[-1]), capture=True) + for field in ("uid", "gid"): + run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0", command[-1]), capture=True) + return result + + with patch.object(self.runner, "run", side_effect=replay): + self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2")) + self.assertEqual(self.snapshot(), before) + self.assertEqual(list(self.disk.parent.iterdir()), [self.disk]) diff --git a/tests/test_install.py b/tests/test_install.py index 6e275be..92cfe78 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -158,6 +158,7 @@ class InstallerArchitectureTests(unittest.TestCase): for verified, marker, force in ( (True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False), (True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True), + (True, "uvm-ubuntu-jammy-v2\n", False), ): with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary: settings = Settings(base=Path(temporary)) @@ -335,7 +336,7 @@ class UbuntuRootfsBuildTests(unittest.TestCase): self.assertRegex(guest("stat /usr/bin/sudo").stdout, r"Mode:\s+0?4755") self.assertEqual(guest("cat /etc/uvm-image").stdout, _ROOTFS_RECIPE) self.assertIn("nameserver 1.1.1.1", guest("cat /etc/resolv.conf").stdout) - self.assertIn("KeepConfiguration=static", guest("cat /etc/systemd/network/10-uvm.network").stdout) + self.assertIn("KeepConfiguration=yes", guest("cat /etc/systemd/network/10-uvm.network").stdout) self.assertIn("jammy-security main universe", guest("cat /etc/apt/sources.list").stdout) for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"): self.assertIn("File not found", guest(f"stat {path}").stderr) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b717c49..c882ca4 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -307,6 +307,9 @@ class LifecycleTestCase(unittest.TestCase): self.images, "resize_vm_disk", side_effect=self.resize_vm_disk, )) self.provision = self.enterContext(patch.object(self.images, "provision_credentials")) + self.configure_network = self.enterContext(patch.object( + self.images, "configure_guest_network", side_effect=self.configure_guest_network, + )) self.copy_disk = self.enterContext(patch.object( self.images, "create_vm_disk", wraps=self.images.create_vm_disk, )) @@ -340,6 +343,14 @@ class LifecycleTestCase(unittest.TestCase): raise AssertionError("cannot resize a running or ambiguous VM") self.disk_sizes[disk] = storage_mib * 1024 * 1024 + def configure_guest_network(self, disk: Path, guest_ip: str) -> None: + vm = self.store.get(disk.parent.name) + self.host.call("images.configure_network", vm.id) + if self.process.observe(vm) != "gone": + raise AssertionError("cannot configure a running or ambiguous VM disk") + if guest_ip != vm.guest_ip: + raise AssertionError("guest network must use the VM's retained address") + def create_vm(self, **kwargs) -> VmRecord: return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs)) @@ -481,7 +492,7 @@ class LifecycleTests(LifecycleTestCase): def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None: vm = self.create_vm() self.assert_call_order( - vm, "network.prepare_tap", "firewall.apply", "firewall.readback", + vm, "images.configure_network", "network.prepare_tap", "firewall.apply", "firewall.readback", "network.activate_tap", "process.start", "process.spawn", "process.ready", "client.configure", "firewall.status", ) @@ -499,6 +510,46 @@ class LifecycleTests(LifecycleTestCase): self.assertEqual(spawned.operation, "configuring") self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid) + def test_guest_network_configuration_failure_rolls_back_before_marking_the_disk_ready(self) -> None: + self.host.failures["images.configure_network", None] = UvmError("guest network verification failed") + with self.assertRaisesRegex(UvmError, "guest network verification failed"): + self.create_vm() + self.assertEqual(self.store.load().vms, {}) + self.assertFalse(self.network.created_taps) + self.assertFalse(self.process.started) + self.assertEqual(list(self.settings.vms_dir.iterdir()), []) + + def test_retained_guest_network_is_configured_after_exit_and_before_boot(self) -> None: + vm = self.create_vm() + self.host.events.clear() + self.service.restart(vm.id) + self.assert_call_order(vm, "images.configure_network", "process.start", "client.configure") + actions = self.host.actions(vm.id) + self.assertLess(max(index for index, action in enumerate(actions) if action == "process.gone"), + actions.index("images.configure_network")) + self.configure_network.assert_called_with(Path(vm.disk), vm.guest_ip) + self.assertEqual(self.provision.call_count, 1) + + def test_live_start_does_not_modify_the_guest_disk(self) -> None: + vm = self.create_vm() + self.configure_network.reset_mock() + self.service.start(vm.id) + self.configure_network.assert_not_called() + + def test_failed_retained_network_configuration_keeps_disk_for_retry(self) -> None: + vm = self.create_vm() + self.service.stop(vm.id) + disk = Path(vm.disk) + disk.write_bytes(b"retained data and credentials") + self.host.failures["images.configure_network", vm.id] = UvmError("network verification failed") + with self.assertRaisesRegex(UvmError, "network verification failed"): + self.service.start(vm.id) + self.assertEqual(disk.read_bytes(), b"retained data and credentials") + self.assertEqual(self.process.observe(self.store.get(vm.id)), "gone") + self.assertEqual(len(self.process.started), 1) + self.assertEqual(self.service.start(vm.id).status, "running") + self.assertEqual(self.provision.call_count, 1) + def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None: self.host.failures["firewall.apply", None] = UvmError("policy unavailable") with self.assertRaisesRegex(UvmError, "policy unavailable"): diff --git a/uvm/images.py b/uvm/images.py index 2bcfa81..259b4e5 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -8,6 +8,7 @@ import stat import tempfile import time from dataclasses import dataclass +from ipaddress import IPv4Address from pathlib import Path from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings @@ -35,6 +36,16 @@ _HOST_KEY_TYPES = ( ("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", +) + @dataclass(frozen=True, slots=True) class GuestAssets: @@ -150,6 +161,91 @@ class ImageStore: 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", + }: + 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 + @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: @@ -397,12 +493,16 @@ class ImageStore: 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) diff --git a/uvm/install.py b/uvm/install.py index a1b9fda..3237b3b 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -14,14 +14,14 @@ from pathlib import Path from .config import Settings from .errors import UvmError -from .images import GuestAssets +from .images import GUEST_NETWORK_TEMPLATE, GuestAssets 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-v2\n" +_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v3\n" _GUEST_PACKAGES = ( "ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev", "dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", @@ -383,10 +383,7 @@ class Installer: "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" - ), + "etc/systemd/network/10-uvm.network": GUEST_NETWORK_TEMPLATE, } for relative, contents in files.items(): path = root / relative diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index 6fc20da..bb18eb8 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -89,6 +89,7 @@ class LifecycleService: self._save(vm) disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib) self._images.provision_credentials(disk, vm.username, vm.password) + self._images.configure_guest_network(disk, vm.guest_ip) vm.disk_ready = True self._save(vm) self._boot(vm, assets.kernel) @@ -188,6 +189,7 @@ class LifecycleService: try: self._cleanup_runtime(vm) self._apply_pending_resize(vm) + self._images.configure_guest_network(Path(vm.disk), vm.guest_ip) vm.network, vm.bridge = str(self._settings.network), self._settings.bridge self._boot(vm, kernel) return vm -- 2.54.0