- Introduced `SessionRename` model for renaming sessions with validation on name length and content. - Implemented `rename_session` endpoint in `session_routes.py` to handle session renaming. - Added `delete_session` endpoint to remove sessions, ensuring proper cleanup of associated data. - Enhanced `SessionStore` with `rename` and `delete` methods, including validation and transaction management. - Updated `SessionManager` to manage session renaming and deletion, ensuring live terminal state is preserved. - Added tests for renaming and deleting sessions, ensuring proper functionality and validation. - Introduced a new `tool_catalog.py` for managing guest tool selections and installation recipes.
74 KiB
Nest Developer Guide
This document is the developer handoff for Nest and its UVM backend. Read it before changing lifecycle behavior, host networking, Firecracker launch semantics, persistent state, or the HTTP API.
Nest owns the workspace dashboard and its server entry point. Its uvm
component is a local, privileged Firecracker microVM manager with a CLI and
standalone FastAPI management API. These are application components, not a
general-purpose Python library or a multi-host control plane.
1. Goals and Boundaries
UVM installs host/guest assets, provisions private disks, and manages named VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk start/stop/restart, CPU/RAM/storage resizing, deletion, fixed-target bulk operations, inspection, SSH, logs, doctor, and explicit port edits/sync. The UVM API and Nest dashboard expose health, installation, VM lifecycle controls, and managed port-policy controls, host diagnostics, bounded VM-log tails, and fixed-target bulk operations, and bounded guest command execution over vsock. Interactive SSH remains CLI-only.
There is no autostart or background reconciler, multi-host control plane, tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port filtering is not public-port publishing or a hardened multi-tenant boundary; see Current Production Gaps for the remaining scope limits.
Treat UVM as a privileged local systems tool. It manipulates /dev/kvm, TAP
interfaces, bridge addresses, nftables/conntrack and existing iptables egress
rules, host filesystems, and processes. For the complete operator command and
flag reference, see USAGE.md.
2. First Run
All Nest and UVM files live in this project. Start here:
python3 -m pip install -e '.[test]'
python3 -B -m unittest discover -s tests -v
./nest.py --help
./uvm.py --help
The nest and uvm console scripts are registered by pyproject.toml.
During source development, ./nest.py and python3 -B -m nest are equivalent
Nest entry points; ./uvm.py and python3 -B -m uvm launch UVM.
Requirements for real host operations:
- Python 3.12+
- Linux with KVM enabled and
/dev/kvmaccessible - Linux >= 5.3 with native bridge conntrack and safe pidfd support
- Root privileges for installation and lifecycle mutations
iproute2,iptables,nftables,conntrack,coreutils,e2fsprogs,util-linux,openssl, and OpenSSH client tools- An x86_64 or ARM64 (
aarch64) host, including NVIDIA DGX Spark
Do not run real integration operations on a developer workstation unless the bridge, subnet, firewall changes, and guest workload are acceptable there. Packet integration tests must remain inside disposable isolated namespaces.
3. Project Layout
./
├── DEVELOPER.md # This document
├── README.md # Project overview
├── USAGE.md # Operator command/deployment guide
├── pyproject.toml # Package metadata and dependencies
├── nest.py # Executable Nest source-tree launcher
├── nest/
│ ├── __main__.py # `python -m nest`
│ ├── cli.py # Nest workspace serve command
│ ├── server.py # Nest composition of the dashboard and UVM API
│ ├── sessions.py # Live guest terminals and browser-independent session lifecycle
│ ├── session_store.py # Private SQLite session history and bounded transcripts
│ ├── session_routes.py # VM-scoped session HTTP and WebSocket endpoints
│ └── dashboard/
│ ├── router.py # Nest dashboard document route
│ └── static/ # Packaged HTML, CSS, JavaScript, and fonts
├── uvm.py # Executable UVM source-tree launcher
├── uvm/
│ ├── __main__.py # `python -m uvm`
│ ├── cli.py # argparse, CLI rendering, serve dispatch
│ ├── app.py # Composition root
│ ├── config.py # Settings and environment loading
│ ├── domain.py # PortRule, VmSpec, VmRecord, State
│ ├── errors.py # Expected error hierarchy
│ ├── validation.py # Resources, names, credentials, ports, timeouts
│ ├── system.py # CommandRunner, root/KVM/directory checks
│ ├── state.py # JSON state, atomic writes, file locks
│ ├── integrity.py # SHA-256 and integrity manifest logic
│ ├── install.py # Apt, Firecracker, kernel, rootfs installation
│ ├── images.py # Template lookup, private disks, and guest credentials
│ ├── known_hosts.py # Deletion-only cleanup of local SSH host-key caches
│ ├── guest_agent.py # Standalone guest daemon, job scopes, and bounded wire protocol
│ ├── guest_exec.py # Host-side guest sessions and execution results
│ ├── guest_terminal.py # Async PTY transport over a process-pinned guest connection
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
│ ├── api_models.py # Pydantic HTTP request/response models
│ ├── server.py # FastAPI factory and Uvicorn launcher
│ ├── routers/
│ │ ├── diagnostics.py # Host diagnostic endpoint
│ │ ├── dependencies.py # API application/token dependencies
│ │ ├── exit.py # Authenticated server shutdown
│ │ ├── health.py # Liveness endpoint
│ │ ├── installation.py # Installation endpoint
│ │ └── vms.py # VM lifecycle endpoints
│ └── firecracker/
│ ├── config.py # Firecracker JSON and boot arguments
│ ├── api.py # Unix-socket HTTP client
│ └── process.py # Process launch, liveness, termination
└── tests/ # Unit/API tests; integration/ has opt-in namespace packets
Keep feature code in the most specific existing module. Do not add a new abstraction layer merely to wrap one function. The current module boundaries are intentionally small and operationally focused.
4. Architecture Overview
flowchart LR
DASH[Nest dashboard] --> NEST[nest/server.py workspace]
NEST --> HTTP[UVM management API]
CLI[CLI: uvm] --> APP[uvm/app.py composition root]
HTTP --> APP
APP --> INSTALL[Installer]
APP --> LIFE[LifecycleService]
APP --> STATE[StateStore]
LIFE --> IMAGES[ImageStore]
LIFE --> NET[NetworkManager]
NET --> FW[FirewallManager]
LIFE --> FC[Firecracker adapters]
INSTALL --> RUNNER[CommandRunner]
NET --> RUNNER
FW --> RUNNER
FC --> HOST[Linux host primitives]
RUNNER --> HOST
STATE --> DISK[(state.json)]
IMAGES --> DISKSTORE[/var/lib/uvm/images and vms]
FC --> KVM[/dev/kvm]
The public entry points are thin adapters. Nest composes the UVM API and adds
the workspace dashboard; UVM does not import Nest. The lifecycle layer is the
only place that coordinates multiple host resources for a VM. Lower-level modules
must not import cli.py, server.py, or lifecycle.py.
Leaf modules are config.py, domain.py, errors.py, and validation.py.
They must remain free of subprocess, network, CLI, and FastAPI side effects.
5. Entrypoints and Request Paths
CLI Path
cli.py builds the application, validates arguments, and calls lifecycle
services. It owns confirmation, terminal/JSON output, exit handling, SSH
execvp, local log streaming from a validated path, and --serve dispatch.
It must not coordinate state files, interfaces, or Firecracker sockets itself.
The local surface is install/create/list/inspect/start/stop/restart/resize/delete
(destroy alias)/ssh/logs/doctor and ports list/open/close/set/sync. Identifiers
resolve by exact ID, name, or guest IP, never an ID prefix. Generated-ID-shaped
inputs resolve only by ID; names cannot impersonate generated IDs. Ambiguous
non-ID aliases raise ConflictError.
Create alone adds 22:TCP unless --no-default-ports is supplied. Port syntax
is PORT[:TCP|HTTP|HTTPS], comma-separated or repeated via --ports; all labels
use TCP. --password-stdin reads one line instead of using a password argument.
_vm_output() is an explicit output whitelist, not VmRecord.to_dict().
JSON stays on stdout, while warnings, prompts, and host traces use stderr.
Bulk lifecycle actions and ports sync resolve/deduplicate IDs before prompting.
Delete and mutating --all selections require a TTY confirmation or --yes/-y.
Port set/close-all and removal of TCP/22 also prompt, except empty managed
no-ops. --dry-run previews without lifecycle/policy mutations or confirmation;
it is not a full preflight. Stop/restart/resize/delete accept --timeout; start does
not. Logs follow does not hold the operation lock. Doctor runs readiness
checks, not per-VM repair or guest application probes.
HTTP Path
nest/server.py owns the workspace application. Its create_app() composes
uvm.server.create_api() with the dashboard document and static assets from
nest/dashboard/. The same-origin API paths and X-UVM-Token authentication
are shared with the standalone UVM API.
Routers validate the token and Pydantic request, invoke application services, and render whitelisted response models. Lifecycle, managed port-policy, diagnostics, and bounded log-tail operations have explicit HTTP routes for the dashboard. Bulk operations have a dedicated fixed-target HTTP route; interactive SSH has no HTTP endpoint. The public dashboard shell does not bypass token validation: it stores a supplied token only for the active browser session and sends it with management requests.
Launch the workspace with nest --serve or ./nest.py --serve. The standalone
backend uses uvm --serve or ./uvm.py --serve. Both create_app() and
create_api() require an explicit host so validation covers the intended bind.
Nest passes its factory to the shared UVM server runner, which enforces token
and TLS requirements and wires graceful server shutdown.
6. Application Composition
app.py constructs one concrete object graph per CLI or server
process:
| Component | Constructor | Responsibility |
|---|---|---|
| Settings | Settings.from_environment() |
Immutable runtime configuration |
| CommandRunner | CommandRunner |
Logged/testable subprocess execution |
| StateStore | StateStore |
Inventory, locks, JSON persistence |
| ImageStore | ImageStore |
Template verification, VM disks, and offline credentials |
| KnownHostsManager | KnownHostsManager |
Scoped local SSH cache cleanup before deleted IPs are released |
| NetworkManager | NetworkManager |
Guest allocation and Linux networking |
| FirewallManager | Constructed by NetworkManager |
Owned native bridge policy and scoped offline conntrack cleanup |
| FirecrackerProcessManager | FirecrackerProcessManager |
VMM process control |
| LifecycleService | LifecycleService |
Ordered VM resource changes and rollback |
| Installer | Installer |
Host packages and artifacts |
Composition can disable command tracing with emit=None; create_api() does
so when it constructs its own application. The CLI supplies an emitter that
prints + ... traces to stderr, including when passing its application to
the server launcher.
7. Persistent Data and Models
Host Data Layout
Default runtime storage is /var/lib/uvm:
/var/lib/uvm/
├── bin/
│ ├── firecracker
│ └── jailer
├── images/
│ ├── vmlinux
│ ├── ubuntu.ext4
│ └── ubuntu-build.log
├── vms/
│ └── vm-<uuid>/
│ ├── rootfs.ext4
│ ├── config.json
│ ├── firecracker.log
│ ├── firecracker.sock
│ └── vsock.sock
├── state.json
├── state.lock
├── operations.lock
└── integrity.json
Per-VM runtime directories are mode 0700; copied VM disks are mode 0600.
state.json is mode 0600 because VM records contain plaintext guest
passwords. Registry-backed commands therefore need access to the root-owned
state file.
State Schema
domain.py is the schema authority. The default registry is
/var/lib/uvm/state.json; its current schema version is 3. An empty registry:
{
"schema_version": 3,
"next_mac_index": 1,
"vms": {}
}
VmSpec is the validated create request. VmRecord is the persisted local
record. Do not make API models or CLI namespaces the persistence format.
| Record Fields | Purpose |
|---|---|
id, name, cpu, ram_mib, storage_mib |
Stable identity, name, applied resources; storage is null for older disks |
pending_resize |
Null or a validated VmResources target with CPU, RAM MiB, and storage MiB |
guest_ip, gateway, network, bridge, tap, mac |
Retained network identity/configuration |
socket, config, log, disk |
Canonical per-VM runtime paths |
username, password |
Guest credentials; password stays out of public output |
ports |
Null for legacy/unmanaged, or a list of { "port": 80, "protocol": "HTTP" } objects |
disk_ready, runtime_owned, tap_owned |
Completed provisioning and resource ownership evidence |
status, operation, spawn_pending, last_error |
Durable state, pending intent, ambiguous-spawn guard, failure detail |
pid, process_start_time, boot_id |
VMM identity, including host reboot detection |
created_at, updated_at |
Registry timestamps |
PortRule accepts TCP/HTTP/HTTPS labels and integer ports 1-65535. A port
number is unique regardless of label. VmSpec.ports defaults to TCP/22,
whereas VmRecord.ports=None deliberately preserves the legacy distinction.
An explicit empty tuple/list is a managed empty TCP allowlist, not unmanaged
mode. There is no persisted boolean asserting that a policy is enforced.
State Guarantees
state.jsonwrites use a sibling temporary file,fsync, atomic replace, and parent-directoryfsync.state.jsonis mode0600; it must be treated as a credential registry and excluded from logs and bug reports.state.lockserializes short JSON read-modify-write transactions.operations.lockserializes long lifecycle, bulk, policy, and installation operations.- State reads are lock-free because atomic replacement gives readers either a complete old file or a complete new file.
- Legacy versions 0 (including unversioned state), 1, and 2 are loaded into schema 3 in memory and written as schema 3 on the next transaction. Newer schemas are rejected. Reads do not reconcile VMs or adopt process identities.
- Missing
name,ports, andboot_idremain null. No policy or default SSH rule is silently imposed on a legacy VM. Use stop, explicitports set, then start; first policy adoption requires a stopped, proven-gone VM. - Missing
diskand process start time remain absent. Legacydisk_readyis inferred only for running/stopped records with a recorded private disk; start still validates the path, file, and provisioning state. - Legacy records own their canonical runtime directory independently of whether
they had a private disk. Deletion never removes the shared rootfs template;
explicit
runtime_owned=Falseon a new interrupted creation stays authoritative. - Records created before credential provisioning default to username
rootand passwordnull; UVM does not apply a password retroactively. - Missing
storage_mibandpending_resizeremain null. The first explicit resize infers an older disk's capacity and enforces the 30 GiB minimum; reads and schema migration never resize a disk. Schema 3 prevents older UVM versions from ignoring pending resource changes during a boot. next_mac_indexis reconstructed from older locally administered MACs when absent.
When adding a persisted field, update VmRecord.to_dict, VmRecord.from_dict,
the state schema tests, and this document. Never silently discard existing
state fields without a migration plan.
8. VM Lifecycle
Stored and Observed Status
The persisted status is a string, not an enum, to keep legacy JSON readable.
| Status | Meaning |
|---|---|
starting |
Identity reserved; host resources are being created |
running |
Firecracker configuration and InstanceStart succeeded |
stopping |
Stop mutation is in progress |
stopped |
Process/TAP removed; private disk remains |
terminating |
Destruction is in progress |
failed |
A lifecycle step or cleanup failed; last_error explains why |
dead |
Observed-only status when a stored running process is proven gone |
unknown |
Observed process identity cannot be established safely |
Transition statuses can survive interruption. dead/observed unknown do
not rewrite inventory. Delete removes the record rather than keeping a
tombstone. Pending deletion remains authoritative even if a subsequent stop
finishes runtime cleanup; start and policy edits must not resurrect it.
ListedVm separates the record, observed process status, and observed policy
status. Policy can be enforced, missing, drift, quarantined, unknown,
legacy unmanaged, or inactive for a cleanly stopped managed VM. Readback is
a ruleset/attachment snapshot, not application health or a reachability test.
Create Order
LifecycleService.create follows this sequence:
- Validate the request/root access and acquire
operations.lock. - Check KVM, verified Firecracker, firewall capability, and installed templates.
- Reserve ID/name/IP/MAC/ports with
status=starting,operation=preparing. - Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
disk, resize ext4 to the requested storage capacity, provision credentials/host
keys and the managed guest network profile, then persist
disk_ready=True. - Ensure bridge/egress networking and prepare an owned, attached TAP down.
- Apply/read back the nftables bridge policy, then activate the TAP.
- Write Firecracker config; persist
operation=spawning,spawn_pending=True, and the host boot ID beforePopen. - Publish PID/start time/boot ID through
on_spawnbefore socket readiness or API configuration; clear the pending-spawn guard. - Configure Firecracker and
InstanceStart, verify policy enforcement again, then saverunningwith no pending operation.
This records both intent and acquired ownership across crash windows. Never make a managed TAP usable before enforcement or mark a partial disk ready.
Rollback Rules
Any Exception, KeyboardInterrupt, or SystemExit during create triggers
compensation:
- Attempt quarantine/TAP deactivation and confirm safe process exit.
- Remove the owned TAP, clean up scoped conntrack entries, and remove its policy.
- Remove the owned runtime directory/private disk only after confirmed exit.
- Remove the reservation only if cleanup succeeds.
- Otherwise retain a
failedrecord with identity/intent andlast_error; do not release its disk or IP for reuse.
Do not reorder these operations without considering leaked TAPs, orphaned VMM processes, and allocation reuse.
Start and Restart
To boot a stopped/proven-gone VM, start requires managed ports, a complete safe private disk, and compatible retained gateway, network, and bridge settings. It verifies the VMM and installed kernel; the original rootfs template is not needed. It never recopies the rootfs, reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes survive stop/start; guest RAM does not.
After runtime cleanup confirms process exit and any pending resize finishes, start specializes an original UVM Ubuntu network profile with the retained guest address/gateway. This one-time migration repairs older guests that lost their default route. It uses a checked sparse copy of the retained private disk; it never replaces that disk with the shared template. Guest-edited profiles are preserved. The image layer's network checks and writes run only under the lifecycle operation lock, before the TAP and VMM are started.
Start on a healthy running VM validates its retained network, synchronizes policy, then restores shared bridge/NAT/forwarding rules without spawning another VMM. A shared-network setup failure retains the running VM for retry. An ambiguous process or interrupted live boot/stop is not adopted as a successful start. Failed resume retains the private disk for retry. Restart performs stop and start under one operation lock, not two independent public calls with a race between them.
Resource Resizing
resize() accepts a partial CPU/RAM/storage update for one VM. New disks default
to 30720 MiB (30 GiB), also the minimum accepted storage. Storage parsing uses
GiB for bare numbers, binary B/K/M/G/T suffixes, and rounds up to MiB; RAM retains
its MiB-default parsing. Capacities must fit the host's signed 64-bit file offsets.
One operations.lock spans preflight, durable pending_resize publication,
_stop_locked(), offline resource application, and _start_locked(). Stop must
prove process exit before any disk work. The complete target is a validated
VmResources, while the record's CPU/RAM/storage fields remain the applied values.
Only the resource fields change; identity, networking, credentials, ports, and
guest SSH keys are retained. Even an initially stopped VM boots after resize.
ImageStore.resize_vm_disk() sparse-copies the private disk to a mode-0600 sibling,
runs e2fsck -f -p, resizes ext4, verifies its geometry using dumpe2fs, fsyncs,
then atomically replaces the original and fsyncs the directory. Growth extends
the image before resize2fs; shrinking truncates it only after ext4 fits and its
geometry matches. Failed staging leaves the original disk intact. CPU/RAM-only
resizes avoid disk work when its configured capacity is already known.
After disk publication, one state write applies all resource fields and clears
pending_resize; boot then follows the normal start path. A crash between disk
publication and that state write is recoverable: the target is durable and the
image layer recognizes an already-completed capacity. A stopped VM's start
retries a pending resize before boot; a live pending resize requires resize
or stop first. Stop retains the target and deletion never applies it. A failed
boot after successful resizing retains the new resources for retry.
Stop and Delete
stop:
persist stop intent -> quarantine/deactivate TAP -> confirm VMM exit
-> delete owned TAP -> scoped conntrack cleanup -> remove owned nft table
-> stopped (retain disk, credentials, name/IP and desired ports)
delete (destroy alias):
persist delete intent -> same runtime cleanup -> remove owned runtime directory
-> clean local known_hosts entries -> remove state record -> release name/IP
Stop signals the VMM, not a guest shutdown agent or ACPI-coordinated
shutdown. SIGTERM is followed by SIGKILL when needed. --timeout is the
per-VM TERM wait, default 5 seconds, finite and non-negative; it is not a total
deadline. Even zero still requires confirmed kernel exit before cleanup.
Application consistency requires separately quiescing the guest.
Runtime cleanup retains reservations when process exit, TAP ownership, or connection cleanup is uncertain. A pending spawn cannot be declared gone merely because its PID was never persisted or its socket has not appeared.
KnownHostsManager is an explicit lifecycle dependency, also used by API and
bulk deletion. It runs after successful process/network/disk teardown and before
the registry entry is removed, under the same operation lock. Cleanup errors
retain operation=delete and the IP reservation; repeating delete retries it.
Stop, restart, and resize retain the existing guest's trusted host-key entries.
Default caches are known_hosts and known_hosts2 under the effective user's
and sudo invoker's account homes (SUDO_UID, with a SUDO_USER fallback).
UVM_KNOWN_HOSTS_FILES overrides this discovery for service deployments using a
colon-separated list of absolute paths. Only local files are managed. Matches
are explicit guest IPs or UVM's uvm-<VM-ID> host-key alias, including OpenSSH's
HMAC-SHA1 hashed form. Plain bracketed ports are recognized directly; hashed
bracketed candidates use port 22 and the VM's recorded allowlist. Wildcard rules
and other aliases sharing a line are retained.
File updates use pinned directory descriptors, regular/single-link checks, an advisory lock, and a sibling temporary file. Owner, group, mode, and extended attributes are preserved. A changed source snapshot prevents publication over concurrent edits. A matching-free file is not rewritten and missing files are not created. Tests inject the dependency to avoid accessing developers' caches, and verify hashed-entry interoperability using real OpenSSH on temporary files.
Policy and Bulk Operations
set_ports() takes a frozen registry ID, not a reusable name/IP; the CLI resolves
the user's identifier before confirmation. It persists the new allowlist with operation=policy before
applying it. Live updates apply atomically and retain conntrack; cleanly
stopped edits are metadata-only. On apply/readback failure, preserve the new
intent, attempt link-down/quarantine, and leave a failed pending record. Never
restore an older, more permissive allowlist after a failed close. An explicit
ports sync retries the desired policy and can reactivate a contained live VM.
run_many() holds one outer operation lock while processing the fixed ID
snapshot produced before CLI confirmation. It reloads by exact ID, not a
reusable name/IP. Results are succeeded, failed, skipped (already absent),
or interrupted; ordinary failures do not undo other successes or stop later
targets. Interruptions skip the remaining targets. Read-only views and API
startup never reconcile; there is no autostart or background repair loop.
9. Firecracker Integration
The firecracker/ package has three separate responsibilities.
| File | Responsibility |
|---|---|
config.py |
Build boot args and Firecracker JSON; atomically write config |
api.py |
Typed HTTP-over-Unix-socket requests |
process.py |
Spawn, socket readiness, liveness, signal safety, cleanup |
Configuration Order
FirecrackerClient.configure_and_start() must keep this ordering:
PUT /machine-configPUT /boot-sourcePUT /drives/rootfsPUT /network-interfaces/eth0PUT /vsockwith a guest CID and a per-VM Unix socket pathPUT /entropywith{}(a host-backed virtio RNG device)PUT /actionswithInstanceStart
Every generated config includes entropy, including retained-disk boots.
An empty object enables the device with default settings; do not skip it with
a truthiness check. A missing/invalid configuration or an entropy API failure
must prevent InstanceStart. Without guest entropy, systemd's random-seed
service and OpenSSH can remain in startup while the VM process and IP respond.
The guest receives a static IP via the kernel ip= boot argument. The netmask
comes from Settings.network, not a hard-coded /24 value. The Ubuntu
systemd-networkd profile also receives explicit Address= and Gateway=
settings so it can maintain the same configuration after boot or reconfiguration.
ARM64 guests also receive keep_bootcon, as recommended by Firecracker's
getting-started guide; both architectures use console=ttyS0.
Process Safety
Firecracker starts detached in a new session, appending stdout/stderr to the
per-VM log. Identity consists of PID, /proc/<pid>/stat start time, and the
host boot ID. observe() distinguishes alive, gone, and unknown;
unreadable evidence is not proof of exit. A changed boot ID or start token can
prove the old process gone without signaling a reused PID.
Start probes safe pidfd support before spawning. Termination opens a pidfd, rechecks identity, signals through that handle (never a bare PID), and confirms whole-process exit before unlinking the socket. Python bindings or libc bindings may supply pidfd support; there is no unsafe PID-signal fallback. A zombie leader alone does not prove its worker threads have exited.
Legacy adoption is deliberately narrow. During a locked mutation,
adopt_legacy_identity() may fill a missing boot ID only when all evidence
matches: a saved numeric start token, a live pidfd-pinned process, the configured
Firecracker executable path and inode, the exact --api-sock command line,
and root-owned/protected binary, socket, and ancestor directories. It checks
that the process has not exited before returning, and the lifecycle layer
persists the adopted boot ID. Listing/inspection never adopts it.
A live legacy PID without a saved start token, or with mismatched/inaccessible binary/socket evidence, remains unknown and fails closed. UVM does not guess an identity from the current occupant of a PID or delete an in-use disk to make recovery convenient. Proven-gone legacy processes can be cleaned up.
10. Guest Images and Integrity
Template Contract
images/ubuntu.ext4 is a template only. Each create operation copies it into
the VM runtime directory before UVM provisions the requested account, its
shadow entry, and OpenSSH password policy with debugfs. Firecracker only sees
the resulting private copy. Never change that to a shared writable rootfs.
Copies use coreutils cp --reflink=auto --sparse=always; the new disk is resized
to the requested capacity before credential provisioning. Existing disk resizes
do not reprovision credentials or host keys.
Guest images must provide /etc/passwd, /etc/shadow, and OpenSSH's
/etc/ssh/sshd_config. Existing accounts retain their identity, groups, home,
shell, and password-aging policy. Missing custom users are created offline,
using /etc/group and an available /bin/sh login shell. UVM allocates unused
UIDs/GIDs from 1000-60000, accounting for primary GIDs in passwd as well as
group entries, and reuses a same-named group when present. New group entries
also update /etc/gshadow if it exists. Account-file metadata is preserved.
The new /home/USERNAME directory is mode 0700, owned by the new UID/GID;
/home is created if absent and existing home paths are not overwritten.
Directory ownership/modes and account-file writes are read back before the
disk can be marked ready. The default root account must already exist.
Custom login users are VM administrators. Provisioning requires a root-owned,
setuid /usr/bin/sudo and /etc/sudoers, and appends a user-scoped PASSWD: ALL
rule with root/target/runas-password defaults disabled. Sudo authenticates with
the login user's password; root's separate shadow entry is not changed. Global
sshd directives precede managed and existing Match blocks so standard Ubuntu
UsePAM/Subsystem settings remain valid.
UVM replaces conventional RSA, ECDSA, and Ed25519 host keys in each private disk and removes the known public login key from Firecracker's bionic image. A VM must never rely on reusable SSH private keys from a shared template.
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/v4 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 v4 (v3 introduced the route fix);
cached older templates are
also handled by per-VM provisioning without requiring an installation refresh.
Integrity Model
integrity.py has two concepts:
- A configured trusted SHA-256 from the environment.
- A local
integrity.jsonmanifest recording artifact hashes and whether the installation was verified.
Strict installation requires these values for downloaded artifacts:
UVM_FIRECRACKER_SHA256
UVM_KERNEL_SHA256
The default rootfs is built from signature-verified Ubuntu packages and its
resulting digest is recorded in the same manifest. UVM_ROOTFS_URL explicitly
selects a prebuilt ext4 download and additionally requires UVM_ROOTFS_SHA256.
A rootfs checksum without a URL is rejected during installation to avoid using
a stale Bionic-image checksum for a local build.
UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is a local-development escape hatch. It
writes a manifest marked verified: false; a later strict run will not treat
that manifest as publisher trust. Use install --force with trusted digests to
refresh an existing artifact without manually deleting managed files.
Retain the explicit opt-out on create/start/restart/resize (or the API server) when using an unverified development installation. It is not appropriate for untrusted workloads. A bare strict install requires the trusted digests.
The optional UVM_FIRECRACKER_BINARY_SHA256 validates a local Firecracker
binary when no verified manifest is available.
11. Networking
Topology
guest eth0
|
virtio-net / Firecracker
|
per-VM TAP: uvm-<suffix>
|
Linux bridge: uvm0 (10.42.0.1/24 by default)
|
iptables forwarding + MASQUERADE
|
host IPv4 route for the destination (current uplink)
|
internet
network.py owns address allocation and host attachments;
firewall.py owns native bridge filtering:
- IP allocation from
Settings.network, excluding network, broadcast, and gateway addresses. - Locally administered MAC generation:
02:fc:00:xx:xx:xx. - Interface names limited to Linux
IFNAMSIZ(15 characters). - Bridge creation or validation that an existing interface is actually a Linux bridge.
- Gateway address setup and IPv4 forwarding.
- Egress NAT and forward/return rules scoped to the guest subnet/bridge.
- TAP ownership aliases (
uvm:<id>), preparation while down, activation, deactivation, cleanup, and error reporting.
Network actions are idempotent where practical. Do not adopt an existing TAP: it may belong to another VM. A create-time name collision is an error. Cleanup checks the TAP kind and ownership alias, with a narrow unaliased-TAP allowance for legacy records; it does not treat an arbitrary interface name as ownership.
Internet sharing is configured even with no host default route. Rules never pin an uplink interface: Linux routing selects it, and MASQUERADE uses that interface's address. Offline-to-online transitions, reconnects and route changes therefore work without another UVM command or a monitoring daemon. Existing connections may need to reconnect after the upstream address changes. Guest DNS in the Ubuntu template uses the public resolvers documented in README.
uvm-egress-commented iptables rules are checked before insertion, with a bounded
xtables-lock wait. Scoped exceptions are inserted at the start of the host chains
so pre-existing terminal drops cannot shadow them. Outbound forwarding requires
the UVM bridge and source subnet; routed replies require the destination bridge,
subnet and established/related conntrack state. Native bridge policy still checks
identity, service ports and permitted protocols. Outbound forwarding also covers
peer traffic if the host already uses br_netfilter, while MASQUERADE excludes
the UVM bridge to retain private peer source addresses. Global chain policies
and unrelated rules remain administrator-owned.
Native Bridge Policy
The supported path is a software Linux bridge with STP disabled. Each
managed TAP's bridge-slave group_fwd_mask must include 0x4000 (the network
layer sets 16384). This sends LLDP through the early filter instead of a
pre-hook delivery/learning path. Firewall apply/status checks the bridge and
port settings when the TAP exists. Do not use hardware/flow offloads or other
paths that bypass these hooks. This is not an offload-isolation guarantee.
Native bridge conntrack requires Linux >= 5.3. Capability checks run
conntrack --version, inspect the kernel release, and ask nft --json --check
to validate a full policy without committing it. The nft CLI must support
the rendered JSON. This layer needs no br_netfilter sysctl changes.
Each VM owns one bridge table named uvm_fw_<digest>, derived from the
resolved UVM_BASE and exact VM ID, with a full ownership comment. Apply uses
an atomic nft JSON transaction, delete-by-handle for the old owned table, and
exclusive create for its replacement. Foreign ownership is an error, not an
adoption opportunity. Readback normalizes presentation differences but compares
all effective objects, ordered rules, hooks, identity, and the full port set;
rule counts or an owner comment alone are not proof of enforcement.
Normalization materializes implied EtherType constraints before folding
transport-protocol dependencies. This accepts nftables' omitted redundant
ether type ip checks while retaining the IPv4 scope of TCP rules.
The policy has prerouting/input/forward/output hooks. Early prerouting identity checks run at priority -310, before conntrack and bridge MAC learning; input paths recheck identity as well. Managed traffic is bound to the recorded MAC and private IPv4 address. Spoofed identity, IPv6, VLAN encapsulation, and other unsupported EtherTypes are dropped. Host-to-guest and peer-to-guest paths are covered, not just routed IP forwarding.
The current TCP allowlist is checked against conntrack's original service destination address/port in both directions, including established service flows. There is no blanket established/related accept before it. Consequently close blocks subsequent packets on existing service connections in both directions after commit, without promising a TCP RST or immediate application disconnect. Live close does not flush conntrack, which would risk reclassifying midstream service traffic as guest egress.
Guest-initiated IPv4 TCP/UDP and replies remain allowed, including outbound DNS. ARP, ICMP echo, and necessary related ICMP errors are supported. Other inbound UDP services are not; HTTP/HTTPS are only TCP labels. Empty managed ports means no inbound TCP service allowance, not quarantine or loss of all egress. Quarantine drops every scoped TAP path, including ARP.
Offline cleanup requires the TAP to be absent. It deletes and verifies absence
of conntrack entries scoped to the guest IP in all four original/reply source
and destination fields, including NAT reply tuples. Only then may teardown
release the address. Rule removal also requires an absent TAP and verified
table ownership; it never flushes global firewall or conntrack state.
Cleanup uses conntrack's documented -D (delete) and -L (list) commands,
including on conntrack-tools 1.4.8, where --list is not a supported option.
Coexistence and Recovery
Shared iptables egress MASQUERADE/forwarding is separate from private bridge
policy. The policy adds no DNAT, public listener, or host-port mapping and does
not replace the host firewall manager. Public inbound connectivity, DNS
publication, guest reverse proxies/TLS, and integration with
UFW/firewalld/Docker/libvirt remain out of scope. An
administrator's external routing/NAT can still affect reachability; do not
infer source-CIDR restrictions or complete public-exposure protection from the
use of private addresses. Operators must choose a non-conflicting topology.
If an external actor loses or changes private policy, recovery is explicit
ports sync; start also restores shared networking for a running VM. An
enforced snapshot cannot prevent later root-level changes or prove that other
rules allow traffic.
FIREWALL.md retains the original proposal/backend evaluation;
do not import its unimplemented topology checks into current documentation.
12. Installer and Host Commands
Installer is the only owner of apt installation, Firecracker artifact
handling, downloads, and integrity-manifest publication.
Settings detects and normalizes the host architecture at construction time
(amd64 to x86_64, arm64 to aarch64) and rejects unsupported architectures.
The same architecture selects the Firecracker release archive, guest kernel,
native rootfs packages, and architecture-specific boot arguments. The default
kernel URL is pinned to Firecracker's published
firecracker-ci/v1.15/<architecture>/vmlinux-6.1.155 artifacts. The CI artifact
series is independent of the VMM release. Both architecture configs include
CONFIG_HW_RANDOM=y and CONFIG_HW_RANDOM_VIRTIO=y; custom kernels need these
built-in drivers for UVM's entropy device. The previous ARM quickstart image
was Linux 4.14.174 without the built-in virtio RNG driver. Updating the device
configuration alone cannot repair that kernel; existing installs need a forced
asset refresh with a matching new trusted kernel checksum, then a fresh VM boot.
An empty Settings.rootfs_url selects the built Ubuntu 22.04 (Jammy) template.
Explicit kernel/rootfs URLs and trusted digests must match the host architecture. ARM64
uses an uncompressed Linux Image (PE format) and an ARM64 ext4 rootfs.
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
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux
tools. UVM adds no reconciliation daemon or tested external firewall-manager
integration profile.
system.py centralizes host command execution:
CommandRunner.run()converts subprocess failures intoCommandError.require_root()is called by host-mutating operations.check_kvm()validates the KVM device.ensure_data_directories()creates the UVM storage layout.
Use CommandRunner rather than direct subprocess.run in installation or
network code. This keeps command construction observable in the CLI and
mockable in tests.
Downloads use temporary sibling files, optional checksum verification, and atomic replacement. Archive extraction rejects path traversal before using Python's safe tar extraction filter.
13. HTTP API
Server Startup
See USAGE.md for dependency installation, loopback deployment,
TLS deployment, and authenticated curl examples. Launch the Nest workspace
through nest --serve, or the standalone backend through uvm --serve, using
the Python environment with the installed API dependencies. Every server
requires UVM_API_TOKEN, even at 127.0.0.1. Non-loopback binds additionally
require UVM_API_TLS_CERT and UVM_API_TLS_KEY; paired certificate/key files
are loaded into an ssl.SSLContext before Uvicorn starts.
The token must contain visible ASCII characters without whitespace. It is
never accepted as a UVM command-line argument, so it does not appear in UVM
process arguments. If you type an export command interactively, your shell
may record it in history; use a protected environment file, service manager,
or secret manager when that matters.
API Contract
The root redirect, dashboard document, and terminal-session routes belong to Nest. Both server entry points expose the UVM management routes below.
| Method | Route | Router | Notes |
|---|---|---|---|
GET |
/ |
nest/server.py |
Redirect to /dashboard (Nest only) |
GET |
/dashboard |
nest/dashboard/router.py |
Public Nest dashboard shell (Nest only) |
GET |
/health |
health.py |
Unauthenticated liveness response |
POST |
/exit |
exit.py |
Request graceful Uvicorn shutdown |
GET |
/diagnostics |
diagnostics.py |
Host readiness checks |
POST |
/install |
installation.py |
{ "force": false } body |
GET |
/vms |
vms.py |
List persisted VM records and observed status |
POST |
/vms |
vms.py |
cpu, ram, storage, guest_ip, username, password, name, ports, no_default_ports |
POST |
/vms/actions |
vms.py |
Fixed-ID snapshot bulk lifecycle action |
GET |
/vms/{identifier} |
vms.py |
Inspect by exact ID, name, or guest IP |
POST |
/vms/{identifier}/start |
vms.py |
Boot a retained private disk |
POST |
/vms/{identifier}/restart |
vms.py |
Restart; optional timeout query parameter |
POST |
/vms/{identifier}/resize |
vms.py |
Partial cpu/ram/storage update; stop, resize, boot; optional timeout |
POST |
/vms/{identifier}/stop |
vms.py |
Stop; optional timeout query parameter |
DELETE |
/vms/{identifier} |
vms.py |
Remove runtime files/state; optional timeout query parameter |
POST |
/vms/{vm_id}/ports |
vms.py |
Exact ID only; body has mode and ports |
POST |
/vms/{vm_id}/ports/sync |
vms.py |
Exact ID only; reapply managed policy |
POST |
/vms/{vm_id}/exec |
vms.py |
Exact ID; command, strict sudo, bounded timeout_seconds |
POST |
/vms/{vm_id}/agent/install |
vms.py |
Exact ID; install/upgrade the agent on a stopped disk |
GET |
/vms/{identifier}/logs |
vms.py |
Bounded non-following log tail |
GET |
/vms/{vm_id}/sessions |
nest/session_routes.py |
Saved session list (Nest only) |
POST |
/vms/{vm_id}/sessions |
nest/session_routes.py |
Launch a guest PTY; optional rows/cols in a JSON object (Nest only) |
GET |
/vms/{vm_id}/sessions/{session_id} |
nest/session_routes.py |
VM-scoped session metadata (Nest only) |
PATCH |
/vms/{vm_id}/sessions/{session_id} |
nest/session_routes.py |
Rename via a strict name body; identity retained (Nest only) |
DELETE |
/vms/{vm_id}/sessions/{session_id} |
nest/session_routes.py |
Close channel, join output reader, delete history; 204 (Nest only) |
POST |
/vms/{vm_id}/sessions/{session_id}/close |
nest/session_routes.py |
End a shell; retain history (Nest only) |
WS |
/vms/{vm_id}/sessions/{session_id}/terminal |
nest/session_routes.py |
Authenticated replay/input/resize (Nest only) |
All management routes use get_authorized_application() and require:
X-UVM-Token: <UVM_API_TOKEN>
POST /exit is a management route. The shared uvm.server.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 for Nest or UVM. It does not stop or delete any managed VM.
Request schemas reject unexpected fields. UvmError instances are rendered as
JSON errors by uvm/server.py; Pydantic request validation uses FastAPI's normal
422 response format. Create accepts name: str | null, ports: list | null,
and strict boolean no_default_ports (default false). Port objects contain a
strict integer port in 1-65535 and uppercase protocol TCP/HTTP/HTTPS (TCP
default). The router normalizes/deduplicates ports and adds TCP/22 unless
default ports are disabled, even for null/empty input. It never creates an
unmanaged VM from an omitted policy. Names share CLI validation.
Create accepts storage (default "30G"). Resize requires at least one non-null
cpu, ram, or storage; identity/credential fields are rejected by its schema.
Responses expose applied storage_mib and a nullable, resource-only
pending_resize object so interrupted requests are visible without secrets.
VM responses include name, ports, policy_status, and operation alongside
resource/status fields and username. The schema excludes password and
disk/socket/config/log path fields; error messages may still contain paths.
ports: null in a legacy response still means unmanaged. Start/restart/stop
and port-policy mutations report the resulting policy status. Restart, stop,
resize, and delete accept an optional finite non-negative timeout query parameter;
there is no dry-run HTTP schema. Port mutation bodies use
{"mode":"set"|"open"|"close", "ports":[...]} and always resolve the
target by exact ID to prevent name/IP reuse. A legacy policy must be set while
stopped before it can be started or synced. Typed not-found/conflict errors map
to 404/409.
Log reads accept a tail query parameter from 0 through 1000 and never follow
the file. LifecycleService.read_log_tail() reads at most the final 256 KiB and
marks a response truncated when an oversized line or that bound prevents a full
tail. Bulk bodies use an action plus either explicit identifiers or all_vms;
the router resolves them before run_many() executes the fixed IDs under one
operation lock. The API does not expose interactive SSH, raw Firecracker calls,
arbitrary host command execution, or host path access. Public /, /dashboard,
/docs, /redoc, and /openapi.json do not perform privileged management
requests.
Guest Execution
LifecycleService.execute() resolves an exact registry ID, validates canonical
paths and an observed running VMM with no pending operation, and connects under
operations.lock. GuestSession checks the Unix peer PID against the selected
VMM, sends Firecracker's CONNECT 1070\n, consumes its OK acknowledgement, and
checks the agent's protocol version, VM ID, and configured username. The lock is
released before submission/waiting; the established socket pins the original
VMM and is never reconnected across a concurrent restart or address reuse.
guest_agent.py is both a host-importable protocol module and a standalone
stdlib-only Python 3.10+ guest daemon. Keep it free of imports from the rest of
UVM. Version 1 uses length-prefixed JSON with bounds before allocation, absolute
socket deadlines, a 64 KiB UTF-8 command limit, and 1 MiB combined raw output.
The synchronous HTTP response exposes only exit code, stdout/stderr, timeout,
and truncation. Captured text uses UTF-8 replacement. Nonzero process exits are
HTTP 200 results; agent failures are 502, and failures before submission are 503.
Lost/malformed responses after submission carry outcome_unknown: true. There
is no command retry, durable job registry, streaming response, or reconnection
for these one-shot exec requests.
The root systemd agent accepts only host-CID connections and limits concurrent
connections/jobs to eight. Each request creates a delegated cgroup v2 job scope.
A fresh child interpreter joins the scope before initializing the chosen user's
supplementary groups, GID, UID, home, and environment, then execs /bin/sh -c.
sudo selects guest root directly; otherwise the recorded username is used.
Password authentication is not involved, and a recorded root login remains
root for both flag values. One-shot commands have closed stdin; Nest terminal
sessions use the separate PTY channel described below.
Both output pipes are drained concurrently. The guest enforces the deadline,
signals the process group, escalates via cgroup.kill, and confirms cleanup.
The leader is observed with waitid(WNOWAIT) until signaling finishes, preventing
PGID reuse before reap. Normal exits clean up remaining background descendants
too. An unconfirmed cleanup is an unknown outcome. Stopping the agent service
also kills its job scopes; dropping an HTTP connection does not cancel a job.
The v5 Ubuntu builder installs Python, the terminal-capable service, and an
ownership manifest. Agent setup is mandatory on every compatible image:
create() provisions the VM ID/username in root-only /etc/uvm-agent.json before
marking the disk ready, and _start_locked() ensures the agent after confirmed
runtime cleanup and pending resize, before booting. This covers stopped starts,
restarts, and resizing. There is no image-marker allowlist for agent setup.
An observed live or ambiguous VMM never permits offline agent writes.
_guest_agent_current() checks the expected payloads, identity, file metadata,
ownership manifest, and enabled service without mutating the disk. Current
installations return immediately. The explicit agent/install operation forces
a staged recheck for manual repair. The original manifest-less v4 agent is
recognized by its exact published source digest.
Provisioning and upgrades stage a sparse copy, replay/check ext4, validate guest path ownership and recorded agent-file hashes, enable the service, verify writes, and publish atomically. A root-only guest manifest records managed file hashes so later upgrades preserve guest edits. An older guest missing Python installs it through apt inside the guest on its next boot; no guest binaries execute on the host. This bootstrap requires working guest egress until the runtime is ready. Incompatible custom images or agent setup failures prevent boot with an actionable error; they are not silently skipped. New-VM failures roll back creation, while retained-VM failures preserve the private disk for retry.
The guest needs systemd, Python 3.10+, built-in virtio-vsock, and cgroup v2 with
cgroup.kill (Linux 5.14+). Host socket paths are derived from Settings, so the
registry schema stays at 3. Each Firecracker instance has an independent vsock
device and uses CID 3. The process adapter retains both sockets while identity
is uncertain and removes them only after confirmed VMM exit. IPv4 port policies
do not govern this host-only management channel.
Nest Terminal Sessions
Nest owns session HTTP/WebSocket routes and history. UVM owns the process-pinned
connection and guest PTY. LifecycleService.open_terminal() validates an exact
running VM ID and connects under the operation lock, then opens the PTY outside
the lock. It never reconnects that socket to a replacement VMM.
The existing version-1 hello and exec request stay compatible. A terminal request
uses {"version":1,"terminal":{"rows":24,"cols":80}}. The guest replies with
{"type":"ready"} and exchanges length-prefixed JSON output/input/resize/close
messages. Bytes are base64 encoded; raw chunks are at most 8192 bytes, frames
16384 bytes, and dimensions 2–200 rows / 2–400 columns. Partial frames have finite
deadlines while idle shells can remain connected indefinitely. An old agent
rejects the new request; the host reports the stopped-disk upgrade procedure.
The guest opens a real PTY and spawns a fresh interpreter in a new OS session.
The child joins its delegated job cgroup, establishes the controlling terminal,
assumes the recorded account, and execs that account's interactive shell with
TERM=xterm-256color. Input, terminal echo, Ctrl+C, foreground job control, and
window-size changes are handled by the guest terminal driver. Normal exit,
explicit close, or host connection loss cleans up the job cgroup and processes.
SessionManager owns background output readers independently of WebSocket
attachments. Accepted creation, rename, close, and delete operations are tracked
and shielded from HTTP disconnects. Creation and termination share a session
lock, so deleting a starting session waits for its channel to be tracked and
then closes it. Deletion joins the output reader and its pending writes before
atomically removing the metadata and transcript rows. Browser navigation,
reload, and logout detach only
the browser. Nest shutdown closes guest connections and marks live sessions
interrupted. In-flight transcript writes finish before releasing store ownership.
SessionStore keeps metadata and raw output in
$UVM_BASE/nest/sessions.sqlite3, separate from the VM registry. A private
directory, mode-0600 files, an exclusive owner lock, and SQLite transactions
protect history. On reopening after a server restart, unfinished records become
interrupted. Session lookups always include the exact VM ID. Output is capped at
the newest 8 MiB per session with byte offsets and an explicit truncation marker;
session metadata is retained until explicit deletion. Session names are trimmed,
1–80 printable characters, and are display labels rather than identifiers.
Renaming updates only the name and modification time; generated default names
avoid labels still present after renames or deletions. Only PTY output is
recorded, not a separate input
or password log. Nest permits 32 active sessions; guest terminals share the
agent's eight connection slots with one-shot jobs.
HTTP routes use the existing X-UVM-Token dependency. Browser WebSockets check
the Origin and require {"type":"auth","token":"...","offset":0} as their
first frame within ten seconds. Tokens never enter query strings. Each output
message has its byte offset and base64 data; the browser acknowledges the next
offset after xterm renders the chunk. Only one chunk is outstanding, providing
backpressure. Reattachment resumes from an acknowledged offset; a truncated
prefix resets the viewer. Closed sessions use the same channel for read-only
replay. WebSocket transport support comes from the websockets dependency.
Renames notify attached viewers through updated session metadata. A deleted
session sends a deleted event to authenticated attachments; polling also clears
stale selections for completed sessions whose replay connection already closed.
The dashboard's Sessions tab and nested hash routes belong to the selected VM.
sessions.js owns attachment teardown, replay, reconnects, and the terminal
widget. Vendored xterm 5.5.0 and fit addon 0.10.0 (with MIT licenses) are served
locally under static/vendor/. Terminal keyboard input bypasses dashboard search
shortcuts. The widget has a 10,000-line scrollback and a 64 KiB per-paste limit.
14. Configuration Reference
The environment-variable table is in README.md;
config.py is the implementation authority. Keep the same
base/network/gateway/bridge settings across CLI and server operations. The
network identity is retained per VM and checked before resume.
Settings validates the gateway, bridge-name length, API token format, and
environment boolean values at process startup. Add configuration fields there,
not as module-level global constants in feature code.
15. Error Handling Rules
Expected failures inherit from UvmError:
| Error | Use |
|---|---|
ConfigurationError |
Invalid local settings or environment |
ValidationError |
Invalid CLI/API resource input |
StateError |
Unsafe or malformed persisted state |
NotFoundError |
Missing registry entry |
ConflictError |
Ambiguous identifier, allocation conflict, or unsafe/pending operation |
CommandError |
Required host command failure |
FirecrackerError |
VMM process or Unix API failure |
GuestAgentUnavailableError |
Agent connection/readiness failure before submission (HTTP 503) |
GuestExecutionError |
Execution failure or unknown outcome after submission (HTTP 502) |
TapCreationError |
TAP setup failed after interface creation |
CLI code catches UvmError and prints uvm: error: ... without a traceback.
Invalid CLI input returns 2, ordinary operational failure 1, and interruption
130. Batch JSON retains per-target outcomes rather than pretending a partial
success was atomic.
The FastAPI server maps the same class to an error envelope. New expected
operational failures should use this hierarchy rather than leaking raw
OSError, CalledProcessError, or implementation-specific exceptions.
16. Concurrency and Ownership Invariants
These invariants are critical:
- Only
LifecycleServicemay coordinate VM state, disks, TAPs, firewall, and Firecracker as one operation. - Only
StateStoremay read/writestate.json. - All long host mutations acquire
operations.lock. - All short inventory mutations acquire
state.lock. - The installer shares
operations.lockwith lifecycle actions, preventing a Firecracker binary replacement between verification and launch. - A VM rootfs is private before it becomes writable.
- Process signaling requires a safe pidfd and matching persisted start/boot identity; unknown is not gone. Legacy adoption must prove binary/socket identity.
- A new VM never adopts a pre-existing TAP or activates it before verified policy.
- A server must have an API token; a remote server must also have usable TLS.
- Routers call application services; they do not run shell commands or touch state files directly.
- Plaintext guest passwords remain in the mode-
0600registry and never enter Firecracker config, helper command arguments/traces, or CLI/API VM output. - Retained-disk start never overwrites guest data, credentials, or SSH host keys.
- No disk/IP release before confirmed exit and scoped network cleanup; no old-policy rollback after a failed close, and no resurrection of pending deletion.
- Policy state is observed from the actual owned rules, not a cached applied flag.
When a change would violate one of these rules, redesign the change rather than adding a special-case bypass.
17. Testing Strategy
Standard Unit Suite
python3 -m pip install -e '.[test]'
python3 -B -m unittest discover -s tests -v
Ordinary tests require no root, KVM, Firecracker, or host network mutations. They cover validation, schema migration/locking/permissions, artifact trust, private disks/credentials, network/firewall rendering and readback, conntrack semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting, CLI parsing/output, Nest dashboard delivery, and server security. Mock/fixture policy checks do not prove the kernel's actual packet behavior.
FastAPI route tests are conditionally enabled when both fastapi and httpx
are installed. They should be enabled in CI using the test extra.
test_nest_server.py checks the workspace launcher, relocated frontend assets,
authenticated machine controls, API documentation, and graceful shutdown.
test_sessions.py covers scoped persistence, interrupted-session recovery,
authentication, reconnects, output acknowledgements, and cancellation cleanup.
test_guest_terminal.py exercises actual PTYs, persistent shell state, resizing,
Ctrl+C, and child cleanup using temporary account homes and fake cgroup scopes.
Optional browser regression checks use Playwright against an isolated fixture server with fake VMs and guest channels. With the project's test extra installed:
npm install --prefix /tmp/nest-browser playwright
/tmp/nest-browser/node_modules/.bin/playwright install chromium
NODE_PATH=/tmp/nest-browser/node_modules \
NEST_TEST_PYTHON="$(command -v python)" node tests/browser_sessions.cjs
This checks launch/input, reload/reconnect, session switching, per-VM isolation, closed-session replay, rename persistence, active/ended deletion, cancellation, multi-tab updates, and the mobile layout. It does not boot a real microVM.
When nft and conntrack are installed, command-parser tests append an invalid
final command/option so parsing is checked without executing any mutation.
These checks supplement fixture-based readback and cleanup tests; they do not
exercise the kernel packet path.
test_storage.py also exercises real e2fsprogs against temporary unmounted,
sparse ext4 images when the tools are installed: default 30 GiB creation,
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures.
test_images.py uses temporary ext4 images to verify custom-user creation,
UID/GID allocation, account/home metadata, existing accounts, and password hashes.
test_install.py simulates package bootstrap and exercises real ext4 formatting
and readback for retained dpkg metadata, sudo modes, template configuration,
staging failures, durable build diagnostics, and checksum provenance. The apt
shell script runs against stub mount/chroot commands to check argument handling
and stop-on-error behavior. Tests simulate Ubuntu removing policy-rc.d after
bootstrap. They do not run debootstrap or boot a VM.
Lifecycle resize tests use fake processes/networking to verify ordering,
single-lock ownership, identity preservation, pending intent, and failure recovery.
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.
Guest execution tests use real child processes with fake cgroup scopes, local
Unix sockets emulating Firecracker's vsock handshake, API validation fixtures,
and real temporary ext4 upgrades. They cover credential selection, concurrent
output draining, timeout cleanup, connection loss, VM targeting/locking, and
upgrade rollback. tests/integration/test_guest_exec.py is an opt-in smoke test
against a prepared disposable VM with a non-root login and all TCP ports closed;
see USAGE.md. Unit tests do not validate the real
guest vsock device or cgroup kernel path.
Isolated Packet Tests
The opt-in packet harness must run only in disposable isolated user/network
namespaces. It verifies namespace separation before creating links or
changing rules, and never falls back to the parent host namespace. It may
require privileges (or enabled unprivileged user namespaces) plus unshare,
ip, bridge, nft, iptables, conntrack, and sysctl:
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 two simulated uplinks. It exercises NAT with a pre-existing terminal FORWARD drop, offline startup, uplink changes and reconnects for host/guest TCP and guest UDP/53. It also checks host/peer service traffic, both-direction close of existing streams while preserving conntrack/egress, IPv6/spoof blocking, quarantine, and offline cleanup. It does not boot a microVM or validate a real TAP deployment.
The packet test skipped in this workspace because namespace creation was
denied (Operation not permitted). Document that permission limitation, not
an assertion that real packets or a real host were validated. Unit tests and
nft JSON parser checks are not substitutes for kernel packet-path execution.
Isolated Host Validation
No current test automatically boots a real microVM. Before a release, validate on an isolated Linux host:
- Strict checked installation.
createand successful guest boot.- Guest IP reachability and egress, including the guest's default route after systemd-networkd startup and reconfiguration.
- SSH with unique guest host keys,
sudo -ifor a custom login, andsudo apt update && sudo apt install htopin a newly built Ubuntu guest. - Stop/start/restart with retained guest data, SSH keys, and credentials.
- Live private-port close in both directions, empty policy, and explicit sync after deliberately lost rules inside the isolated setup.
- Delete and IP/TAP/policy/conntrack cleanup.
- API launch with token and TLS on a non-loopback test address.
- Firecracker crash, interrupted create/start/stop/delete, and legacy recovery.
18. Common Development Tasks
- CLI: keep business logic in services; wire arguments in
build_parser()and dispatch/output in_run_command(). Test validation, prompts, dry runs, JSON, and service behavior; useUvmErrorfor expected failures. - HTTP: define strict models in
api_models.py, use the appropriate router andget_authorized_application(), and register new routers increate_api(). Add API tests and update all route tables. New CLI commands do not implicitly authorize new HTTP endpoints;/healthremains the only unauthenticated operational API route. Nest owns the public/and/dashboardroutes. - Dashboard: keep the document route and frontend assets in
nest/dashboard/. Compose workspace-level routes innest/server.py; machine-management operations continue through the UVM API and its application services. - Host primitives: use the specific network/firewall/install/process module
and
CommandRunnerfor external commands. Define ownership, idempotence, and compensation before invoking a primitive from lifecycle code. Use fake runners rather than requiring privileges in unit tests. - State: update
VmRecord/State, schema tests, cleanup, and documentation. Version incompatible interpretations and preserve older data or provide an explicit migration. Consider lock order and crash/retry behavior first.
19. Debugging Guide
| Symptom | First places to inspect |
|---|---|
guest assets missing |
/var/lib/uvm/images, integrity variables, integrity.json |
| 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 |
| Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID |
| Guest has no egress | Host connectivity/routes, bridge/TAP state, uvm-egress iptables rules; start refreshes networking on a running VM |
Guest says Network is unreachable |
Guest ip -4 route; the default route must use the UVM bridge gateway. Restart once with updated code to migrate an original Ubuntu profile. |
| Policy missing/drift/unknown | Actual nft JSON and ownership, STP/group mask, permissions; explicit sync after fixing cause |
| Retained-disk start fails | disk_ready, safe private path, kernel/VMM integrity, process identity, retained network, pending operation |
| API server refuses to start | UVM_API_TOKEN, requested host, TLS cert/key files |
| API returns 401 | X-UVM-Token header mismatch |
| API returns 422 | Request schema or UVM input validation failure |
Use inspect VM --json, logs VM, ports list --all, and doctor before
examining host state. USAGE.md lists read-only bridge/nft/iptables
debugging commands. Never share the credential registry or unredacted guest
logs. Do not manually mutate UVM resources during a lifecycle operation;
fix the service/host failure and retry through supported commands.
20. Current Production Gaps
The code is intentionally honest about current limits:
- Firecracker is launched directly, even though the Jailer binary is downloaded.
- No cgroup CPU, memory, I/O, or process limits are applied.
- Fractional CPU requests round vCPUs up without host quota enforcement.
- Guest health is inferred from VMM process state, not application readiness.
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
- No guest snapshots, extra disks, production image builder, or SSH-key injection.
- Per-VM private TCP ingress policy exists, but public inbound connectivity, DNS publication, guest reverse proxy/TLS, and external firewall integration remain out of scope.
- Shared egress
iptablesrules are incremental and idempotent; only the private bridge policy is atomically rendered per VM. - No autostart, background reconciliation, or guest-coordinated shutdown.
- The JSON inventory is suitable for one host, not distributed coordination.
- Guest passwords are stored in plaintext in the root-only local registry.
- API authentication is a single bearer token, not a user/tenant model.
Do not market UVM as a hardened multi-tenant platform until these boundaries are deliberately addressed with architecture, tests, and operational review.
21. Contributor Checklist
Before submitting a change:
- Run the complete unit suite.
- Run
git diff --check. - Keep workspace/dashboard features in
nest/and machine-management features inuvm/. - Verify command/API errors use
UvmErrorrather than raw tracebacks. - Verify host mutations have a lock owner and cleanup path.
- Verify persistent state remains readable across upgrades.
- Update
README.mdfor operator-facing behavior. - Update this document for architectural, trust-boundary, or lifecycle changes.