Compare commits

..
9 Commits
Author SHA1 Message Date
kstyagi 660ba9efee feat: enhance guest network configuration and recovery process for UVM 2026-09-23 08:20:35 +05:30
kstyagi dca3cbde2f feat: add host-backed entropy device support and update kernel configuration 2026-09-23 07:00:22 +05:30
kstyagi eb6119c797 feat: add graceful shutdown endpoint for UVM API server and update documentation 2026-09-23 06:30:58 +05:30
kstyagi 0f72ac693a fix: correct formatting and improve clarity in documentation and test descriptions 2026-09-23 06:25:35 +05:30
kstyagi ed9c5306af feat: enhance networking capabilities with improved egress handling and automatic host sharing rules 2026-09-23 06:23:49 +05:30
kstyagi 534a78f2a9 feat: enhance network management and lifecycle handling with improved egress setup and validation 2026-09-23 06:23:36 +05:30
kstyagi bd92573158 feat: enhance Ubuntu rootfs build process with improved logging and error handling 2026-09-22 09:30:55 +05:30
kstyagi 1d3644ab7c feat: update UVM installation and usage documentation; enhance image handling tests 2026-09-22 00:33:34 +05:30
kstyagi 4bf4c0853c 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.
2026-09-22 00:33:05 +05:30
25 changed files with 1827 additions and 296 deletions
+168 -34
View File
@@ -211,7 +211,8 @@ Default runtime storage is `/var/lib/uvm`:
│ └── jailer │ └── jailer
├── images/ ├── images/
│ ├── vmlinux │ ├── vmlinux
│ └── ubuntu.ext4 │ ├── ubuntu.ext4
│ └── ubuntu-build.log
├── vms/ ├── vms/
│ └── vm-<uuid>/ │ └── vm-<uuid>/
│ ├── rootfs.ext4 │ ├── rootfs.ext4
@@ -335,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`. 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 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 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**. 5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**.
6. Apply/read back the nftables bridge policy, then activate the TAP. 6. Apply/read back the nftables bridge policy, then activate the TAP.
7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`, 7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`,
@@ -372,9 +373,19 @@ not needed. It never recopies the rootfs, reprovisions credentials, or
regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes
survive stop/start; guest RAM does not. survive stop/start; guest RAM does not.
Start on a healthy running VM synchronizes policy without spawning another After runtime cleanup confirms process exit and any pending resize finishes,
VMM. An ambiguous process or interrupted live boot/stop is not adopted as a start specializes an original UVM Ubuntu network profile with the retained
successful start. Failed resume retains the private disk for retry. Restart 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
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 performs stop and start under one operation lock, not two independent public
calls with a race between them. calls with a race between them.
@@ -487,10 +498,19 @@ The `firecracker/` package has three separate responsibilities.
2. `PUT /boot-source` 2. `PUT /boot-source`
3. `PUT /drives/rootfs` 3. `PUT /drives/rootfs`
4. `PUT /network-interfaces/eth0` 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 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 ARM64 guests also receive `keep_bootcon`, as recommended by Firecracker's
getting-started guide; both architectures use `console=ttyS0`. getting-started guide; both architectures use `console=ttyS0`.
@@ -545,10 +565,41 @@ 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 Directory ownership/modes and account-file writes are read back before the
disk can be marked ready. The default `root` account must already exist. 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 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. 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. 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 Model
[`integrity.py`](uvm/integrity.py) has two concepts: [`integrity.py`](uvm/integrity.py) has two concepts:
@@ -557,14 +608,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 - A local `integrity.json` manifest recording artifact hashes and whether the
installation was verified. installation was verified.
Strict installation requires all of these values: Strict installation requires these values for downloaded artifacts:
```text ```text
UVM_FIRECRACKER_SHA256 UVM_FIRECRACKER_SHA256
UVM_KERNEL_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 `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 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 that manifest as publisher trust. Use `install --force` with trusted digests to
@@ -592,7 +648,7 @@ Linux bridge: uvm0 (10.42.0.1/24 by default)
| |
iptables forwarding + MASQUERADE iptables forwarding + MASQUERADE
| |
host default uplink host IPv4 route for the destination (current uplink)
| |
internet internet
``` ```
@@ -607,7 +663,7 @@ internet
- Bridge creation or validation that an existing interface is actually a - Bridge creation or validation that an existing interface is actually a
Linux bridge. Linux bridge.
- Gateway address setup and IPv4 forwarding. - 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:<id>`), preparation while down, activation, - TAP ownership aliases (`uvm:<id>`), preparation while down, activation,
deactivation, cleanup, and error reporting. deactivation, cleanup, and error reporting.
@@ -616,6 +672,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 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. 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 ### Native Bridge Policy
The supported path is a **software Linux bridge** with STP disabled. Each The supported path is a **software Linux bridge** with STP disabled. Each
@@ -672,17 +745,19 @@ including on conntrack-tools 1.4.8, where `--list` is not a supported option.
### Coexistence and Recovery ### Coexistence and Recovery
Existing `iptables` egress MASQUERADE/forwarding remains unchanged. The new Shared `iptables` egress MASQUERADE/forwarding is separate from private bridge
policy adds no DNAT, public listener, or host-port mapping and does not replace policy. The policy adds no DNAT, public listener, or host-port mapping and does
the host firewall manager. Public connectivity, DNS, guest reverse proxies/TLS, not replace the host firewall manager. Public inbound connectivity, DNS
and integration with UFW/firewalld/Docker/libvirt remain out of scope. An 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 administrator's external routing/NAT can still affect reachability; do not
infer source-CIDR restrictions or complete public-exposure protection from the infer source-CIDR restrictions or complete public-exposure protection from the
use of private addresses. Operators must choose a non-conflicting topology. use of private addresses. Operators must choose a non-conflicting topology.
If an external actor loses or changes UVM rules, recovery is explicit If an external actor loses or changes private policy, recovery is explicit
`ports sync`, not a promised daemon repair. An `enforced` snapshot cannot `ports sync`; `start` also restores shared networking for a running VM. An
prevent later root-level changes or prove that other rules allow traffic. `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; [FIREWALL.md](FIREWALL.md) retains the original proposal/backend evaluation;
do not import its unimplemented topology checks into current documentation. do not import its unimplemented topology checks into current documentation.
@@ -694,12 +769,49 @@ handling, downloads, and integrity-manifest publication.
`Settings` detects and normalizes the host architecture at construction time `Settings` detects and normalizes the host architecture at construction time
(`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures. (`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures.
The same architecture selects the Firecracker release archive, guest kernel, The same architecture selects the Firecracker release archive, guest kernel,
rootfs, and architecture-specific boot arguments. Default guest URLs use native rootfs packages, and architecture-specific boot arguments. The default
Firecracker's `img/quickstart_guide/<architecture>/` assets: `kernels/vmlinux.bin` kernel URL is pinned to Firecracker's published
and `rootfs/bionic.rootfs.ext4`. Kernel and rootfs URL overrides are independent; `firecracker-ci/v1.15/<architecture>/vmlinux-6.1.155` artifacts. The CI artifact
custom assets and trusted digests must match the host architecture. ARM64 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. 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 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`.
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
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.
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 The host package list includes `nftables` and `conntrack` as well as the
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux
tools. UVM adds no reconciliation daemon or tested external firewall-manager tools. UVM adds no reconciliation daemon or tested external firewall-manager
@@ -744,6 +856,7 @@ or secret manager when that matters.
| `GET` | `/` | `server.py` | Redirect to `/dashboard` | | `GET` | `/` | `server.py` | Redirect to `/dashboard` |
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell | | `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
| `GET` | `/health` | `health.py` | Unauthenticated liveness response | | `GET` | `/health` | `health.py` | Unauthenticated liveness response |
| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown |
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks | | `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
| `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `POST` | `/install` | `installation.py` | `{ "force": false }` body |
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status | | `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
@@ -765,6 +878,12 @@ All management routes use `get_authorized_application()` and require:
X-UVM-Token: <UVM_API_TOKEN> X-UVM-Token: <UVM_API_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 Request schemas reject unexpected fields. `UvmError` instances are rendered as
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
422 response format. Create accepts `name: str | null`, `ports: list | null`, 422 response format. Create accepts `name: str | null`, `ports: list | null`,
@@ -892,8 +1011,18 @@ sparse ext4 images when the tools are installed: default 30 GiB creation,
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures. growth, shrinking, geometry, and retained data/password-hash/host-key fixtures.
`test_images.py` uses temporary ext4 images to verify custom-user creation, `test_images.py` uses temporary ext4 images to verify custom-user creation,
UID/GID allocation, account/home metadata, existing accounts, and password hashes. 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, 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, Lifecycle resize tests use fake processes/networking to verify ordering,
single-lock ownership, identity preservation, pending intent, and failure recovery. 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 ### Isolated Packet Tests
@@ -901,17 +1030,18 @@ The opt-in packet harness must run **only in disposable isolated user/network
namespaces**. It verifies namespace separation before creating links or namespaces**. It verifies namespace separation before creating links or
changing rules, and never falls back to the parent host namespace. It may changing rules, and never falls back to the parent host namespace. It may
require privileges (or enabled unprivileged user namespaces) plus `unshare`, require privileges (or enabled unprivileged user namespaces) plus `unshare`,
`ip`, `bridge`, `nft`, `conntrack`, and `sysctl`: `ip`, `bridge`, `nft`, `iptables`, `conntrack`, and `sysctl`:
```sh ```sh
UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v 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 The harness uses bridge-connected veths in place of guest TAPs and two simulated
egress without NAT. It exercises host/peer service traffic, both-direction uplinks. It exercises NAT with a pre-existing terminal FORWARD drop, offline
close of existing streams while preserving conntrack/egress, IPv6/spoof startup, uplink changes and reconnects for host/guest TCP and guest UDP/53.
blocking, quarantine, and offline cleanup. It does not boot a microVM or It also checks host/peer service traffic, both-direction close of existing
validate real TAP/NAT deployment. 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 The packet test skipped in this workspace because namespace creation was
denied (`Operation not permitted`). Document that permission limitation, not denied (`Operation not permitted`). Document that permission limitation, not
@@ -925,8 +1055,10 @@ on an isolated Linux host:
1. Strict checked installation. 1. Strict checked installation.
2. `create` and successful guest boot. 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
4. SSH with unique guest host keys. 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. 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 6. Live private-port close in both directions, empty policy, and explicit sync
after deliberately lost rules inside the isolated setup. after deliberately lost rules inside the isolated setup.
@@ -961,7 +1093,8 @@ on an isolated Linux host:
| Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access | | Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access |
| VM is `dead` in `list` | PID/start token in state, `/proc/<pid>`, VM log | | VM is `dead` in `list` | PID/start token in state, `/proc/<pid>`, VM log |
| Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID | | 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 |
| 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 | | 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 | | 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 | | API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files |
@@ -985,9 +1118,10 @@ The code is intentionally honest about current limits:
- Guest health is inferred from VMM process state, not application readiness. - Guest health is inferred from VMM process state, not application readiness.
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy. - No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
- No guest snapshots, extra disks, production image builder, or SSH-key injection. - 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,
guest reverse proxy/TLS, and external firewall integration remain out of scope. DNS publication, guest reverse proxy/TLS, and external firewall integration
- Existing egress `iptables` rules are incremental and unchanged; only the remain out of scope.
- Shared egress `iptables` rules are incremental and idempotent; only the
private bridge policy is atomically rendered per VM. private bridge policy is atomically rendered per VM.
- No autostart, background reconciliation, or guest-coordinated shutdown. - No autostart, background reconciliation, or guest-coordinated shutdown.
- The JSON inventory is suitable for one host, not distributed coordination. - The JSON inventory is suitable for one host, not distributed coordination.
+124 -24
View File
@@ -3,8 +3,8 @@
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu. `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, 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 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 create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
enforced private-IPv4 TCP port allowlists. host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
This directory is a self-contained project. All UVM code, tests, packaging This directory is a self-contained project. All UVM code, tests, packaging
metadata, launcher scripts, and documentation live here. metadata, launcher scripts, and documentation live here.
@@ -48,12 +48,25 @@ before downloading VM artifacts. Boot operations additionally check that the
native nftables bridge policy is supported; installing packages alone is not native nftables bridge policy is supported; installing packages alone is not
a connectivity or isolation test. a connectivity or isolation test.
Firecracker, the guest kernel, and the Ubuntu rootfs are selected automatically Firecracker and the guest kernel are selected automatically for the host
for the host architecture. On NVIDIA DGX Spark, UVM uses the ARM64 assets. 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 `arm64` and `amd64` architecture names are normalized to `aarch64` and
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides `x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
must point to assets compatible with the host architecture. 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 ## Trusted Installation
Installation requires trusted SHA-256 digests by default. Supply digests from Installation requires trusted SHA-256 digests by default. Supply digests from
@@ -64,21 +77,42 @@ the privileged installer:
sudo env \ sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \ UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \ UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install ./uvm.py install
``` ```
Use `--force` to redownload Firecracker and guest assets when replacing an The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures,
image or refreshing artifacts: 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:
```sh ```sh
sudo env \ sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \ UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \ UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install --force ./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.
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 For local experimentation only, the old unverified quick-start behavior is
available through an explicit opt-out: available through an explicit opt-out:
@@ -232,8 +266,8 @@ well as new connections. This is packet dropping, not a guaranteed TCP RST or
immediate application disconnect. immediate application disconnect.
These rules govern access to the **guest private IP**. They do not start a 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,
reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not 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 yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required
ARP/ICMP traffic remain allowed; managed TAPs block IPv6. ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
@@ -280,6 +314,7 @@ The server exposes these routes:
| `GET` | `/` | Redirect to the local dashboard | | `GET` | `/` | Redirect to the local dashboard |
| `GET` | `/dashboard` | Serve the local management dashboard | | `GET` | `/dashboard` | Serve the local management dashboard |
| `GET` | `/health` | Unauthenticated liveness response | | `GET` | `/health` | Unauthenticated liveness response |
| `POST` | `/exit` | Gracefully stop the UVM API server |
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view | | `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
| `GET` | `/vms` | List VM records and observed status | | `GET` | `/vms` | List VM records and observed status |
@@ -341,6 +376,18 @@ curl \
http://127.0.0.1:8000/vms 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, Do not expose this server directly to an untrusted network. For remote access,
keep TLS enabled and consider placing it behind an additional authenticated keep TLS enabled and consider placing it behind an additional authenticated
reverse proxy. reverse proxy.
@@ -360,13 +407,41 @@ defaults are username `root` and password `root`. A custom image must provide:
- `sshd` - `sshd`
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config` - `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
- `/etc/group` and `/bin/sh` when creating a new user - `/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`.
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 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 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 unused GID. `/etc/gshadow` is updated when present. The default `root` account
must already exist in the image. 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, 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 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. host identities. Never rely on SSH private keys baked into a shared template.
@@ -382,13 +457,35 @@ Bridge: uvm0
TAPs: uvm-<unique suffix> TAPs: uvm-<unique suffix>
``` ```
It creates the bridge when absent, validates an existing bridge before using VMs share the host's IPv4 internet connection by default. When the host has
it, enables IPv4 forwarding, adds an egress masquerade rule, and adds matching internet access, guests can initiate outbound connections through it. When
forward rules for return traffic. Guest IPs, gateway, bridge name, and guest the host loses connectivity, guest internet access is lost too; the private
boot netmask derive from the configured CIDR. VM network remains usable. Reconnecting the host restores guest egress.
The existing `iptables` egress NAT/forwarding behavior is unchanged. Private UVM creates the bridge when absent, validates an existing bridge before using
VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`, 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.
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 `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 gateway, subnet, or bridge changes within the same registry. Private
port enforcement uses separate per-VM native nftables `bridge` tables, not port enforcement uses separate per-VM native nftables `bridge` tables, not
@@ -405,8 +502,9 @@ firewalld, Docker, or other external firewall managers remains deferred.
Do not offload traffic around the software bridge hooks. Do not offload traffic around the software bridge hooks.
There is no autostart service or daemon reconciliation. After externally lost There is no autostart service or daemon reconciliation. After externally lost
or changed UVM rules, inspect policy status and explicitly run `ports sync`. or changed private policy, inspect its status and explicitly run `ports sync`.
Read-only commands do not repair rules; sync does not boot stopped VMs. 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` Use one authoritative registry per bridge/subnet. Independent `UVM_BASE`
directories do not coordinate IP or MAC allocation with each other. directories do not coordinate IP or MAC allocation with each other.
@@ -433,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. replace the default `root` password immediately.
Credential provisioning applies only to VMs created by this version. Existing 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 Older registries are read and migrated on the next write. A missing/null
`ports` policy remains `None`/`unmanaged`, not an empty allowlist or an `ports` policy remains `None`/`unmanaged`, not an empty allowlist or an
@@ -466,10 +564,10 @@ Host and API settings can be overridden through environment variables:
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` | | `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_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_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_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 |
| `UVM_KERNEL_SHA256` | Trusted guest kernel 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_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development | | `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` | | `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` |
@@ -530,8 +628,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 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 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; validation. The harness uses veths in place of TAPs and simulated upstream
validate KVM, TAP, NAT, SSH, and guest connectivity on an isolated Linux host. 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 ## Current Boundaries
+209 -26
View File
@@ -29,6 +29,12 @@ Bridge: uvm0
Guest pool: 10.42.0.2 through 10.42.0.254 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 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 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. autostart service or a daemon that continuously repairs VM or firewall state.
@@ -135,29 +141,51 @@ 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 `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. 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 ## 4. Install UVM Assets
UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
rootfs template. It requires SHA-256 checksums by default because it downloads rootfs template. The default rootfs is built locally from authenticated Ubuntu
artifacts that will be used by a privileged process. 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) ### ARM64 Hosts (NVIDIA DGX Spark)
UVM detects the host architecture and selects matching Firecracker, kernel, UVM detects the host architecture and selects matching Firecracker and kernel
and rootfs downloads. NVIDIA DGX Spark uses the `aarch64` assets; the `arm64` downloads and native Ubuntu packages. NVIDIA DGX Spark uses `aarch64` artifacts
architecture alias is also accepted. The default ARM64 guest downloads are: 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: `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 Use the installation commands below with checksums for the ARM64 kernel
and the ARM64 Firecracker release archive. `UVM_KERNEL_URL` and and Firecracker release archive. `UVM_KERNEL_URL` overrides the kernel download;
`UVM_ROOTFS_URL` can override either download independently. Custom images must `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). 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), 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 and the rootfs must be an ARM64 ext4 filesystem. UVM adds `keep_bootcon` to the
guest boot arguments for ARM64 console output. 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 ### Scenario: Strict First Installation
Obtain trusted checksums independently, then run: Obtain trusted checksums independently, then run:
@@ -166,7 +194,6 @@ Obtain trusted checksums independently, then run:
sudo env \ sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \ UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \ UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install ./uvm.py install
``` ```
@@ -174,6 +201,13 @@ Installation prints the Firecracker, kernel, and rootfs paths under
`/var/lib/uvm`. Checksums and verification provenance are recorded in `/var/lib/uvm`. Checksums and verification provenance are recorded in
`/var/lib/uvm/integrity.json`. `/var/lib/uvm/integrity.json`.
The default build installs `debootstrap` and `ubuntu-keyring` on the host,
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 ### Scenario: Refresh Firecracker or Guest Assets
Use `--force` after changing a URL, replacing an image, or rotating checksums: Use `--force` after changing a URL, replacing an image, or rotating checksums:
@@ -182,12 +216,17 @@ Use `--force` after changing a URL, replacing an image, or rotating checksums:
sudo env \ sudo env \
UVM_FIRECRACKER_SHA256='<new-firecracker-archive-sha256>' \ UVM_FIRECRACKER_SHA256='<new-firecracker-archive-sha256>' \
UVM_KERNEL_SHA256='<new-kernel-sha256>' \ UVM_KERNEL_SHA256='<new-kernel-sha256>' \
UVM_ROOTFS_SHA256='<new-rootfs-sha256>' \
./uvm.py install --force ./uvm.py install --force
``` ```
`--force` downloads to temporary files and only replaces the existing artifact `--force` stages downloads and builds in temporary paths. Downloaded artifacts
after checksum verification succeeds. 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 ### Scenario: Local Experimentation Without Checksums
@@ -215,6 +254,19 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM
| `checksum is required` | Provide the requested `UVM_*_SHA256` variable. | | `checksum is required` | Provide the requested `UVM_*_SHA256` variable. |
| `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. | | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
| `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. | | `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 ## 5. Create VMs
@@ -245,7 +297,8 @@ create DNS records, and identifiers are not matched by ID prefix.
`--username` selects the guest login account. If a custom username is missing, `--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 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 A `--password` value is visible in process arguments and may enter shell
history. Prefer `--password-stdin`, which reads one line, strips the trailing history. Prefer `--password-stdin`, which reads one line, strips the trailing
@@ -415,6 +468,20 @@ guest image must include:
- An SSH server. - An SSH server.
- Standard passwd and shadow files, plus `/etc/ssh/sshd_config`. - Standard passwd and shadow files, plus `/etc/ssh/sshd_config`.
- `/etc/group` and `/bin/sh` for new users; the default `root` account must exist. - `/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 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. disk during creation and removes Firecracker's publicly known demo login key.
@@ -1040,7 +1107,6 @@ sudo env \
UVM_BRIDGE='uvm50' \ UVM_BRIDGE='uvm50' \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \ UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \ UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install ./uvm.py install
``` ```
@@ -1059,10 +1125,10 @@ automatically detects every conflict with host routes or other managers.
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` | | `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
| `UVM_KNOWN_HOSTS_FILES` | Override deletion's local SSH cache files; colon-separated absolute paths | | `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_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_FIRECRACKER_SHA256` | Strict Firecracker archive checksum |
| `UVM_KERNEL_SHA256` | Strict kernel 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_FIRECRACKER_BINARY_SHA256` | Optional local binary checksum |
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for local development | | `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for local development |
| `UVM_API_TOKEN` | Required API token | | `UVM_API_TOKEN` | Required API token |
@@ -1095,7 +1161,10 @@ Firecracker logs may include guest/application secrets; redact them before shari
## 15. Networking Troubleshooting ## 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 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 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` must have STP disabled and each TAP must have bridge-slave `group_fwd_mask`
@@ -1123,11 +1192,73 @@ sudo iptables -t nat -S POSTROUTING
sudo iptables -S FORWARD 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.
### 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
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: Common issues:
| Symptom | First checks | | 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. |
| 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. | | 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. | | 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. | | Existing bridge is rejected | Verify it is a software Linux bridge with STP disabled. |
@@ -1140,6 +1271,58 @@ External firewall integration (including UFW, firewalld, Docker, and libvirt)
is deferred. A UVM policy readback does not prove that external rules permit is deferred. A UVM policy readback does not prove that external rules permit
traffic, or that they will not later remove UVM's rules. 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='<trusted-firecracker-archive-sha256>' \
UVM_KERNEL_SHA256='<trusted-new-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 ## 16. Important Limitations
Keep these constraints in mind when deciding whether UVM fits a scenario: Keep these constraints in mind when deciding whether UVM fits a scenario:
@@ -1148,7 +1331,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. - No quotas or cgroup CPU, memory, I/O, or process limits are applied.
- Fractional CPU requests are advisory only. - Fractional CPU requests are advisory only.
- No autostart, background lifecycle/firewall reconciliation, or guest-coordinated stop. - 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, DNS publication, guest reverse proxy/TLS setup, DHCP, load balancing,
snapshots, multi-network tenancy, or multi-host scheduling. snapshots, multi-network tenancy, or multi-host scheduling.
- The API uses one shared bearer token, not individual users, roles, or tenants. - The API uses one shared bearer token, not individual users, roles, or tenants.
@@ -1181,7 +1364,7 @@ python3 -B -m unittest discover -s tests -v
The opt-in packet test runs **only in disposable isolated user/network The opt-in packet test runs **only in disposable isolated user/network
namespaces**, which may require privileges. It needs `unshare`, `ip`, namespaces**, which may require privileges. It needs `unshare`, `ip`,
`bridge`, `nft`, `conntrack`, and `sysctl`: `bridge`, `nft`, `iptables`, `conntrack`, and `sysctl`:
```sh ```sh
UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
@@ -1189,10 +1372,10 @@ UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration
Namespace creation was denied in this workspace (`Operation not permitted`), Namespace creation was denied in this workspace (`Operation not permitted`),
so the packet test skipped. That is a permission limitation, not real-packet 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 or real-host validation. The harness uses veths as TAP stand-ins and simulated
egress without NAT; it does not boot Firecracker or validate the host's real upstreams to check NAT, offline startup, reconnects and uplink changes. It does
TAP/NAT setup. Run actual VM/SSH/egress checks on an isolated Linux host before not boot Firecracker or validate the host's real TAP setup. Run actual
relying on the deployment. VM/SSH/egress checks on an isolated Linux host before relying on the deployment.
## 19. Get Help ## 19. Get Help
+70 -10
View File
@@ -2,16 +2,18 @@
Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v 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
namespaces (root, or unprivileged user namespaces). No sudo, installation, network namespaces (root, or unprivileged user namespaces). No sudo, installation,
/run/netns mounts, host firewall changes or br_netfilter sysctls are used. /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 Bridge-connected veths stand in for guest TAPs. A third namespace with two
guest egress without NAT. Ordinary test runs skip without executing commands. 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 from __future__ import annotations
import contextlib import contextlib
import errno
import json import json
import os import os
import re import re
@@ -62,6 +64,13 @@ class SocketEndpoint:
except TimeoutError: except TimeoutError:
sock.close() sock.close()
return False 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: except BaseException:
sock.close() sock.close()
raise raise
@@ -206,6 +215,7 @@ def _sandbox(parent_fd):
from uvm.domain import PortRule, VmRecord from uvm.domain import PortRule, VmRecord
from uvm.errors import UvmError from uvm.errors import UvmError
from uvm.firewall import FirewallManager from uvm.firewall import FirewallManager
from uvm.network import NetworkManager
from uvm.system import CommandRunner from uvm.system import CommandRunner
runner = CommandRunner(emit=None) runner = CommandRunner(emit=None)
@@ -220,11 +230,12 @@ def _sandbox(parent_fd):
manager = FirewallManager(settings, runner) manager = FirewallManager(settings, runner)
manager.check_available() manager.check_available()
run("ip", "link", "set", "lo", "up") run("ip", "link", "set", "lo", "up")
run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0") # All changes are inside the freshly verified namespace. A pre-existing
run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge) # terminal drop catches regressions that append ineffective forward rules.
run("ip", "link", "set", settings.bridge, "up") run("iptables", "-P", "FORWARD", "DROP")
# This IPv4 sysctl belongs only to the freshly verified network namespace. run("iptables", "-A", "FORWARD", "-j", "DROP")
run("sysctl", "-w", "net.ipv4.ip_forward=1") assert not run("ip", "route", "show", "default").strip()
NetworkManager(settings, runner).ensure_bridge()
metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway), metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway),
socket="", config="", log="", network=str(settings.network), bridge=settings.bridge) 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") run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000")
endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)]) 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(vm)
manager.apply(peer_vm) manager.apply(peer_vm)
for tap in (vm.tap, peer_vm.tap): 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") guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query")
query = outside.request("udp_recv", name="dns") query = outside.request("udp_recv", name="dns")
assert query[0] == "dns-query" 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") 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" assert guest.request("udp_recv", name="dns")[0] == "dns-reply"
guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054) 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) assert endpoint.request("ping", ip=target), (endpoint, target)
print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True) 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[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"
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 # Spoofed source MACs must not reach FDB learning, including the local
# link-local path which can skip bridge prerouting entirely. # link-local path which can skip bridge prerouting entirely.
spoof = bytes.fromhex("02aabbccddee") spoof = bytes.fromhex("02aabbccddee")
@@ -393,7 +453,7 @@ class PrivatePortNamespaceTests(unittest.TestCase):
probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5) probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5)
if probe.returncode: if probe.returncode:
self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}") 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: if missing:
self.skipTest(f"integration tools unavailable: {', '.join(missing)}") self.skipTest(f"integration tools unavailable: {', '.join(missing)}")
with open("/proc/self/ns/net", "rb") as parent: with open("/proc/self/ns/net", "rb") as parent:
+6 -6
View File
@@ -27,17 +27,17 @@ class ArchitectureSettingsTests(unittest.TestCase):
settings = factory() settings = factory()
base = ( base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
f"{architecture}" f"{architecture}"
) )
self.assertEqual(settings.architecture, 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, f"{base}/rootfs/bionic.rootfs.ext4") self.assertEqual(settings.rootfs_url, "")
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None: def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
kernel = "https://example.com/custom-arm64-kernel" kernel = "https://example.com/custom-arm64-kernel"
rootfs = "https://example.com/custom-arm64-rootfs.ext4" 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 ( for overrides in (
{"UVM_KERNEL_URL": kernel}, {"UVM_KERNEL_URL": kernel},
{"UVM_ROOTFS_URL": rootfs}, {"UVM_ROOTFS_URL": rootfs},
@@ -52,11 +52,11 @@ class ArchitectureSettingsTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
settings.kernel_url, 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( self.assertEqual(
settings.rootfs_url, 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: def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
+37
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
import unittest import unittest
from ipaddress import IPv4Address, IPv4Network from ipaddress import IPv4Address, IPv4Network
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings from uvm.config import Settings
from uvm.domain import VmRecord from uvm.domain import VmRecord
from uvm.errors import FirecrackerError
from uvm.firecracker.api import FirecrackerClient from uvm.firecracker.api import FirecrackerClient
from uvm.firecracker.config import build_config, vcpu_count 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.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments) self.assertIn("console=ttyS0", arguments)
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", 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: def test_builds_a_writable_vm_disk_configuration(self) -> None:
vm = VmRecord( vm = VmRecord(
@@ -101,6 +107,7 @@ class FirecrackerConfigTests(unittest.TestCase):
"/boot-source", "/boot-source",
"/drives/rootfs", "/drives/rootfs",
"/network-interfaces/eth0", "/network-interfaces/eth0",
"/entropy",
"/actions", "/actions",
], ],
) )
@@ -110,6 +117,36 @@ class FirecrackerConfigTests(unittest.TestCase):
) )
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"}) 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: def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
settings = Settings( settings = Settings(
network=IPv4Network("10.50.0.0/16"), network=IPv4Network("10.50.0.0/16"),
+213
View File
@@ -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])
+72 -1
View File
@@ -130,6 +130,44 @@ class ImageStoreTests(unittest.TestCase):
self.assertIn("Match User service\n PasswordAuthentication yes", updated) self.assertIn("Match User service\n PasswordAuthentication yes", updated)
self.assertNotIn("ChallengeResponseAuthentication 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)
@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: def test_removes_only_the_public_firecracker_demo_key(self) -> None:
own_key = "ssh-ed25519 AAAA-own-key developer@example" own_key = "ssh-ed25519 AAAA-own-key developer@example"
@@ -241,6 +279,8 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
), ),
"/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n", "/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/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", "/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", "/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n",
} }
@@ -251,7 +291,8 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
path = root / guest_path.lstrip("/") path = root / guest_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8") 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: with self.template.open("wb") as image:
image.truncate(16 * 1024 * 1024) image.truncate(16 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True) self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True)
@@ -305,6 +346,15 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
self.assertLessEqual(int(shadow[2]), int(time.time() // 86400)) self.assertLessEqual(int(shadow[2]), int(time.time() // 86400))
self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""]) self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""])
self.assert_password("codemate-user", "custom-password") 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)
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"): for guest_path in ("/etc/passwd", "/etc/group"):
self.assert_metadata(guest_path, 0o644, 0, 0) self.assert_metadata(guest_path, 0o644, 0, 0)
for guest_path in ("/etc/shadow", "/etc/gshadow"): for guest_path in ("/etc/shadow", "/etc/gshadow"):
@@ -332,6 +382,10 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
self.assertEqual(self.contents(guest_path), self.files[guest_path]) self.assertEqual(self.contents(guest_path), self.files[guest_path])
self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:]) self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:])
self.assert_password(username, "changed-password") 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(): for line in self.files["/etc/shadow"].splitlines():
if line.split(":", 1)[0] != username: if line.split(":", 1)[0] != username:
self.assertIn(line + "\n", self.contents("/etc/shadow")) self.assertIn(line + "\n", self.contents("/etc/shadow"))
@@ -375,3 +429,20 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"]) 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/passwd"), self.files["/etc/passwd"])
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"]) 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")
+260 -4
View File
@@ -2,7 +2,10 @@ from __future__ import annotations
import hashlib import hashlib
import io import io
import json
import os import os
import shutil
import sys
import tarfile import tarfile
import tempfile import tempfile
import unittest import unittest
@@ -13,10 +16,11 @@ from unittest.mock import Mock, patch
from uvm.config import Settings from uvm.config import Settings
from uvm.errors import UvmError from uvm.errors import UvmError
from uvm.install import Installer from uvm.images import ImageStore
from uvm.integrity import load_manifest 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.state import StateStore
from uvm.system import CommandRunner from uvm.system import CommandResult, CommandRunner
class InstallerArchitectureTests(unittest.TestCase): class InstallerArchitectureTests(unittest.TestCase):
@@ -56,6 +60,10 @@ class InstallerArchitectureTests(unittest.TestCase):
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(), firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(), kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
rootfs_sha256=hashlib.sha256(rootfs_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 = ( guest_base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
@@ -64,7 +72,8 @@ class InstallerArchitectureTests(unittest.TestCase):
payloads = { payloads = {
"https://github.com/firecracker-microvm/firecracker/releases/download/" "https://github.com/firecracker-microvm/firecracker/releases/download/"
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes, 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, f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
} }
runner = Mock(spec=CommandRunner) runner = Mock(spec=CommandRunner)
@@ -111,3 +120,250 @@ class InstallerArchitectureTests(unittest.TestCase):
check_kvm.assert_not_called() check_kvm.assert_not_called()
download.assert_not_called() download.assert_not_called()
self.assertFalse(settings.integrity_manifest_path.exists()) 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, "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))
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":
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/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/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary",
"var/lib/dpkg/status": status,
"var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n",
}
if self.failure == "metadata":
files["var/lib/dpkg/status"] = ""
self.write_files(root, files)
(root / "usr/bin/sudo").chmod(0o4755)
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")
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("--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)
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)
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=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)
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]
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(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))
+52 -1
View File
@@ -307,6 +307,9 @@ class LifecycleTestCase(unittest.TestCase):
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk, self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
)) ))
self.provision = self.enterContext(patch.object(self.images, "provision_credentials")) 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.copy_disk = self.enterContext(patch.object(
self.images, "create_vm_disk", wraps=self.images.create_vm_disk, 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") raise AssertionError("cannot resize a running or ambiguous VM")
self.disk_sizes[disk] = storage_mib * 1024 * 1024 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: def create_vm(self, **kwargs) -> VmRecord:
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs)) 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: def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None:
vm = self.create_vm() vm = self.create_vm()
self.assert_call_order( 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", "network.activate_tap", "process.start", "process.spawn", "process.ready",
"client.configure", "firewall.status", "client.configure", "firewall.status",
) )
@@ -499,6 +510,46 @@ class LifecycleTests(LifecycleTestCase):
self.assertEqual(spawned.operation, "configuring") self.assertEqual(spawned.operation, "configuring")
self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid) 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: def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None:
self.host.failures["firewall.apply", None] = UvmError("policy unavailable") self.host.failures["firewall.apply", None] = UvmError("policy unavailable")
with self.assertRaisesRegex(UvmError, "policy unavailable"): with self.assertRaisesRegex(UvmError, "policy unavailable"):
+26 -1
View File
@@ -385,7 +385,7 @@ class LifecyclePortTests(LifecycleTestCase):
self.assertEqual(self.settings.state_path.read_bytes(), before) self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, []) 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() vm = self.create_vm()
del self.network.firewall.tables[vm.id] del self.network.firewall.tables[vm.id]
self.host.events.clear() self.host.events.clear()
@@ -395,9 +395,34 @@ class LifecyclePortTests(LifecycleTestCase):
self.assertNotIn("process.start", self.host.actions(vm.id)) self.assertNotIn("process.start", self.host.actions(vm.id))
self.assertNotIn("process.terminate", self.host.actions(vm.id)) self.assertNotIn("process.terminate", self.host.actions(vm.id))
self.assertNotIn("client.configure", 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.provision.call_count, 1)
self.assertEqual(self.copy_disk.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): class LifecycleBulkTests(LifecycleTestCase):
def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None: def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None:
+75 -39
View File
@@ -5,6 +5,7 @@ import unittest
from copy import deepcopy from copy import deepcopy
from dataclasses import replace from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network from ipaddress import IPv4Address, IPv4Network
from unittest.mock import patch
from uvm.config import Settings from uvm.config import Settings
from uvm.domain import PortRule, VmRecord from uvm.domain import PortRule, VmRecord
@@ -22,6 +23,10 @@ class FakeRunner:
self.links: dict[str, dict] = {} self.links: dict[str, dict] = {}
self.inventory: str | None = None self.inventory: str | None = None
self.failures: dict[tuple[str, ...], BaseException] = {} 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: def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
del capture, timeout del capture, timeout
@@ -68,11 +73,21 @@ class FakeRunner:
elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"): elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"):
pass pass
elif args[0] == "iptables": 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"): elif args != ("ip", "route", "show", "default"):
raise AssertionError(f"unexpected host command: {args}") raise AssertionError(f"unexpected host command: {args}")
if args == ("ip", "route", "show", "default"): 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) return CommandResult(args=args, returncode=0)
@@ -127,44 +142,61 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"), ("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
self.runner.calls, self.runner.calls,
) )
self.assertIn( self.assertIn(("sysctl", "-w", "net.ipv4.ip_forward=1"), self.runner.calls)
( nat, = self.runner.rules["nat", "POSTROUTING"]
"iptables", self.assertEqual(nat[:6], ("-s", "10.42.0.0/24", "!", "-o", "uvm0", "-m"))
"-A", self.assertEqual(nat[-2:], ("-j", "MASQUERADE"))
"FORWARD", inbound, outbound = self.runner.rules["filter", "FORWARD"]
"-i", self.assertEqual(outbound[:4], ("-i", "uvm0", "-s", "10.42.0.0/24"))
"uvm0", self.assertEqual(inbound[:8], (
"-o", "-o", "uvm0", "-d", "10.42.0.0/24", "-m", "conntrack",
"eth0", "--ctstate", "ESTABLISHED,RELATED",
"-s", ))
"10.42.0.0/24", self.assertEqual(inbound[-2:], ("-j", "ACCEPT"))
"-j", self.assertEqual(outbound[-2:], ("-j", "ACCEPT"))
"ACCEPT",
), def test_egress_is_installed_offline_and_survives_uplink_changes_without_extra_rules(self) -> None:
self.runner.calls, self.runner.default_routes = ""
) self.network.ensure_bridge()
checks = [ offline_rules = deepcopy(self.runner.rules)
call self.assertEqual([len(rules) for rules in offline_rules.values()], [1, 2])
for call in self.runner.calls
if call[:5] == ("iptables", "-t", "nat", "-C", "POSTROUTING") 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.runner.rules["filter", "FORWARD"] = list(existing)
self.assertIn( self.network.ensure_bridge()
( rules = list(self.runner.rules["filter", "FORWARD"])
"iptables", self.assertEqual(rules[2:], existing)
"-t", self.assertTrue(all("uvm-egress" in rule and rule[-1] == "ACCEPT" for rule in rules[:2]))
"nat", self.network.ensure_bridge()
"-A", self.assertEqual(self.runner.rules["filter", "FORWARD"], rules)
"POSTROUTING",
"-s", def test_iptables_inspection_errors_are_not_treated_as_missing_rules(self) -> None:
"10.42.0.0/24", check = ("iptables", "--wait", "5", "-t", "nat", "-C", "POSTROUTING",
"-o", "-s", "10.42.0.0/24", "!", "-o", "uvm0",
"eth0", "-m", "comment", "--comment", "uvm-egress", "-j", "MASQUERADE")
"-j", with patch.object(self.runner, "run", return_value=CommandResult(check, 4, stderr="permission denied")) as run:
"MASQUERADE", with self.assertRaisesRegex(CommandError, "could not inspect.*permission denied"):
), self.network._ensure_masquerade()
self.runner.calls, self.assertEqual(run.call_count, 1)
)
def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None: def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None:
settings = Settings( settings = Settings(
@@ -178,6 +210,10 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"), ("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
runner.calls, 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: def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"): for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"):
+39 -8
View File
@@ -7,7 +7,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import Mock, patch
from uvm.cli import build_parser, main from uvm.cli import build_parser, main
from uvm.config import Settings from uvm.config import Settings
@@ -99,19 +99,31 @@ class ServerCliTests(unittest.TestCase):
application = fake_application(api_token="test-token") application = fake_application(api_token="test-token")
application.settings.api_tls_cert = certificate application.settings.api_tls_cert = certificate
application.settings.api_tls_key = key 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 ( with (
patch("uvm.server._validate_server_settings"), 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.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_server(application, host="0.0.0.0", port=8443)
run_mock.assert_called_once() fake_uvicorn.Config.assert_called_once_with(
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate)) api,
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key)) 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: def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
for token in ("s\u00e9cret", "contains space", ""): for token in ("s\u00e9cret", "contains space", ""):
@@ -300,7 +312,8 @@ class ApiRouterTests(unittest.TestCase):
) )
application.state_store = SimpleNamespace(initialize=lambda: None) application.state_store = SimpleNamespace(initialize=lambda: None)
self.headers = {"X-UVM-Token": "test-token"} 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) self.addCleanup(self.client.close)
def test_health_is_available_without_credentials(self) -> None: 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", {}),
("POST", "/vms/test-vm/ports/sync", {}), ("POST", "/vms/test-vm/ports/sync", {}),
("GET", "/diagnostics", {}), ("GET", "/diagnostics", {}),
("POST", "/exit", {}),
("POST", "/install", {}), ("POST", "/install", {}),
): ):
for headers in ({}, {"X-UVM-Token": "wrong-token"}): 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.assertEqual(authorized.json()[0]["id"], "vm-test")
self.assertNotIn("password", authorized.json()[0]) 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: def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
created = self.client.post( created = self.client.post(
"/vms", "/vms",
@@ -752,6 +782,7 @@ class ApiRouterTests(unittest.TestCase):
("/", "get"), ("/", "get"),
("/dashboard", "get"), ("/dashboard", "get"),
("/health", "get"), ("/health", "get"),
("/exit", "post"),
("/install", "post"), ("/install", "post"),
("/vms", "get"), ("/vms", "get"),
("/vms", "post"), ("/vms", "post"),
+4
View File
@@ -14,6 +14,10 @@ class HealthResponse(BaseModel):
status: str = "ok" status: str = "ok"
class ServerExitResponse(BaseModel):
status: str = "stopping"
class InstallRequest(BaseModel): class InstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
+5 -4
View File
@@ -74,8 +74,9 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
install = subcommands.add_parser( install = subcommands.add_parser(
"install", "install",
description=( description=(
"Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 " "Set UVM_FIRECRACKER_SHA256 and UVM_KERNEL_SHA256 to trusted digests. "
"to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit " "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." "development-only opt-out."
), ),
help="install dependencies and assets; SHA-256 values are required by default", 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( install.add_argument(
"--force", "--force",
action="store_true", 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") 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( create.add_argument(
"--username", "--username",
default=application.settings.default_ssh_user, 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 = create.add_mutually_exclusive_group()
credentials.add_argument( credentials.add_argument(
+5 -5
View File
@@ -10,7 +10,9 @@ from pathlib import Path
from .errors import ConfigurationError 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 MIN_STORAGE_MIB = 30 * 1024
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024) MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
@@ -31,7 +33,7 @@ class Settings:
default_vcpu: float = 1 default_vcpu: float = 1
default_ssh_user: str = "root" default_ssh_user: str = "root"
kernel_url: str = "" kernel_url: str = ""
rootfs_url: str = "" rootfs_url: str = "" # Empty selects the locally built, package-managed Ubuntu image.
firecracker_sha256: str | None = None firecracker_sha256: str | None = None
firecracker_binary_sha256: str | None = None firecracker_binary_sha256: str | None = None
kernel_sha256: str | None = None kernel_sha256: str | None = None
@@ -55,9 +57,7 @@ class Settings:
# Resolve defaults at construction time, while preserving individual URL overrides. # Resolve defaults at construction time, while preserving individual URL overrides.
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}" asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
if not self.kernel_url: 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 not self.rootfs_url:
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
if self.gateway not in self.network: if self.gateway not in self.network:
raise ConfigurationError( raise ConfigurationError(
+2 -2
View File
@@ -193,7 +193,7 @@
<form id="create-form" class="create-layout"> <form id="create-form" class="create-layout">
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section> <div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section> <section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory. Credentials are applied to the machine’s private disk.</p></section> <section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div> <section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside> <aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
</form> </form>
@@ -202,7 +202,7 @@
<section id="page-system" class="page" aria-labelledby="system-heading" hidden> <section id="page-system" class="page" aria-labelledby="system-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div> <div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div>
<div id="health-summary" class="compact-metrics"></div> <div id="health-summary" class="compact-metrics"></div>
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install the Firecracker binary, guest kernel, and root filesystem, ready for your next machine.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Reinstall with force enabled.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div> <div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install Firecracker, a guest kernel, and a root filesystem. The default Ubuntu 22.04 image includes SSH, sudo, and apt.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Refresh downloads and rebuild the Ubuntu image.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
</section> </section>
</main> </main>
</div> </div>
+4
View File
@@ -35,12 +35,15 @@ class FirecrackerClient:
boot = config["boot-source"] boot = config["boot-source"]
drives = config["drives"] drives = config["drives"]
interfaces = config["network-interfaces"] interfaces = config["network-interfaces"]
entropy = config.get("entropy")
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping): if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
raise FirecrackerError("invalid Firecracker configuration") raise FirecrackerError("invalid Firecracker configuration")
if not isinstance(drives, list) or not drives: if not isinstance(drives, list) or not drives:
raise FirecrackerError("Firecracker configuration has no root drive") raise FirecrackerError("Firecracker configuration has no root drive")
if not isinstance(interfaces, list) or not interfaces: if not isinstance(interfaces, list) or not interfaces:
raise FirecrackerError("Firecracker configuration has no network interface") raise FirecrackerError("Firecracker configuration has no network interface")
if not isinstance(entropy, Mapping):
raise FirecrackerError("Firecracker configuration has no valid entropy device")
self._request( self._request(
"PUT", "PUT",
@@ -54,6 +57,7 @@ class FirecrackerClient:
self._request("PUT", "/boot-source", boot) self._request("PUT", "/boot-source", boot)
self._request("PUT", "/drives/rootfs", drives[0]) self._request("PUT", "/drives/rootfs", drives[0])
self._request("PUT", "/network-interfaces/eth0", interfaces[0]) self._request("PUT", "/network-interfaces/eth0", interfaces[0])
self._request("PUT", "/entropy", entropy)
self._request("PUT", "/actions", {"action_type": "InstanceStart"}) self._request("PUT", "/actions", {"action_type": "InstanceStart"})
def _request(self, method: str, path: str, body: object) -> None: def _request(self, method: str, path: str, body: object) -> None:
+2
View File
@@ -70,6 +70,8 @@ def build_config(
"host_dev_name": vm.tap, "host_dev_name": vm.tap,
} }
], ],
# Supply host-backed randomness before guest services such as sshd start.
"entropy": {},
} }
+139 -3
View File
@@ -8,6 +8,7 @@ import stat
import tempfile import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from ipaddress import IPv4Address
from pathlib import Path from pathlib import Path
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
@@ -35,6 +36,16 @@ _HOST_KEY_TYPES = (
("ed25519", None), ("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) @dataclass(frozen=True, slots=True)
class GuestAssets: class GuestAssets:
@@ -150,6 +161,91 @@ class ImageStore:
finally: finally:
temporary.unlink(missing_ok=True) 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 @staticmethod
def _validate_storage(storage_mib: int) -> None: def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB: if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
@@ -226,6 +322,8 @@ class ImageStore:
encoding="utf-8", encoding="utf-8",
) )
shadow.path.chmod(shadow.mode) shadow.path.chmod(shadow.mode)
if username != "root":
self._provision_guest_sudo(disk, username, temporary)
sshd_config.path.write_text( sshd_config.path.write_text(
_enable_ssh_password_authentication( _enable_ssh_password_authentication(
@@ -244,6 +342,33 @@ class ImageStore:
except (OSError, UnicodeError) as error: except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest credentials: {error}") from error raise UvmError(f"could not provision guest credentials: {error}") from error
def _provision_guest_sudo(self, disk: Path, username: str, temporary: Path) -> None:
sudoers = self._read_guest_file(disk, "/etc/sudoers", temporary / "sudoers", required=False)
sudo = self._runner.run(("debugfs", "-R", "stat /usr/bin/sudo", disk), capture=True)
mode = _MODE_PATTERN.search(sudo.stdout)
owner = _OWNER_PATTERN.search(sudo.stdout)
if (
sudoers is None
or sudoers.uid != 0 or sudoers.mode & 0o022
or re.search(r"Type:\s+regular\b", sudo.stdout) is None
or mode is None or not int(mode.group(1), 8) & 0o4000
or not int(mode.group(1), 8) & 0o111
or owner is None or int(owner.group(1)) != 0
):
raise UvmError(
"guest image needs setuid-root sudo and /etc/sudoers for a custom administrator; "
"run uvm install --force to build the complete Ubuntu image"
)
contents = sudoers.path.read_text(encoding="utf-8").rstrip()
sudoers.path.write_text(
contents + "\n\n# Managed by uvm: guest administrator\n"
+ f"Defaults:{username} !rootpw, !runaspw, !targetpw\n"
+ f"{username} ALL=(ALL:ALL) PASSWD: ALL\n",
encoding="utf-8",
)
sudoers.path.chmod(sudoers.mode)
self._write_guest_file(disk, "/etc/sudoers", sudoers)
def _create_guest_user( def _create_guest_user(
self, self,
disk: Path, disk: Path,
@@ -368,12 +493,16 @@ class ImageStore:
destination: Path, destination: Path,
*, *,
required: bool = True, required: bool = True,
regular_only: bool = False,
) -> _GuestFile | None: ) -> _GuestFile | None:
stat_result = self._runner.run( stat_result = self._runner.run(
("debugfs", "-R", f"stat {guest_path}", disk), ("debugfs", "-R", f"stat {guest_path}", disk),
check=False, check=False,
capture=True, 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) mode_match = _MODE_PATTERN.search(stat_result.stdout)
owner_match = _OWNER_PATTERN.search(stat_result.stdout) owner_match = _OWNER_PATTERN.search(stat_result.stdout)
flags_match = _FLAGS_PATTERN.search(stat_result.stdout) flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
@@ -562,12 +691,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
} }
if username == "root": if username == "root":
managed_directives.add("permitrootlogin") managed_directives.add("permitrootlogin")
unmanaged_lines = [] global_lines = []
match_lines = []
in_match = False
for line in contents.splitlines(): for line in contents.splitlines():
stripped = line.lstrip() stripped = line.lstrip()
directive = stripped.split(None, 1)[0].lower() if stripped else "" 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: if stripped.startswith("#") or directive not in managed_directives:
unmanaged_lines.append(line) (match_lines if in_match else global_lines).append(line)
directives = [ directives = [
"HostKey /etc/ssh/ssh_host_rsa_key", "HostKey /etc/ssh/ssh_host_rsa_key",
@@ -579,13 +712,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
] ]
if username == "root": if username == "root":
directives.append("PermitRootLogin yes") 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 ( return (
"# Managed by uvm\n" "# Managed by uvm\n"
+ "\n".join(directives) + "\n".join(directives)
+ (f"\n{globals_text}" if globals_text else "")
+ f"\nMatch User {username}\n" + f"\nMatch User {username}\n"
+ " PasswordAuthentication yes\n" + " PasswordAuthentication yes\n"
+ "Match all\n" + "Match all\n"
+ "\n".join(unmanaged_lines).rstrip() + "\n".join(match_lines).rstrip()
+ "\n" + "\n"
) )
+223 -11
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import shutil import shutil
import tarfile import tarfile
import tempfile import tempfile
@@ -13,12 +14,43 @@ from pathlib import Path
from .config import Settings from .config import Settings
from .errors import UvmError from .errors import UvmError
from .images import GuestAssets from .images import GUEST_NETWORK_TEMPLATE, 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 .state import StateStore
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
_UBUNTU_RELEASE = "jammy"
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v3\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"
)
_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: class Installer:
"""Install exactly the local host dependencies used by the uvm CLI.""" """Install exactly the local host dependencies used by the uvm CLI."""
@@ -68,12 +100,18 @@ class Installer:
"UVM_KERNEL_SHA256", "UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads, allow_unverified=self._settings.allow_unverified_downloads,
) )
if self._settings.rootfs_url:
require_checksum( require_checksum(
self._settings.rootfs_sha256, self._settings.rootfs_sha256,
"guest root filesystem", "guest root filesystem",
"UVM_ROOTFS_SHA256", "UVM_ROOTFS_SHA256",
allow_unverified=self._settings.allow_unverified_downloads, 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: def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
write_manifest( write_manifest(
@@ -88,7 +126,7 @@ class Installer:
( (
self._settings.firecracker_sha256, self._settings.firecracker_sha256,
self._settings.kernel_sha256, self._settings.kernel_sha256,
self._settings.rootfs_sha256, self._settings.rootfs_sha256 if self._settings.rootfs_url else True,
) )
), ),
) )
@@ -108,6 +146,8 @@ class Installer:
"openssl", "openssl",
"openssh-client", "openssh-client",
) )
if not self._settings.rootfs_url:
packages += ("debootstrap", "ubuntu-keyring")
self._runner.run(("apt-get", "update")) self._runner.run(("apt-get", "update"))
self._runner.run(("apt-get", "install", "-y", *packages)) self._runner.run(("apt-get", "install", "-y", *packages))
@@ -183,14 +223,6 @@ class Installer:
label="guest kernel", label="guest kernel",
environment_name="UVM_KERNEL_SHA256", environment_name="UVM_KERNEL_SHA256",
) )
if force or not rootfs.exists():
self.download(
self._settings.rootfs_url,
rootfs,
expected_sha256=self._settings.rootfs_sha256,
label="guest root filesystem",
environment_name="UVM_ROOTFS_SHA256",
)
verify_file( verify_file(
kernel, kernel,
self._settings.kernel_sha256, self._settings.kernel_sha256,
@@ -198,6 +230,17 @@ class Installer:
"UVM_KERNEL_SHA256", "UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads, 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():
self.download(
self._settings.rootfs_url,
rootfs,
expected_sha256=self._settings.rootfs_sha256,
label="guest root filesystem",
environment_name="UVM_ROOTFS_SHA256",
)
if self._settings.rootfs_url:
verify_file( verify_file(
rootfs, rootfs,
self._settings.rootfs_sha256, self._settings.rootfs_sha256,
@@ -207,6 +250,175 @@ class Installer:
) )
return GuestAssets(kernel=kernel, rootfs=rootfs) 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"
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._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)
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 _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/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": GUEST_NETWORK_TEMPLATE,
}
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( def download(
self, self,
url: str, url: str,
+9 -1
View File
@@ -89,6 +89,7 @@ class LifecycleService:
self._save(vm) self._save(vm)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib) 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.provision_credentials(disk, vm.username, vm.password)
self._images.configure_guest_network(disk, vm.guest_ip)
vm.disk_ready = True vm.disk_ready = True
self._save(vm) self._save(vm)
self._boot(vm, assets.kernel) self._boot(vm, assets.kernel)
@@ -177,7 +178,10 @@ class LifecycleService:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting") 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"}: 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") 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) self._validate_resume(vm)
if kernel is None: if kernel is None:
self._check_boot() self._check_boot()
@@ -185,6 +189,7 @@ class LifecycleService:
try: try:
self._cleanup_runtime(vm) self._cleanup_runtime(vm)
self._apply_pending_resize(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 vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel) self._boot(vm, kernel)
return vm return vm
@@ -276,6 +281,9 @@ class LifecycleService:
disk = Path(vm.disk) disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1: 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}") 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) if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)} or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}): or vm.bridge not in {None, self._settings.bridge}):
+29 -102
View File
@@ -9,7 +9,7 @@ from ipaddress import IPv4Address
from .config import Settings from .config import Settings
from .domain import VmRecord from .domain import VmRecord
from .errors import ConflictError, UvmError, ValidationError from .errors import CommandError, ConflictError, UvmError, ValidationError
from .firewall import FirewallManager from .firewall import FirewallManager
from .system import CommandRunner from .system import CommandRunner
@@ -178,111 +178,38 @@ class NetworkManager:
raise UvmError(f"could not delete TAP device {name}{suffix}") raise UvmError(f"could not delete TAP device {name}{suffix}")
def _ensure_masquerade(self) -> None: def _ensure_masquerade(self) -> None:
route = self._runner.run(("ip", "route", "show", "default"), capture=True) # Match the guest network, not a snapshot of the host's default uplink.
match = re.search(r"\bdev\s+(\S+)", route.stdout) # Routing selects the current interface for each destination, even after
if match is None: # an offline boot, a reconnect, or a switch between Wi-Fi/Ethernet/VPN.
return bridge = self._settings.bridge
uplink = match.group(1) network = str(self._settings.network)
rule = ( owner = ("-m", "comment", "--comment", "uvm-egress")
"iptables", self._ensure_iptables_rule(
"-t", "nat", "POSTROUTING",
"nat", ("-s", network, "!", "-o", bridge, *owner, "-j", "MASQUERADE"),
"-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",
) )
# 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( self._ensure_iptables_rule(
( "filter", "FORWARD",
"iptables", ("-o", bridge, "-d", network, "-m", "conntrack",
"-C", "--ctstate", "ESTABLISHED,RELATED", *owner, "-j", "ACCEPT"),
"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",
),
)
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",
),
) )
def _ensure_iptables_rule( def _ensure_iptables_rule(
self, self, table: str, chain: str, rule: tuple[str, ...],
check_rule: tuple[str, ...],
add_rule: tuple[str, ...],
) -> None: ) -> None:
present = self._runner.run(check_rule, check=False) command = ("iptables", "--wait", "5", "-t", table)
if present.returncode != 0: present = self._runner.run((*command, "-C", chain, *rule), check=False, capture=True)
self._runner.run(add_rule) 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)
+35
View File
@@ -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
+6 -1
View File
@@ -35,6 +35,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
) from error ) from error
from .routers.health import router as health_router 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.installation import router as installation_router
from .routers.vms import router as vms_router from .routers.vms import router as vms_router
from .routers.diagnostics import router as diagnostics_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(health_router)
api.include_router(exit_router)
api.include_router(installation_router) api.include_router(installation_router)
api.include_router(vms_router) api.include_router(vms_router)
api.include_router(diagnostics_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: if settings.api_tls_cert is not None:
options["ssl_certfile"] = str(settings.api_tls_cert) options["ssl_certfile"] = str(settings.api_tls_cert)
options["ssl_keyfile"] = str(settings.api_tls_key) 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: def _is_loopback_host(host: str) -> bool: