Compare commits
23
Commits
10a58daa56
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2ceb3bcc | ||
|
|
a4e9cefbc3 | ||
|
|
6573f483e6 | ||
|
|
d870c98bfa | ||
|
|
caaea12163 | ||
|
|
d202f9e2f8 | ||
|
|
6aa70133c5 | ||
|
|
73d85fefe4 | ||
|
|
9541e29b76 | ||
|
|
3b94ac1b48 | ||
|
|
4663fd1923 | ||
|
|
b0bf99a6ee | ||
|
|
dca7f78dae | ||
|
|
283eb4ecf7 | ||
|
|
660ba9efee | ||
|
|
dca3cbde2f | ||
|
|
eb6119c797 | ||
|
|
0f72ac693a | ||
|
|
ed9c5306af | ||
|
|
534a78f2a9 | ||
|
|
bd92573158 | ||
|
|
1d3644ab7c | ||
|
|
4bf4c0853c |
+393
-65
@@ -1,11 +1,12 @@
|
|||||||
# UVM Developer Guide
|
# Nest Developer Guide
|
||||||
|
|
||||||
This document is the developer handoff for the entire UVM codebase. Read it
|
This document is the developer handoff for Nest and its UVM backend. Read it
|
||||||
before changing lifecycle behavior, host networking, Firecracker launch
|
before changing lifecycle behavior, host networking, Firecracker launch
|
||||||
semantics, persistent state, or the HTTP API.
|
semantics, persistent state, or the HTTP API.
|
||||||
|
|
||||||
`uvm` is a local, privileged Firecracker microVM manager. It is an
|
Nest owns the workspace dashboard and its server entry point. Its `uvm`
|
||||||
application with a CLI and optional FastAPI management server. It is not a
|
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.
|
general-purpose Python library or a multi-host control plane.
|
||||||
|
|
||||||
## 1. Goals and Boundaries
|
## 1. Goals and Boundaries
|
||||||
@@ -13,10 +14,10 @@ general-purpose Python library or a multi-host control plane.
|
|||||||
UVM installs host/guest assets, provisions private disks, and manages named
|
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
|
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,
|
start/stop/restart, CPU/RAM/storage resizing, deletion, fixed-target bulk operations, inspection, SSH,
|
||||||
logs, doctor, and explicit port edits/sync. The optional API and local dashboard
|
logs, doctor, and explicit port edits/sync. The UVM API and Nest dashboard
|
||||||
expose health, installation, VM lifecycle controls, and managed port-policy
|
expose health, installation, VM lifecycle controls, and managed port-policy
|
||||||
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
||||||
operations. Interactive SSH remains CLI-only.
|
operations, and bounded guest command execution over vsock. Interactive SSH remains CLI-only.
|
||||||
|
|
||||||
There is no autostart or background reconciler, multi-host control plane,
|
There is no autostart or background reconciler, multi-host control plane,
|
||||||
tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port
|
tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port
|
||||||
@@ -30,16 +31,18 @@ flag reference, see [USAGE.md](USAGE.md).
|
|||||||
|
|
||||||
## 2. First Run
|
## 2. First Run
|
||||||
|
|
||||||
All UVM-specific files live in this directory. Start here:
|
All Nest and UVM files live in this project. Start here:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m pip install -e '.[test]'
|
python3 -m pip install -e '.[test]'
|
||||||
python3 -B -m unittest discover -s tests -v
|
python3 -B -m unittest discover -s tests -v
|
||||||
|
./nest.py --help
|
||||||
./uvm.py --help
|
./uvm.py --help
|
||||||
```
|
```
|
||||||
|
|
||||||
The `uvm` console script is registered by `pyproject.toml`. During source
|
The `nest` and `uvm` console scripts are registered by `pyproject.toml`.
|
||||||
development, `./uvm.py` and `python3 -B -m uvm` are equivalent entry points.
|
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:
|
Requirements for real host operations:
|
||||||
|
|
||||||
@@ -63,7 +66,18 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
|||||||
├── README.md # Project overview
|
├── README.md # Project overview
|
||||||
├── USAGE.md # Operator command/deployment guide
|
├── USAGE.md # Operator command/deployment guide
|
||||||
├── pyproject.toml # Package metadata and dependencies
|
├── pyproject.toml # Package metadata and dependencies
|
||||||
├── uvm.py # Executable source-tree launcher
|
├── 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/
|
├── uvm/
|
||||||
│ ├── __main__.py # `python -m uvm`
|
│ ├── __main__.py # `python -m uvm`
|
||||||
│ ├── cli.py # argparse, CLI rendering, serve dispatch
|
│ ├── cli.py # argparse, CLI rendering, serve dispatch
|
||||||
@@ -78,16 +92,18 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
|||||||
│ ├── install.py # Apt, Firecracker, kernel, rootfs installation
|
│ ├── install.py # Apt, Firecracker, kernel, rootfs installation
|
||||||
│ ├── images.py # Template lookup, private disks, and guest credentials
|
│ ├── images.py # Template lookup, private disks, and guest credentials
|
||||||
│ ├── known_hosts.py # Deletion-only cleanup of local SSH host-key caches
|
│ ├── 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
|
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
|
||||||
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
|
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
|
||||||
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
|
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
|
||||||
│ ├── api_models.py # Pydantic HTTP request/response models
|
│ ├── api_models.py # Pydantic HTTP request/response models
|
||||||
│ ├── server.py # FastAPI factory and Uvicorn launcher
|
│ ├── server.py # FastAPI factory and Uvicorn launcher
|
||||||
│ ├── dashboard/ # Packaged local dashboard HTML, CSS, and JS
|
|
||||||
│ ├── routers/
|
│ ├── routers/
|
||||||
│ │ ├── dashboard.py # Dashboard document route
|
|
||||||
│ │ ├── diagnostics.py # Host diagnostic endpoint
|
│ │ ├── diagnostics.py # Host diagnostic endpoint
|
||||||
│ │ ├── dependencies.py # API application/token dependencies
|
│ │ ├── dependencies.py # API application/token dependencies
|
||||||
|
│ │ ├── exit.py # Authenticated server shutdown
|
||||||
│ │ ├── health.py # Liveness endpoint
|
│ │ ├── health.py # Liveness endpoint
|
||||||
│ │ ├── installation.py # Installation endpoint
|
│ │ ├── installation.py # Installation endpoint
|
||||||
│ │ └── vms.py # VM lifecycle endpoints
|
│ │ └── vms.py # VM lifecycle endpoints
|
||||||
@@ -106,8 +122,10 @@ are intentionally small and operationally focused.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
CLI[CLI: uvm] --> APP[app.py composition root]
|
DASH[Nest dashboard] --> NEST[nest/server.py workspace]
|
||||||
HTTP[FastAPI server] --> APP
|
NEST --> HTTP[UVM management API]
|
||||||
|
CLI[CLI: uvm] --> APP[uvm/app.py composition root]
|
||||||
|
HTTP --> APP
|
||||||
APP --> INSTALL[Installer]
|
APP --> INSTALL[Installer]
|
||||||
APP --> LIFE[LifecycleService]
|
APP --> LIFE[LifecycleService]
|
||||||
APP --> STATE[StateStore]
|
APP --> STATE[StateStore]
|
||||||
@@ -125,8 +143,9 @@ flowchart LR
|
|||||||
FC --> KVM[/dev/kvm]
|
FC --> KVM[/dev/kvm]
|
||||||
```
|
```
|
||||||
|
|
||||||
The public entry points are thin adapters. The lifecycle layer is the only
|
The public entry points are thin adapters. Nest composes the UVM API and adds
|
||||||
place that coordinates multiple host resources for a VM. Lower-level modules
|
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`.
|
must not import `cli.py`, `server.py`, or `lifecycle.py`.
|
||||||
|
|
||||||
Leaf modules are `config.py`, `domain.py`, `errors.py`, and `validation.py`.
|
Leaf modules are `config.py`, `domain.py`, `errors.py`, and `validation.py`.
|
||||||
@@ -163,6 +182,11 @@ checks, not per-VM repair or guest application probes.
|
|||||||
|
|
||||||
### HTTP Path
|
### 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,
|
Routers validate the token and Pydantic request, invoke application services,
|
||||||
and render whitelisted response models. Lifecycle, managed port-policy,
|
and render whitelisted response models. Lifecycle, managed port-policy,
|
||||||
diagnostics, and bounded log-tail operations have explicit HTTP routes for the
|
diagnostics, and bounded log-tail operations have explicit HTTP routes for the
|
||||||
@@ -171,9 +195,11 @@ 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
|
validation: it stores a supplied token only for the active browser session and
|
||||||
sends it with management requests.
|
sends it with management requests.
|
||||||
|
|
||||||
Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The
|
Launch the workspace with `nest --serve` or `./nest.py --serve`. The standalone
|
||||||
`create_api()` factory requires an explicit host specifically to prevent it
|
backend uses `uvm --serve` or `./uvm.py --serve`. Both `create_app()` and
|
||||||
from being used as an insecure generic Uvicorn factory.
|
`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
|
## 6. Application Composition
|
||||||
|
|
||||||
@@ -211,13 +237,15 @@ 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
|
||||||
│ ├── config.json
|
│ ├── config.json
|
||||||
│ ├── firecracker.log
|
│ ├── firecracker.log
|
||||||
│ └── firecracker.sock
|
│ ├── firecracker.sock
|
||||||
|
│ └── vsock.sock
|
||||||
├── state.json
|
├── state.json
|
||||||
├── state.lock
|
├── state.lock
|
||||||
├── operations.lock
|
├── operations.lock
|
||||||
@@ -335,7 +363,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 +400,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 +525,20 @@ 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 /vsock` with a guest CID and a per-VM Unix socket path
|
||||||
|
6. `PUT /entropy` with `{}` (a host-backed virtio RNG device)
|
||||||
|
7. `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 +593,42 @@ 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/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 Model
|
||||||
|
|
||||||
[`integrity.py`](uvm/integrity.py) has two concepts:
|
[`integrity.py`](uvm/integrity.py) has two concepts:
|
||||||
@@ -557,14 +637,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 +677,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 +692,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 +701,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 +774,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 +798,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
|
||||||
@@ -725,8 +866,9 @@ Python's safe tar extraction filter.
|
|||||||
### Server Startup
|
### Server Startup
|
||||||
|
|
||||||
See [USAGE.md](USAGE.md) for dependency installation, loopback deployment,
|
See [USAGE.md](USAGE.md) for dependency installation, loopback deployment,
|
||||||
TLS deployment, and authenticated curl examples. Launch through `uvm --serve`
|
TLS deployment, and authenticated curl examples. Launch the Nest workspace
|
||||||
using the Python environment with the installed API dependencies. Every server
|
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
|
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
|
require `UVM_API_TLS_CERT` and `UVM_API_TLS_KEY`; paired certificate/key files
|
||||||
are loaded into an `ssl.SSLContext` before Uvicorn starts.
|
are loaded into an `ssl.SSLContext` before Uvicorn starts.
|
||||||
@@ -739,11 +881,15 @@ or secret manager when that matters.
|
|||||||
|
|
||||||
### API Contract
|
### 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 |
|
| Method | Route | Router | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
| `GET` | `/` | `nest/server.py` | Redirect to `/dashboard` (Nest only) |
|
||||||
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
| `GET` | `/dashboard` | `nest/dashboard/router.py` | Public Nest dashboard shell (Nest only) |
|
||||||
| `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 |
|
||||||
@@ -757,7 +903,14 @@ or secret manager when that matters.
|
|||||||
| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files/state; 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` | `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}/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/{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) |
|
||||||
|
| `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:
|
All management routes use `get_authorized_application()` and require:
|
||||||
|
|
||||||
@@ -765,8 +918,14 @@ 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. 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
|
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 `uvm/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`,
|
||||||
and strict boolean `no_default_ports` (default false). Port objects contain a
|
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
|
strict integer `port` in 1-65535 and uppercase `protocol` TCP/HTTP/HTTPS (TCP
|
||||||
@@ -797,10 +956,131 @@ 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`;
|
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
|
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,
|
operation lock. The API does not expose interactive SSH, raw Firecracker calls,
|
||||||
arbitrary command execution, or host path access. Public `/`, `/dashboard`,
|
arbitrary host command execution, or host path access. Public `/`, `/dashboard`,
|
||||||
`/docs`, `/redoc`, and `/openapi.json` do not perform privileged management
|
`/docs`, `/redoc`, and `/openapi.json` do not perform privileged management
|
||||||
requests.
|
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. Creating a shell is shielded from an HTTP disconnect; its connection
|
||||||
|
is either tracked or closed. 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. 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.
|
||||||
|
|
||||||
|
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
|
## 14. Configuration Reference
|
||||||
|
|
||||||
The environment-variable table is in [README.md](README.md#configuration);
|
The environment-variable table is in [README.md](README.md#configuration);
|
||||||
@@ -825,6 +1105,8 @@ Expected failures inherit from `UvmError`:
|
|||||||
| `ConflictError` | Ambiguous identifier, allocation conflict, or unsafe/pending operation |
|
| `ConflictError` | Ambiguous identifier, allocation conflict, or unsafe/pending operation |
|
||||||
| `CommandError` | Required host command failure |
|
| `CommandError` | Required host command failure |
|
||||||
| `FirecrackerError` | VMM process or Unix API 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 |
|
| `TapCreationError` | TAP setup failed after interface creation |
|
||||||
|
|
||||||
CLI code catches `UvmError` and prints `uvm: error: ...` without a traceback.
|
CLI code catches `UvmError` and prints `uvm: error: ...` without a traceback.
|
||||||
@@ -877,11 +1159,31 @@ Ordinary tests require no root, KVM, Firecracker, or host network mutations.
|
|||||||
They cover validation, schema migration/locking/permissions, artifact trust,
|
They cover validation, schema migration/locking/permissions, artifact trust,
|
||||||
private disks/credentials, network/firewall rendering and readback, conntrack
|
private disks/credentials, network/firewall rendering and readback, conntrack
|
||||||
semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting,
|
semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting,
|
||||||
CLI parsing/output, and server security. Mock/fixture policy checks do not
|
CLI parsing/output, Nest dashboard delivery, and server security. Mock/fixture
|
||||||
prove the kernel's actual packet behavior.
|
policy checks do not prove the kernel's actual packet behavior.
|
||||||
|
|
||||||
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
||||||
are installed. They should be enabled in CI using the `test` extra.
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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, and the mobile layout. It does not boot a real microVM.
|
||||||
|
|
||||||
When `nft` and `conntrack` are installed, command-parser tests append an invalid
|
When `nft` and `conntrack` are installed, command-parser tests append an invalid
|
||||||
final command/option so parsing is checked without executing any mutation.
|
final command/option so parsing is checked without executing any mutation.
|
||||||
These checks supplement fixture-based readback and cleanup tests; they do not
|
These checks supplement fixture-based readback and cleanup tests; they do not
|
||||||
@@ -892,8 +1194,27 @@ 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.
|
||||||
|
|
||||||
|
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](USAGE.md#18-verification). Unit tests do not validate the real
|
||||||
|
guest vsock device or cgroup kernel path.
|
||||||
|
|
||||||
### Isolated Packet Tests
|
### Isolated Packet Tests
|
||||||
|
|
||||||
@@ -901,17 +1222,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 +1247,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.
|
||||||
@@ -943,8 +1267,10 @@ on an isolated Linux host:
|
|||||||
`get_authorized_application()`, and register new routers in `create_api()`.
|
`get_authorized_application()`, and register new routers in `create_api()`.
|
||||||
Add API tests and update all route tables. New CLI commands do not implicitly
|
Add API tests and update all route tables. New CLI commands do not implicitly
|
||||||
authorize new HTTP endpoints; `/health` remains the only unauthenticated
|
authorize new HTTP endpoints; `/health` remains the only unauthenticated
|
||||||
operational API route, while `/` and `/dashboard` only serve the public
|
operational API route. Nest owns the public `/` and `/dashboard` routes.
|
||||||
dashboard shell.
|
- Dashboard: keep the document route and frontend assets in `nest/dashboard/`.
|
||||||
|
Compose workspace-level routes in `nest/server.py`; machine-management
|
||||||
|
operations continue through the UVM API and its application services.
|
||||||
- Host primitives: use the specific network/firewall/install/process module
|
- Host primitives: use the specific network/firewall/install/process module
|
||||||
and `CommandRunner` for external commands. Define ownership, idempotence,
|
and `CommandRunner` for external commands. Define ownership, idempotence,
|
||||||
and compensation before invoking a primitive from lifecycle code. Use fake
|
and compensation before invoking a primitive from lifecycle code. Use fake
|
||||||
@@ -961,7 +1287,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 +1312,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.
|
||||||
@@ -1003,7 +1331,7 @@ Before submitting a change:
|
|||||||
|
|
||||||
1. Run the complete unit suite.
|
1. Run the complete unit suite.
|
||||||
2. Run `git diff --check`.
|
2. Run `git diff --check`.
|
||||||
3. Confirm a new feature stays inside the `uvm/` project directory.
|
3. Keep workspace/dashboard features in `nest/` and machine-management features in `uvm/`.
|
||||||
4. Verify command/API errors use `UvmError` rather than raw tracebacks.
|
4. Verify command/API errors use `UvmError` rather than raw tracebacks.
|
||||||
5. Verify host mutations have a lock owner and cleanup path.
|
5. Verify host mutations have a lock owner and cleanup path.
|
||||||
6. Verify persistent state remains readable across upgrades.
|
6. Verify persistent state remains readable across upgrades.
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
# uvm
|
# Nest
|
||||||
|
|
||||||
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu.
|
Nest is a local infrastructure workspace with a browser dashboard for your
|
||||||
It creates one Firecracker process per VM, attaches it to a host TAP device,
|
machines, network, and host health. Launch it with `nest --serve`.
|
||||||
|
|
||||||
|
Its `uvm` component is a small, local Firecracker microVM manager for Ubuntu.
|
||||||
|
UVM 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. Nest owns the dashboard and
|
||||||
metadata, launcher scripts, and documentation live here.
|
workspace server; UVM provides the machine-management CLI and backend services.
|
||||||
|
All code, tests, packaging metadata, launchers, and documentation live here.
|
||||||
|
|
||||||
For architecture, lifecycle invariants, extension guidance, and contributor
|
For architecture, lifecycle invariants, extension guidance, and contributor
|
||||||
workflows, see [DEVELOPER.md](DEVELOPER.md).
|
workflows, see [DEVELOPER.md](DEVELOPER.md).
|
||||||
@@ -24,11 +28,13 @@ feature contract; these guides describe the implementation in the workspace.
|
|||||||
Run commands from this project root:
|
Run commands from this project root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
./nest.py --help
|
||||||
|
python3 -B -m nest --help
|
||||||
./uvm.py --help
|
./uvm.py --help
|
||||||
python3 -B -m uvm --help
|
python3 -B -m uvm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
The `pyproject.toml` file also defines an installable `uvm` console command.
|
The `pyproject.toml` file defines installable `nest` and `uvm` console commands.
|
||||||
The project is an application, not a reusable Python SDK.
|
The project is an application, not a reusable Python SDK.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -48,12 +54,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 +83,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,12 +272,12 @@ 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.
|
||||||
|
|
||||||
## Management API
|
## Nest Dashboard and Management API
|
||||||
|
|
||||||
Install the project dependencies before serving the API:
|
Install the project dependencies before serving the API:
|
||||||
|
|
||||||
@@ -245,28 +285,34 @@ Install the project dependencies before serving the API:
|
|||||||
python3 -m pip install .
|
python3 -m pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
Launch the FastAPI management server using the environment where the project
|
Launch the Nest workspace server using the environment where the project
|
||||||
dependencies were installed (use the full console-script path if needed):
|
dependencies were installed (use the full console-script path if needed):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
||||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
uvm --serve --host 127.0.0.1 --port 8000
|
nest --serve --host 127.0.0.1 --port 8000
|
||||||
|
|
||||||
# Equivalent when running the source checkout directly.
|
# Equivalent when running the source checkout directly.
|
||||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
./nest.py --serve --host 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
The default bind address is `127.0.0.1` and the default port is `8000`.
|
The default bind address is `127.0.0.1` and the default port is `8000`.
|
||||||
OpenAPI documentation is available at `/docs` once the server is running. The
|
OpenAPI documentation is available at `/docs` once the server is running. The
|
||||||
root URL redirects to the local dashboard at `/dashboard`; enter the API token
|
root URL redirects to the Nest dashboard at `/dashboard`; enter the API token
|
||||||
there for the current browser session.
|
there for the current browser session.
|
||||||
|
|
||||||
The dashboard includes a fleet overview, searchable machine collection, private
|
Nest serves the dashboard and UVM-backed management endpoints together, using
|
||||||
network and port-policy view, host diagnostics, and machine provisioning. Open a
|
the existing `UVM_API_TOKEN` and `UVM_*` host configuration. The standalone UVM
|
||||||
machine to manage its lifecycle, edit or sync its port rules, copy its SSH command,
|
API is available through `uvm --serve`, with its reference at `/docs`; the `/`
|
||||||
or read recent logs. Select machines in the collection for bulk operations.
|
and `/dashboard` routes belong to the Nest server.
|
||||||
|
|
||||||
|
The Nest dashboard includes a fleet overview, searchable machine collection,
|
||||||
|
private network and port-policy view, host diagnostics, and machine provisioning.
|
||||||
|
Open a machine to manage its lifecycle, launch terminal sessions, edit or sync
|
||||||
|
its port rules, copy its SSH command, or read recent logs. Select machines in the
|
||||||
|
collection for bulk operations.
|
||||||
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
|
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
|
||||||
five seconds while the tab is visible; host checks refresh every thirty seconds
|
five seconds while the tab is visible; host checks refresh every thirty seconds
|
||||||
and can also be run manually. Charts show observed machine states and allocated
|
and can also be run manually. Charts show observed machine states and allocated
|
||||||
@@ -277,9 +323,10 @@ The server exposes these routes:
|
|||||||
|
|
||||||
| Method | Route | Purpose |
|
| Method | Route | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/` | Redirect to the local dashboard |
|
| `GET` | `/` | Redirect to the dashboard (Nest server) |
|
||||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
|
||||||
| `GET` | `/health` | Unauthenticated liveness response |
|
| `GET` | `/health` | Unauthenticated liveness response |
|
||||||
|
| `POST` | `/exit` | Gracefully stop the management 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 |
|
||||||
@@ -293,7 +340,14 @@ The server exposes these routes:
|
|||||||
| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter |
|
| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter |
|
||||||
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID |
|
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID |
|
||||||
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID |
|
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID |
|
||||||
|
| `POST` | `/vms/{vm_id}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges |
|
||||||
|
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk |
|
||||||
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
|
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
|
||||||
|
| `GET` | `/vms/{vm_id}/sessions` | List this VM's saved sessions (Nest) |
|
||||||
|
| `POST` | `/vms/{vm_id}/sessions` | Launch a guest terminal; body: `{}` or `{"rows":24,"cols":80}` (Nest) |
|
||||||
|
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | Session metadata and status (Nest) |
|
||||||
|
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | End the shell and retain its history (Nest) |
|
||||||
|
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated terminal input, output, resize, and replay (Nest) |
|
||||||
|
|
||||||
API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`;
|
API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`;
|
||||||
`no_default_ports` is a boolean, default `false`. Create also accepts `storage`,
|
`no_default_ports` is a boolean, default `false`. Create also accepts `storage`,
|
||||||
@@ -311,6 +365,81 @@ Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or
|
|||||||
for each fixed target and do not prompt for confirmation. Interactive SSH
|
for each fixed target and do not prompt for confirmation. Interactive SSH
|
||||||
remains CLI-only. The API and dashboard are not background reconcilers.
|
remains CLI-only. The API and dashboard are not background reconcilers.
|
||||||
|
|
||||||
|
### MicroVM Terminal Sessions
|
||||||
|
|
||||||
|
Open **Machines → select a microVM → Sessions**, then click **Launch session**.
|
||||||
|
Each session opens an interactive guest shell in the dashboard, with terminal
|
||||||
|
colors, keyboard input, Ctrl+C, resizing, and persistent shell state such as the
|
||||||
|
working directory. Sessions run as the VM's configured guest user; normal guest
|
||||||
|
`sudo` commands can request that user's password in the terminal.
|
||||||
|
|
||||||
|
The history list belongs to the selected VM. Reopen an active session to
|
||||||
|
continue using the same shell, or select an ended session to view its saved
|
||||||
|
output. Navigation, browser reloads, and signing out leave the shell running.
|
||||||
|
**End session** closes the shell and its processes. Stopping/restarting the VM
|
||||||
|
or the Nest server ends live sessions; their saved history remains available.
|
||||||
|
|
||||||
|
Nest stores session metadata and the latest **8 MiB of output per session** in
|
||||||
|
`$UVM_BASE/nest/sessions.sqlite3` (default `/var/lib/uvm/nest/sessions.sqlite3`).
|
||||||
|
The viewer indicates truncated output. Raw keyboard input is not logged
|
||||||
|
separately; the transcript contains what the guest terminal outputs. History
|
||||||
|
files are private to the host account running Nest. One Nest server owns
|
||||||
|
sessions for a given VM registry at a time, with up to 32 active sessions; the
|
||||||
|
guest agent shares its eight connection slots between terminals and exec calls.
|
||||||
|
|
||||||
|
**Vsock and the guest agent are configured by default for every microVM.**
|
||||||
|
Creation installs and enables the agent on every compatible image, including
|
||||||
|
cached older templates and custom images. Each stopped-disk boot also installs
|
||||||
|
or updates it automatically; an already-current installation is a read-only
|
||||||
|
check. Existing running VMs receive the setup on their next restart.
|
||||||
|
The **Sessions → Guest terminal support → Repair / update terminal support**
|
||||||
|
control is available for manual repair on a stopped VM. The guest-agent/kernel
|
||||||
|
requirements described below apply to terminals.
|
||||||
|
|
||||||
|
Terminal support adds the Python `websockets` dependency. In your micromamba
|
||||||
|
environment, install the current project and launch with its interpreter:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
micromamba activate nest
|
||||||
|
python -m pip install -e '.[test]'
|
||||||
|
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
|
"$CONDA_PREFIX/bin/python" -m nest --serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execute Commands Without SSH
|
||||||
|
|
||||||
|
Use an exact VM ID from create/list:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"whoami","sudo":true,"timeout_seconds":60}' \
|
||||||
|
"http://127.0.0.1:8000/vms/<vm-id>/exec"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response contains `exit_code`, `stdout`, `stderr`, `timed_out`, and
|
||||||
|
`truncated`. Commands and multiline scripts run through `/bin/sh` inside the
|
||||||
|
guest. `sudo: false` (the default) uses the VM's configured username; `true`
|
||||||
|
uses guest root without a password prompt. A VM configured with username `root`
|
||||||
|
still runs as root when `sudo` is false.
|
||||||
|
|
||||||
|
Execution uses a guest systemd agent over Firecracker vsock. It works with SSH
|
||||||
|
disabled and with an empty TCP allowlist. Every Firecracker boot attaches the
|
||||||
|
vsock device, and creation/start/restart/resize ensure the guest service is
|
||||||
|
installed and enabled before boot. Restart the management server to load this
|
||||||
|
behavior, then restart older VMs once to apply it automatically.
|
||||||
|
|
||||||
|
Installation stages and verifies a private disk copy when changes are needed,
|
||||||
|
preserving guest data and credentials. If Python is missing, the installed
|
||||||
|
bootstrap service acquires it inside the guest on boot; this one-time step needs
|
||||||
|
guest internet access. The default Ubuntu template already includes Python.
|
||||||
|
The guest needs Python 3.10+, systemd with cgroup v2, and a vsock-capable Linux
|
||||||
|
5.14+ kernel; UVM's default Linux 6.1 kernel provides the required drivers.
|
||||||
|
|
||||||
|
See [execution examples and limits](USAGE.md#execute-commands-without-ssh) for
|
||||||
|
programmatic calls, local script submission, timeout behavior, and upgrade steps.
|
||||||
|
|
||||||
### API Security
|
### API Security
|
||||||
|
|
||||||
The server controls privileged host operations. `UVM_API_TOKEN` is required
|
The server controls privileged host operations. `UVM_API_TOKEN` is required
|
||||||
@@ -330,7 +459,7 @@ sudo env \
|
|||||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||||
./uvm.py --serve --host 0.0.0.0 --port 8000
|
./nest.py --serve --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Send the token on management requests:
|
Send the token on management requests:
|
||||||
@@ -341,6 +470,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 serving Nest or UVM 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 +501,42 @@ 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
|
||||||
|
- systemd and apt for the automatically installed vsock guest agent (Ubuntu 22.04+)
|
||||||
|
|
||||||
|
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 +552,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 +597,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 +626,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,13 +659,13 @@ 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 Nest and standalone UVM servers; sent as `X-UVM-Token` |
|
||||||
| `UVM_API_TLS_CERT` | TLS certificate path required for a non-loopback API bind |
|
| `UVM_API_TLS_CERT` | TLS certificate path required for a non-loopback API bind |
|
||||||
| `UVM_API_TLS_KEY` | TLS private-key path required for a non-loopback API bind |
|
| `UVM_API_TLS_KEY` | TLS private-key path required for a non-loopback API bind |
|
||||||
|
|
||||||
@@ -486,14 +679,18 @@ Selected files:
|
|||||||
├── USAGE.md
|
├── USAGE.md
|
||||||
├── DEVELOPER.md
|
├── DEVELOPER.md
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── uvm.py # Executable compatibility launcher
|
├── nest.py # Executable Nest workspace launcher
|
||||||
├── uvm/ # Private application implementation
|
├── nest/ # Nest workspace application
|
||||||
|
│ ├── cli.py # nest --serve entry point
|
||||||
|
│ ├── server.py # Workspace server, composing the UVM management API
|
||||||
|
│ └── dashboard/ # Nest document route and local HTML, CSS, JS, and fonts
|
||||||
|
├── uvm.py # Executable UVM compatibility launcher
|
||||||
|
├── uvm/ # Machine-management implementation
|
||||||
│ ├── cli.py # argparse, terminal output, SSH execution
|
│ ├── cli.py # argparse, terminal output, SSH execution
|
||||||
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent
|
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent
|
||||||
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
|
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
|
||||||
│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup
|
│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup
|
||||||
│ ├── dashboard/ # Local dashboard HTML, CSS, and JavaScript
|
│ ├── routers/ # UVM management API routes
|
||||||
│ ├── routers/ # FastAPI routes, including the dashboard document
|
|
||||||
│ └── firecracker/ # Firecracker config, API, and process adapters
|
│ └── firecracker/ # Firecracker config, API, and process adapters
|
||||||
└── tests/ # Unit tests and opt-in isolated namespace packet tests
|
└── tests/ # Unit tests and opt-in isolated namespace packet tests
|
||||||
```
|
```
|
||||||
@@ -530,8 +727,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
|
||||||
|
|
||||||
@@ -539,7 +738,7 @@ validate KVM, TAP, NAT, SSH, and guest connectivity on an isolated Linux host.
|
|||||||
- 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.
|
||||||
- Firecracker is launched directly by this local CLI. Jailer/cgroup hardening
|
- Firecracker is launched directly by this local CLI. Jailer/cgroup hardening
|
||||||
is not implemented by the current command set.
|
is not implemented by the current command set.
|
||||||
- No VM autostart, background reconciliation, guest readiness probing,
|
- No VM autostart, background reconciliation, guest application health probing,
|
||||||
snapshots, DHCP, public-port publishing, or multi-host scheduling.
|
snapshots, DHCP, public-port publishing, or multi-host scheduling.
|
||||||
- The API uses one shared token, not users, roles, or tenants. UVM is not a
|
- The API uses one shared token, not users, roles, or tenants. UVM is not a
|
||||||
hardened multi-tenant platform.
|
hardened multi-tenant platform.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# UVM Usage Guide
|
# Nest and UVM Usage Guide
|
||||||
|
|
||||||
This guide explains how to install, operate, and troubleshoot UVM as an
|
This guide explains how to install, operate, and troubleshoot Nest and its UVM
|
||||||
operator. It covers the command-line tool, the optional FastAPI server, common
|
machine-management backend. It covers the Nest dashboard, UVM command-line
|
||||||
scenarios, and expected limitations.
|
tool, FastAPI server, common scenarios, and expected limitations.
|
||||||
|
|
||||||
For the project architecture and contributor guidance, see
|
For the project architecture and contributor guidance, see
|
||||||
[DEVELOPER.md](DEVELOPER.md). For a concise overview, see [README.md](README.md).
|
[DEVELOPER.md](DEVELOPER.md). For a concise overview, see [README.md](README.md).
|
||||||
@@ -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.
|
||||||
@@ -62,20 +68,23 @@ lscpu | grep -i virtualization
|
|||||||
From this project root:
|
From this project root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
./nest.py --help
|
||||||
./uvm.py --help
|
./uvm.py --help
|
||||||
python3 -B -m uvm --help
|
python3 -B -m uvm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
To install the optional HTTP API dependencies and the console command:
|
To install the HTTP server dependencies and console commands:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m pip install .
|
python3 -m pip install .
|
||||||
|
nest --help
|
||||||
uvm --help
|
uvm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
The rest of this guide uses `./uvm.py` so it works from a source checkout. If
|
The rest of this guide uses `./uvm.py` so it works from a source checkout. If
|
||||||
you installed the package, replace `./uvm.py` with `uvm`. For the API, use the
|
you installed the package, replace `./uvm.py` with `uvm` and `./nest.py` with
|
||||||
Python environment containing the installed dependencies; under `sudo`, use
|
`nest`. For the server, use the Python environment containing the installed
|
||||||
|
dependencies; under `sudo`, use
|
||||||
the full path to that environment's console script when necessary.
|
the full path to that environment's console script when necessary.
|
||||||
|
|
||||||
### Privilege Rules
|
### Privilege Rules
|
||||||
@@ -101,6 +110,7 @@ Examples elsewhere are separate scenarios; substitute your VM identifiers
|
|||||||
and do not run every destructive example as a single script.
|
and do not run every destructive example as a single script.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
nest --serve [--host HOST] [--port PORT]
|
||||||
uvm install [--force]
|
uvm install [--force]
|
||||||
uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--storage STORAGE] [--guest-ip GUEST_IP]
|
uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--storage STORAGE] [--guest-ip GUEST_IP]
|
||||||
[--username USER] [--password PASSWORD | --password-stdin]
|
[--username USER] [--password PASSWORD | --password-stdin]
|
||||||
@@ -135,29 +145,52 @@ 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 the serving Nest or standalone UVM 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 +199,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 +206,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 +221,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 +259,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 +302,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 +473,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.
|
||||||
@@ -699,31 +771,39 @@ UVM will not blindly signal that PID or delete a possibly in-use disk. Listing
|
|||||||
does not perform adoption. Inspect such failures rather than manually
|
does not perform adoption. Inspect such failures rather than manually
|
||||||
inventing identity fields in `state.json`.
|
inventing identity fields in `state.json`.
|
||||||
|
|
||||||
## 10. Use the FastAPI Server
|
## 10. Use the Nest Workspace and FastAPI Server
|
||||||
|
|
||||||
The API uses the same local lifecycle services and exposes health, installation,
|
The API uses the same local lifecycle services and exposes health, installation,
|
||||||
VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port
|
VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port
|
||||||
controls. The local dashboard at `/dashboard` uses those same endpoints. There
|
controls. Nest owns the local dashboard at `/dashboard` and serves it alongside
|
||||||
API also supports fixed-target bulk actions; interactive SSH remains CLI-only.
|
these UVM-backed endpoints. The API also supports fixed-target bulk actions;
|
||||||
|
interactive SSH remains CLI-only.
|
||||||
The server does not autostart VMs or reconcile rules, and is not safe to expose
|
The server does not autostart VMs or reconcile rules, and is not safe to expose
|
||||||
directly to the public internet.
|
directly to the public internet.
|
||||||
|
|
||||||
### Start a Local Server
|
### Start a Local Server
|
||||||
|
|
||||||
After installing the project dependencies, start the server as root:
|
After installing the project dependencies, start the Nest workspace as root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
||||||
|
|
||||||
sudo env \
|
sudo env \
|
||||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
./nest.py --serve --host 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Liveness is at `http://127.0.0.1:8000/health`; the dashboard is at
|
Liveness is at `http://127.0.0.1:8000/health`; the dashboard is at
|
||||||
`http://127.0.0.1:8000/dashboard`; OpenAPI docs are at
|
`http://127.0.0.1:8000/dashboard`; OpenAPI docs are at
|
||||||
`http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`.
|
`http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`.
|
||||||
|
|
||||||
|
The installed command is `nest --serve`; `python3 -B -m nest --serve` also
|
||||||
|
works from the source checkout. Nest uses `UVM_API_TOKEN` and the `UVM_*` host
|
||||||
|
settings for its machine-management backend. To launch the standalone UVM API,
|
||||||
|
use `./uvm.py --serve` with the same host/port options and configuration. That
|
||||||
|
server exposes the UVM management endpoints and `/docs`; Nest serves the
|
||||||
|
dashboard and terminal-session endpoints.
|
||||||
|
|
||||||
The API token is required even for a loopback server. All VM, diagnostics, and
|
The API token is required even for a loopback server. All VM, diagnostics, and
|
||||||
installation routes require it. The dashboard shell, liveness route, and FastAPI's
|
installation routes require it. The dashboard shell, liveness route, and FastAPI's
|
||||||
informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available
|
informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available
|
||||||
@@ -733,6 +813,42 @@ with no whitespace. Use a protected environment file or secret manager rather
|
|||||||
than recording a real token in shell history; do not share tokens or guest
|
than recording a real token in shell history; do not share tokens or guest
|
||||||
passwords in reports.
|
passwords in reports.
|
||||||
|
|
||||||
|
### Use Sessions Inside a MicroVM
|
||||||
|
|
||||||
|
1. Open **Machines** and select the microVM.
|
||||||
|
2. Start it if it is stopped.
|
||||||
|
3. Click **Launch session**, or open its **Sessions** tab and launch there.
|
||||||
|
4. Type directly in the embedded terminal. Each session keeps its own shell,
|
||||||
|
current directory, environment, and foreground program.
|
||||||
|
5. Use **Session history** to switch between that VM's sessions. Active sessions
|
||||||
|
reconnect to their existing shell; ended sessions replay saved output.
|
||||||
|
6. Use **End session** when finished, or run `exit` inside the shell.
|
||||||
|
|
||||||
|
Sessions continue while you navigate elsewhere, reload, or sign out of the
|
||||||
|
dashboard. VM shutdown/restart or Nest shutdown ends the shell. Session metadata,
|
||||||
|
start/end times, exit status, and output survive Nest restarts in
|
||||||
|
`$UVM_BASE/nest/sessions.sqlite3`; files are mode `0600` in a mode-`0700` directory.
|
||||||
|
Only sessions belonging to the selected exact VM ID are shown. Reusing a VM name
|
||||||
|
does not attach another VM's history.
|
||||||
|
|
||||||
|
The retained transcript is capped at its latest 8 MiB, and the terminal viewer
|
||||||
|
has a 10,000-line scrollback. A truncation note appears when older output is
|
||||||
|
discarded. Terminal input supports pastes up to 64 KiB at a time. Guest output
|
||||||
|
is saved even while no browser is attached.
|
||||||
|
|
||||||
|
Vsock and the guest agent are set up automatically for every microVM during
|
||||||
|
creation and on each stopped-disk boot. This includes compatible older and custom
|
||||||
|
images. Restart an older VM once to apply the setup; **Sessions → Guest terminal
|
||||||
|
support → Repair / update terminal support** remains available for manual repair
|
||||||
|
while stopped. If the agent is still starting or installing Python, wait for it
|
||||||
|
to become ready before launching a session.
|
||||||
|
|
||||||
|
The terminal transport uses the guest agent over vsock. It logs in as the VM's
|
||||||
|
configured username; `sudo` inside the terminal uses normal guest authentication.
|
||||||
|
Nest supports 32 active sessions per server; each guest agent shares eight
|
||||||
|
connection slots with its command-execution endpoint. Run one Nest session
|
||||||
|
manager per `UVM_BASE` registry. Reverse proxies must forward WebSocket upgrades.
|
||||||
|
|
||||||
### Start a TLS Server
|
### Start a TLS Server
|
||||||
|
|
||||||
Non-loopback binds require a valid TLS certificate and key. The files must be
|
Non-loopback binds require a valid TLS certificate and key. The files must be
|
||||||
@@ -747,7 +863,7 @@ sudo env \
|
|||||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||||
./uvm.py --serve --host 0.0.0.0 --port 8443
|
./nest.py --serve --host 0.0.0.0 --port 8443
|
||||||
```
|
```
|
||||||
|
|
||||||
For real remote use, use a certificate trusted by clients and restrict network
|
For real remote use, use a certificate trusted by clients and restrict network
|
||||||
@@ -778,8 +894,8 @@ curl --cacert /path/to/ca.pem \
|
|||||||
|
|
||||||
| Method | Path | Operation |
|
| Method | Path | Operation |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/` | Redirect to `/dashboard` |
|
| `GET` | `/` | Redirect to `/dashboard` (Nest server) |
|
||||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
|
||||||
| `GET` | `/health` | Unauthenticated liveness |
|
| `GET` | `/health` | Unauthenticated liveness |
|
||||||
| `GET` | `/diagnostics` | Run host readiness checks |
|
| `GET` | `/diagnostics` | Run host readiness checks |
|
||||||
| `POST` | `/install` | Install/refresh with a `force` boolean |
|
| `POST` | `/install` | Install/refresh with a `force` boolean |
|
||||||
@@ -794,7 +910,14 @@ curl --cacert /path/to/ca.pem \
|
|||||||
| `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter |
|
| `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter |
|
||||||
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID |
|
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID |
|
||||||
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID |
|
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID |
|
||||||
|
| `POST` | `/vms/{vm_id}/exec` | Execute a guest command/script over vsock, with optional root privileges |
|
||||||
|
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the agent on a stopped VM's disk |
|
||||||
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail |
|
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail |
|
||||||
|
| `GET` | `/vms/{vm_id}/sessions` | List saved sessions for an exact VM ID (Nest) |
|
||||||
|
| `POST` | `/vms/{vm_id}/sessions` | Launch a terminal, with `{}` or optional `rows`/`cols` (Nest) |
|
||||||
|
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | Inspect a session (Nest) |
|
||||||
|
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | End a session and retain its output (Nest) |
|
||||||
|
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated live terminal/replay (Nest) |
|
||||||
|
|
||||||
Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt
|
Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt
|
||||||
for confirmation. `timeout` is an optional finite, non-negative query parameter
|
for confirmation. `timeout` is an optional finite, non-negative query parameter
|
||||||
@@ -967,6 +1090,137 @@ the other policy operations. `open`, `close`, and `sync` reject a legacy policy.
|
|||||||
Mutating a live policy is subject to the same containment and confirmation rules
|
Mutating a live policy is subject to the same containment and confirmation rules
|
||||||
as the CLI.
|
as the CLI.
|
||||||
|
|
||||||
|
### Execute Commands Without SSH
|
||||||
|
|
||||||
|
`POST /vms/{vm_id}/exec` accepts an **exact VM ID** and a JSON body:
|
||||||
|
|
||||||
|
| Field | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `command` | Required, non-empty command or multiline script; at most 65,536 UTF-8 bytes; no NUL characters |
|
||||||
|
| `sudo` | Strict boolean, default `false`; select guest root instead of the VM's configured login user |
|
||||||
|
| `timeout_seconds` | Finite JSON number greater than 0 and at most 3600; default 60 |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export VM_ID='<exact-vm-id-from-create-or-list>'
|
||||||
|
curl --fail-with-body -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"whoami","sudo":true,"timeout_seconds":60}' \
|
||||||
|
"$UVM_API_URL/vms/$VM_ID/exec"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"exit_code":0,"stdout":"root\n","stderr":"","timed_out":false,"truncated":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent runs `/bin/sh -c COMMAND` in the selected user's home directory, with
|
||||||
|
that user's UID and groups and a fresh `HOME`/`USER`/`PATH` environment. Stdin is
|
||||||
|
closed and no TTY is allocated. The entire script, including pipes and
|
||||||
|
redirections, uses the selected identity. For a Bash script already in the VM,
|
||||||
|
use a command such as `bash /opt/jobs/process.sh`. The flag selects an execution
|
||||||
|
identity directly; it does not invoke an interactive `sudo` password prompt.
|
||||||
|
For VMs whose configured username is `root`, both flag values run as root.
|
||||||
|
|
||||||
|
Submit a local script's contents using `jq` to handle JSON quoting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jq -n --rawfile command ./script.sh \
|
||||||
|
'{command:$command,sudo:false,timeout_seconds:300}' |
|
||||||
|
curl --fail-with-body -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data-binary @- "$UVM_API_URL/vms/$VM_ID/exec"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or call it from Python using the standard library:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
request = Request(
|
||||||
|
f"{os.environ['UVM_API_URL']}/vms/{os.environ['VM_ID']}/exec",
|
||||||
|
data=json.dumps({"command": "id && uname -a", "sudo": False}).encode(),
|
||||||
|
headers={"X-UVM-Token": os.environ["UVM_API_TOKEN"],
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=75) as response:
|
||||||
|
result = json.load(response)
|
||||||
|
print(result["stdout"])
|
||||||
|
print(result["exit_code"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior and limits:**
|
||||||
|
|
||||||
|
- The VM must be running with a known process identity and no pending lifecycle
|
||||||
|
operation. Guest readiness is checked separately by connecting to its agent.
|
||||||
|
- The call waits for completion. Nonzero command exits still return HTTP 200;
|
||||||
|
inspect `exit_code`. Negative codes indicate a terminating signal.
|
||||||
|
- The deadline is enforced inside the guest. On timeout the agent terminates
|
||||||
|
the job, including descendants in its cgroup, and returns partial output with
|
||||||
|
`timed_out: true` and `exit_code: null`. Connection and cleanup time are additional
|
||||||
|
to `timeout_seconds`; give the HTTP client about 15 extra seconds.
|
||||||
|
- The agent captures up to **1 MiB combined stdout/stderr**, then drains and
|
||||||
|
discards excess data. `truncated` reports this limit; invalid UTF-8 is replaced.
|
||||||
|
- Up to eight connections/jobs per VM are admitted. Excess connections fail
|
||||||
|
before submission. Execution does not hold the global lifecycle lock while
|
||||||
|
waiting, so another request can stop or restart the VM.
|
||||||
|
- A normal script exit also cleans up background descendants in the job's
|
||||||
|
cgroup. Use a guest service manager for workloads that should outlive the call.
|
||||||
|
- A lost connection after submission returns HTTP 502 with
|
||||||
|
`error.outcome_unknown: true`. The command may already have produced effects;
|
||||||
|
it is never automatically resubmitted. An HTTP client disconnect does not
|
||||||
|
itself cancel the guest job; the guest deadline still applies.
|
||||||
|
- Results are returned in the response, not stored as durable jobs. Commands
|
||||||
|
and their captured output are not copied to the VM registry or host traces.
|
||||||
|
|
||||||
|
#### Prepare New or Existing VMs
|
||||||
|
|
||||||
|
The default Ubuntu template includes Python 3 and `uvm-agent.service`. UVM also
|
||||||
|
installs the service automatically on compatible older/custom images during
|
||||||
|
creation and before booting a retained private disk. Every VM gets a Firecracker
|
||||||
|
vsock device by default. Agent files, VM identity configuration, the ownership
|
||||||
|
manifest, and the enabled service are checked; an already-current installation
|
||||||
|
does not rewrite the disk.
|
||||||
|
|
||||||
|
Restart a running Nest or UVM API server to load the new code. Existing VMs pick
|
||||||
|
up the setup on their next start or restart. For a currently running VM:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --fail-with-body -X POST -H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
"$UVM_API_URL/vms/$VM_ID/restart"
|
||||||
|
```
|
||||||
|
|
||||||
|
The explicit `POST /vms/{vm_id}/agent/install` endpoint is available for manual
|
||||||
|
repair while stopped. It returns
|
||||||
|
`{"id":"...","status":"installed","restart_required":true}` and does not start
|
||||||
|
the VM itself. All agent installation requires a proven-offline private disk;
|
||||||
|
updates are checked on a sparse copy before publication. Guest files,
|
||||||
|
credentials, and SSH keys are retained; customized agent files are preserved
|
||||||
|
rather than overwritten. Repeating installation also upgrades unedited managed
|
||||||
|
agent files using their recorded checksums.
|
||||||
|
|
||||||
|
The guest image must provide systemd and apt (Ubuntu 22.04 or newer is the
|
||||||
|
supported baseline). If Python 3.10+ is missing, a bootstrap service installs
|
||||||
|
`python3` inside the VM on its next boot. This one-time install needs guest DNS,
|
||||||
|
internet access, and working apt repositories. The service retries failed startup;
|
||||||
|
execution returns 503 until it can connect. Current default templates already include
|
||||||
|
the runtime and do not need this download.
|
||||||
|
|
||||||
|
The guest kernel needs built-in `CONFIG_VSOCKETS=y`, `CONFIG_VIRTIO_VSOCKETS=y`,
|
||||||
|
and `CONFIG_CGROUPS=y`, plus cgroup v2 and `cgroup.kill` (Linux 5.14+). UVM's
|
||||||
|
default Linux 6.1 kernel supports these. Refresh an older installed kernel as
|
||||||
|
described in the installation/entropy recovery sections before booting.
|
||||||
|
|
||||||
|
All transport is via a per-VM Unix socket and Firecracker vsock port 1070. This
|
||||||
|
management channel is independent of the IPv4 port policy, including TCP/22;
|
||||||
|
the guest agent accepts connections from the host CID only. VM execution does
|
||||||
|
not need SSH credentials, SSH host-key caches, or an open guest TCP port.
|
||||||
|
|
||||||
### Diagnostics and Log Tails
|
### Diagnostics and Log Tails
|
||||||
|
|
||||||
The dashboard's System view calls the protected diagnostics endpoint. It runs
|
The dashboard's System view calls the protected diagnostics endpoint. It runs
|
||||||
@@ -1016,6 +1270,8 @@ Use `{"force": true}` to refresh all downloaded artifacts.
|
|||||||
| `409` | Name/identifier conflict, incompatible retained state, or an unsafe/pending operation. |
|
| `409` | Name/identifier conflict, incompatible retained state, or an unsafe/pending operation. |
|
||||||
| `422` | Invalid JSON body, extra request fields, or invalid UVM input. |
|
| `422` | Invalid JSON body, extra request fields, or invalid UVM input. |
|
||||||
| `500` | Host command, Firecracker, state, or unexpected operational failure. |
|
| `500` | Host command, Firecracker, state, or unexpected operational failure. |
|
||||||
|
| `502` | Guest execution failure or lost/invalid execution response; inspect `error.outcome_unknown`. |
|
||||||
|
| `503` | Guest agent unavailable, not ready, or at its connection limit before command submission. |
|
||||||
|
|
||||||
UVM operation errors use this envelope:
|
UVM operation errors use this envelope:
|
||||||
|
|
||||||
@@ -1024,6 +1280,8 @@ UVM operation errors use this envelope:
|
|||||||
```
|
```
|
||||||
|
|
||||||
FastAPI request-schema errors use FastAPI's normal `detail` response format.
|
FastAPI request-schema errors use FastAPI's normal `detail` response format.
|
||||||
|
Guest execution errors additionally include a boolean `outcome_unknown` in the
|
||||||
|
`error` object; `true` means the caller must not assume the command did not run.
|
||||||
|
|
||||||
## 13. Configuration Scenarios
|
## 13. Configuration Scenarios
|
||||||
|
|
||||||
@@ -1040,7 +1298,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 +1316,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 +1352,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 +1383,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 +1462,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 Nest 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,11 +1522,12 @@ 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.
|
||||||
- There is no guest readiness probe beyond Firecracker process state.
|
- Execution probes guest-agent readiness; VM list/status still describes the
|
||||||
|
Firecracker process rather than guest application health.
|
||||||
- UVM is not a hardened multi-tenant platform.
|
- UVM is not a hardened multi-tenant platform.
|
||||||
|
|
||||||
## 17. Safe Operating Checklist
|
## 17. Safe Operating Checklist
|
||||||
@@ -1181,7 +1556,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 +1564,28 @@ 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.
|
||||||
|
|
||||||
|
The execution smoke tests target an already-created **disposable VM on an
|
||||||
|
isolated host**, with a non-root login, an installed/running agent, and an empty
|
||||||
|
TCP allowlist. They check both privilege modes, output and exit status, bounded
|
||||||
|
output, and cgroup cleanup of a descendant that starts its own session. They do
|
||||||
|
not create, stop, or delete VMs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
UVM_EXEC_INTEGRATION=1 UVM_EXEC_VM_ID='<disposable-vm-id>' \
|
||||||
|
python3 -B -m unittest discover -s tests/integration -p test_guest_exec.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `UVM_API_URL` and `UVM_API_TOKEN` as in the API examples. For TLS, the Python
|
||||||
|
client uses the system trust store (or `SSL_CERT_FILE`). Without the explicit
|
||||||
|
integration flag these tests skip and make no requests. The ordinary unit tests
|
||||||
|
exercise real child processes with fake cgroup scopes, local Unix-socket protocol
|
||||||
|
fixtures, and temporary ext4 upgrades; they do not prove the real vsock/cgroup
|
||||||
|
path inside a VM.
|
||||||
|
|
||||||
## 19. Get Help
|
## 19. Get Help
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Source-tree launcher for the Nest workspace."""
|
||||||
|
|
||||||
|
from nest.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Nest workspace application."""
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Run the Nest workspace with ``python -m nest``."""
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
"""Command-line entry point for the Nest workspace server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from uvm.app import build_application
|
||||||
|
from uvm.errors import UvmError
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="nest",
|
||||||
|
description="Nest dashboard and local machine management workspace.",
|
||||||
|
allow_abbrev=False,
|
||||||
|
)
|
||||||
|
parser.add_argument("--serve", action="store_true", help="launch the Nest workspace server")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
|
||||||
|
parser.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not args.serve:
|
||||||
|
parser.error("--serve is required to launch the Nest workspace")
|
||||||
|
if not 1 <= args.port <= 65535:
|
||||||
|
parser.error("--port must be between 1 and 65535")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .server import run_server
|
||||||
|
|
||||||
|
application = build_application(emit=lambda message: print(message, file=sys.stderr))
|
||||||
|
run_server(application, host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
except UvmError as error:
|
||||||
|
print(f"nest: error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("nest: interrupted", file=sys.stderr)
|
||||||
|
return 130
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Packaged frontend assets for the Nest workspace."""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Dashboard frontend routes."""
|
"""Nest dashboard document route."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html"
|
_DASHBOARD_HTML = Path(__file__).parent / "static" / "index.html"
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
@router.get("", response_class=HTMLResponse)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
.machine-tabs { display: flex; gap: 8px; margin: 0 0 24px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
.machine-tab { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 22px; color: var(--muted); font-size: 11px; }
|
||||||
|
.machine-tab.active { background: var(--white); color: var(--ink); box-shadow: var(--shadow-card); }
|
||||||
|
.machine-tab .icon { width: 14px; height: 14px; }
|
||||||
|
.session-section-heading { margin-bottom: 14px; }
|
||||||
|
.session-workspace { display: grid; grid-template-columns: 205px minmax(0, 1fr); gap: 16px; align-items: stretch; }
|
||||||
|
.session-history { padding: 14px 10px; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.session-history > header { display: flex; align-items: center; justify-content: space-between; padding: 0 6px 10px; }
|
||||||
|
.session-history h3 { font-size: 11px; }
|
||||||
|
.session-history > .panel-footnote { margin-top: auto; padding: 15px 8px 0; }
|
||||||
|
.session-list { display: grid; gap: 6px; max-height: 510px; overflow-y: auto; }
|
||||||
|
.session-list-empty { padding: 24px 9px; color: var(--muted); font-size: 10px; line-height: 1.8; }
|
||||||
|
.session-item { display: grid; grid-template-columns: 1fr auto; gap: 7px; padding: 12px 10px; border: 1px solid transparent; border-radius: 12px; color: var(--ink-soft); }
|
||||||
|
.session-item:hover { background: #fff8; }
|
||||||
|
.session-item.active { background: var(--white); border-color: #d9d0e4; }
|
||||||
|
.session-item strong { overflow-wrap: anywhere; font-size: 11px; }
|
||||||
|
.session-item small { grid-column: 1 / -1; color: var(--muted); font-size: 9px; }
|
||||||
|
.session-state { align-self: center; width: 6px; height: 6px; border-radius: 50%; background: var(--lilac); }
|
||||||
|
.session-state.active { background: var(--sage); box-shadow: 0 0 0 3px var(--sage-soft); }
|
||||||
|
.session-state.failed, .session-state.interrupted { background: var(--peach); }
|
||||||
|
.terminal-panel { min-width: 0; overflow: hidden; border-radius: 17px; background: #25222d; color: #e8e3ef; box-shadow: var(--shadow-card); }
|
||||||
|
.terminal-toolbar { min-height: 56px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #ffffff12; background: #302c39; }
|
||||||
|
.terminal-toolbar > div:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
|
.terminal-toolbar strong { font-size: 11px; }
|
||||||
|
.terminal-status { color: #b8afc6; font-size: 9px; }
|
||||||
|
.terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; }
|
||||||
|
.terminal-toolbar .button:hover { background: #ffffff19; }
|
||||||
|
.terminal-lights { display: inline-flex; gap: 4px; margin-right: 3px; }
|
||||||
|
.terminal-lights i { height: 6px; width: 6px; border-radius: 50%; background: var(--peach); }
|
||||||
|
.terminal-lights i:nth-child(2) { background: var(--yellow); }
|
||||||
|
.terminal-lights i:nth-child(3) { background: var(--sage); }
|
||||||
|
.session-terminal { height: 480px; padding: 14px 12px 10px; overflow: hidden; }
|
||||||
|
.session-terminal .xterm { height: 100%; }
|
||||||
|
.session-terminal .xterm-viewport { scrollbar-width: thin; scrollbar-color: #766a88 #25222d; }
|
||||||
|
.terminal-blank { min-height: 480px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 17px; text-align: center; padding: 24px; }
|
||||||
|
.terminal-blank > .icon { width: 37px; height: 37px; color: #b3a5ca; }
|
||||||
|
.terminal-blank h3 { font-size: 20px; font-weight: 450; }
|
||||||
|
.terminal-blank p { color: #b2a9bf; font-size: 11px; line-height: 1.8; }
|
||||||
|
.terminal-footer { min-height: 43px; padding: 11px 16px; color: #b5abc4; font-size: 9px; border-top: 1px solid #ffffff0d; overflow-wrap: anywhere; }
|
||||||
|
.terminal-support { margin-top: 18px; padding: 15px 19px; font-size: 10px; color: var(--muted); }
|
||||||
|
.terminal-support summary { cursor: pointer; color: var(--ink-soft); }
|
||||||
|
.terminal-support p { margin: 12px 0; max-width: 680px; line-height: 1.8; }
|
||||||
|
.terminal-support .muted { margin-left: 12px; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.session-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.session-list { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); max-height: 190px; }
|
||||||
|
.session-history > .panel-footnote { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.session-terminal { height: 400px; }
|
||||||
|
.terminal-blank { min-height: 330px; }
|
||||||
|
.session-section-heading { flex-wrap: wrap; }
|
||||||
|
.terminal-support .muted { display: block; margin: 10px 0 0; }
|
||||||
|
}
|
||||||
@@ -5,11 +5,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#e8e7ed" />
|
<meta name="theme-color" content="#e8e7ed" />
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<title>UVM Control</title>
|
<title>Nest</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 11v7a7 7 0 0 0 14 0v-7' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
|
<meta name="description" content="Nest — a local workspace for your machines, network, and infrastructure." />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 22V10l14 12V10' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
||||||
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=2" />
|
<link rel="stylesheet" href="/dashboard/static/vendor/xterm/css/xterm.css?v=5.5.0" />
|
||||||
<script src="/dashboard/static/js/dashboard.js?v=2" defer></script>
|
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
|
||||||
|
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=1" />
|
||||||
|
<script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script>
|
||||||
|
<script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script>
|
||||||
|
<script src="/dashboard/static/js/sessions.js?v=2" defer></script>
|
||||||
|
<script src="/dashboard/static/js/dashboard.js?v=4" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
@@ -47,8 +53,8 @@
|
|||||||
<section id="auth-screen" class="auth-screen">
|
<section id="auth-screen" class="auth-screen">
|
||||||
<div class="auth-window">
|
<div class="auth-window">
|
||||||
<div class="auth-story">
|
<div class="auth-story">
|
||||||
<a href="#overview" class="brand" aria-label="UVM Control"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
<a href="#overview" class="brand" aria-label="Nest workspace"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
||||||
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your compute.<br />A little calmer.</h1><p>A considered workspace for your machines,<br />your network, and everything in between.</p></div>
|
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your workspace.<br />Right at home.</h1><p>A home for your machines,<br />your network, and your next idea.</p></div>
|
||||||
<div class="auth-sculpture" aria-hidden="true">
|
<div class="auth-sculpture" aria-hidden="true">
|
||||||
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
|
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
|
||||||
<div class="sculpture-card sculpture-back"><span></span><i></i><i></i><i></i></div>
|
<div class="sculpture-card sculpture-back"><span></span><i></i><i></i><i></i></div>
|
||||||
@@ -56,13 +62,13 @@
|
|||||||
<div class="sculpture-card sculpture-front"><svg class="icon"><use href="#i-server"/></svg><span>Room to create.</span><div class="sculpture-lights"><i></i><i></i><i></i></div></div>
|
<div class="sculpture-card sculpture-front"><svg class="icon"><use href="#i-server"/></svg><span>Room to create.</span><div class="sculpture-lights"><i></i><i></i><i></i></div></div>
|
||||||
<div class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
|
<div class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="auth-story-footer">MicroVMs, thoughtfully managed.</span>
|
<span class="auth-story-footer">Your infrastructure, thoughtfully managed.</span>
|
||||||
</div>
|
</div>
|
||||||
<main class="auth-panel">
|
<main class="auth-panel">
|
||||||
<div class="auth-panel-inner">
|
<div class="auth-panel-inner">
|
||||||
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
|
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
|
||||||
<span class="subtle-label">Your local workspace</span>
|
<span class="subtle-label">Your local workspace</span>
|
||||||
<h2>Welcome to UVM</h2>
|
<h2>Welcome to Nest</h2>
|
||||||
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
|
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
|
||||||
<form id="auth-form">
|
<form id="auth-form">
|
||||||
<label class="field" for="token-input"><span>Management token</span><input id="token-input" name="token" type="password" placeholder="Enter your API token" autocomplete="off" spellcheck="false" required aria-describedby="auth-error" /></label>
|
<label class="field" for="token-input"><span>Management token</span><input id="token-input" name="token" type="password" placeholder="Enter your API token" autocomplete="off" spellcheck="false" required aria-describedby="auth-error" /></label>
|
||||||
@@ -80,8 +86,8 @@
|
|||||||
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
|
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
|
||||||
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
|
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a class="brand" href="#overview" aria-label="UVM Control overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
<a class="brand" href="#overview" aria-label="Nest workspace overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
||||||
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Management</span><span id="sidebar-subtitle">Overview</span></h2></div>
|
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Workspace</span><span id="sidebar-subtitle">Overview</span></h2></div>
|
||||||
<button class="sidebar-search" type="button" data-action="search"><svg class="icon"><use href="#i-search"/></svg><span>Find in workspace</span><kbd>/</kbd></button>
|
<button class="sidebar-search" type="button" data-action="search"><svg class="icon"><use href="#i-search"/></svg><span>Find in workspace</span><kbd>/</kbd></button>
|
||||||
<nav class="primary-nav" aria-label="Primary">
|
<nav class="primary-nav" aria-label="Primary">
|
||||||
<a class="nav-item" href="#overview" data-nav="overview"><svg class="icon"><use href="#i-grid"/></svg><span>Overview</span><span class="nav-indicator"></span></a>
|
<a class="nav-item" href="#overview" data-nav="overview"><svg class="icon"><use href="#i-grid"/></svg><span>Overview</span><span class="nav-indicator"></span></a>
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-secondary"><a class="nav-item" href="/docs" target="_blank" rel="noopener"><svg class="icon"><use href="#i-book"/></svg><span>API reference</span><svg class="icon nav-end-icon"><use href="#i-arrow"/></svg></a></div>
|
<div class="sidebar-secondary"><a class="nav-item" href="/docs" target="_blank" rel="noopener"><svg class="icon"><use href="#i-book"/></svg><span>API reference</span><svg class="icon nav-end-icon"><use href="#i-arrow"/></svg></a></div>
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
<div class="workspace-identity"><span class="workspace-avatar">u</span><div><strong>Local workspace</strong><span><i id="connection-dot" class="dot"></i><span id="connection-label">Connecting</span></span></div></div>
|
<div class="workspace-identity"><span class="workspace-avatar">n</span><div><strong>Local Nest workspace</strong><span><i id="connection-dot" class="dot"></i><span id="connection-label">Connecting</span></span></div></div>
|
||||||
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
|
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +127,7 @@
|
|||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button id="nav-toggle" class="icon-button mobile-menu" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="workspace-nav"><svg class="icon"><use href="#i-menu"/></svg></button>
|
<button id="nav-toggle" class="icon-button mobile-menu" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="workspace-nav"><svg class="icon"><use href="#i-menu"/></svg></button>
|
||||||
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Workspace</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
|
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Nest</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
|
||||||
<button id="refresh-btn" class="icon-button refresh-button" type="button" aria-label="Refresh workspace" title="Refresh workspace"><svg class="icon"><use href="#i-refresh"/></svg></button>
|
<button id="refresh-btn" class="icon-button refresh-button" type="button" aria-label="Refresh workspace" title="Refresh workspace"><svg class="icon"><use href="#i-refresh"/></svg></button>
|
||||||
<div class="global-search">
|
<div class="global-search">
|
||||||
<svg class="icon"><use href="#i-search"/></svg><input id="global-search" type="search" placeholder="Find a machine or a page" autocomplete="off" role="combobox" aria-label="Search workspace" aria-autocomplete="list" aria-expanded="false" aria-controls="search-results" /><kbd>⌘ K</kbd>
|
<svg class="icon"><use href="#i-search"/></svg><input id="global-search" type="search" placeholder="Find a machine or a page" autocomplete="off" role="combobox" aria-label="Search workspace" aria-autocomplete="list" aria-expanded="false" aria-controls="search-results" /><kbd>⌘ K</kbd>
|
||||||
@@ -134,7 +140,7 @@
|
|||||||
<div id="connection-banner" class="notice notice-error" role="status" hidden><svg class="icon"><use href="#i-info"/></svg><span id="connection-message"></span><button class="quiet-link" type="button" data-action="refresh">Try again</button></div>
|
<div id="connection-banner" class="notice notice-error" role="status" hidden><svg class="icon"><use href="#i-info"/></svg><span id="connection-message"></span><button class="quiet-link" type="button" data-action="refresh">Try again</button></div>
|
||||||
|
|
||||||
<section id="page-overview" class="page" aria-labelledby="overview-heading" hidden>
|
<section id="page-overview" class="page" aria-labelledby="overview-heading" hidden>
|
||||||
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Workspace overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
|
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Nest overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
|
||||||
<div class="overview-metrics">
|
<div class="overview-metrics">
|
||||||
<article class="metric-card fleet-metric"><div class="card-caption"><h2>Your machine fleet</h2><a href="#vms" class="mini-link" aria-label="View all machines"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="fleet-number"><strong id="metric-total">—</strong><span>little machines,<br />endless possibilities</span></div><div class="metric-bottom"><span class="status-inline"><i class="dot dot-yellow"></i><span id="metric-running">Waiting for the fleet</span></span><span id="metric-stopped" class="muted">—</span></div></article>
|
<article class="metric-card fleet-metric"><div class="card-caption"><h2>Your machine fleet</h2><a href="#vms" class="mini-link" aria-label="View all machines"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="fleet-number"><strong id="metric-total">—</strong><span>little machines,<br />endless possibilities</span></div><div class="metric-bottom"><span class="status-inline"><i class="dot dot-yellow"></i><span id="metric-running">Waiting for the fleet</span></span><span id="metric-stopped" class="muted">—</span></div></article>
|
||||||
<article class="metric-card"><div class="card-caption"><h2>Host signals</h2><a href="#system" class="mini-link" aria-label="View host signals"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="signal-readouts"><div><span class="dot dot-sage"></span><strong id="checks-ready">—</strong><small>Ready</small></div><div><span class="dot dot-peach"></span><strong id="checks-attention">—</strong><small>Attention</small></div><div><span class="dot dot-lilac"></span><strong id="checks-total">—</strong><small>Checks</small></div></div><div class="segmented-track" aria-hidden="true"><span id="checks-ready-bar"></span><span id="checks-attention-bar"></span></div><div class="range-labels"><span>Host readiness</span><span id="checks-summary">Taking a look</span></div></article>
|
<article class="metric-card"><div class="card-caption"><h2>Host signals</h2><a href="#system" class="mini-link" aria-label="View host signals"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="signal-readouts"><div><span class="dot dot-sage"></span><strong id="checks-ready">—</strong><small>Ready</small></div><div><span class="dot dot-peach"></span><strong id="checks-attention">—</strong><small>Attention</small></div><div><span class="dot dot-lilac"></span><strong id="checks-total">—</strong><small>Checks</small></div></div><div class="segmented-track" aria-hidden="true"><span id="checks-ready-bar"></span><span id="checks-attention-bar"></span></div><div class="range-labels"><span>Host readiness</span><span id="checks-summary">Taking a look</span></div></article>
|
||||||
@@ -161,7 +167,7 @@
|
|||||||
<section id="page-network" class="page" aria-labelledby="network-heading" hidden>
|
<section id="page-network" class="page" aria-labelledby="network-heading" hidden>
|
||||||
<div class="page-heading"><div><span class="subtle-label">Good connections start here</span><h1 id="network-heading" tabindex="-1">Network & ports<span class="heading-period">.</span></h1></div><span class="tag"><svg class="icon"><use href="#i-network"/></svg>Private IPv4 · TCP</span></div>
|
<div class="page-heading"><div><span class="subtle-label">Good connections start here</span><h1 id="network-heading" tabindex="-1">Network & ports<span class="heading-period">.</span></h1></div><span class="tag"><svg class="icon"><use href="#i-network"/></svg>Private IPv4 · TCP</span></div>
|
||||||
<div id="network-summary" class="compact-metrics"></div>
|
<div id="network-summary" class="compact-metrics"></div>
|
||||||
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>UVM control plane</small></div><div class="fabric-connector"></div><div class="fabric-node fabric-gateway"><span class="round-icon"><svg class="icon"><use href="#i-network"/></svg></span><strong id="network-gateway">No gateway yet</strong><small>Private gateway</small></div><div class="fabric-branches" aria-hidden="true"><span></span><span></span><span></span></div><div class="fabric-guests" aria-hidden="true"><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg></div><span id="network-machine-count" class="muted">Waiting for your fleet</span></div><p class="panel-footnote">Port rules allow private guest TCP traffic. HTTP and HTTPS are descriptive labels for TCP services.</p></article><article class="soft-panel network-policies"><header class="section-heading"><div><h2>Ports, with purpose</h2><span>Choose a machine to manage its rules.</span></div><svg class="icon caption-icon"><use href="#i-shield"/></svg></header><div id="network-policies"></div></article></div>
|
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>Nest control plane</small></div><div class="fabric-connector"></div><div class="fabric-node fabric-gateway"><span class="round-icon"><svg class="icon"><use href="#i-network"/></svg></span><strong id="network-gateway">No gateway yet</strong><small>Private gateway</small></div><div class="fabric-branches" aria-hidden="true"><span></span><span></span><span></span></div><div class="fabric-guests" aria-hidden="true"><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg></div><span id="network-machine-count" class="muted">Waiting for your fleet</span></div><p class="panel-footnote">Port rules allow private guest TCP traffic. HTTP and HTTPS are descriptive labels for TCP services.</p></article><article class="soft-panel network-policies"><header class="section-heading"><div><h2>Ports, with purpose</h2><span>Choose a machine to manage its rules.</span></div><svg class="icon caption-icon"><use href="#i-shield"/></svg></header><div id="network-policies"></div></article></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
|
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
|
||||||
@@ -170,6 +176,11 @@
|
|||||||
<div id="detail-content" hidden>
|
<div id="detail-content" hidden>
|
||||||
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
|
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
|
||||||
<div id="detail-error" class="notice notice-error" hidden></div>
|
<div id="detail-error" class="notice notice-error" hidden></div>
|
||||||
|
<nav class="machine-tabs" aria-label="Selected machine views">
|
||||||
|
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
|
||||||
|
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
|
||||||
|
</nav>
|
||||||
|
<div id="vm-overview-panel">
|
||||||
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
|
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
|
||||||
<article class="soft-panel resources-panel">
|
<article class="soft-panel resources-panel">
|
||||||
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
|
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
|
||||||
@@ -186,6 +197,30 @@
|
|||||||
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
|
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
|
||||||
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
|
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
|
||||||
|
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
|
||||||
|
<p id="sessions-error" class="form-error" role="alert"></p>
|
||||||
|
<div class="session-workspace">
|
||||||
|
<aside class="session-history soft-panel" aria-label="Session history">
|
||||||
|
<header><h3>Session history</h3><button id="sessions-refresh" class="icon-button" type="button" aria-label="Refresh sessions"><svg class="icon"><use href="#i-refresh"/></svg></button></header>
|
||||||
|
<div id="session-list" class="session-list"></div>
|
||||||
|
<p class="panel-footnote">Saved for this microVM.</p>
|
||||||
|
</aside>
|
||||||
|
<article class="terminal-panel">
|
||||||
|
<header class="terminal-toolbar"><div><span class="terminal-lights" aria-hidden="true"><i></i><i></i><i></i></span><strong id="session-title">Terminal</strong><span id="session-status" class="terminal-status">Select a session</span></div><div class="button-row"><button id="session-reconnect" class="button button-small" type="button" hidden>Reconnect</button><button id="session-end" class="button button-small" type="button" disabled>End session</button></div></header>
|
||||||
|
<div id="session-blank" class="terminal-blank"><svg class="icon"><use href="#i-terminal"/></svg><h3>A little room to work.</h3><p>Select a saved session, or launch a fresh terminal<br />inside this microVM.</p><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></div>
|
||||||
|
<div id="session-terminal" class="session-terminal" aria-label="MicroVM terminal" hidden></div>
|
||||||
|
<footer id="session-meta" class="terminal-footer">Your sessions and terminal output are saved on the host.</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<details id="terminal-support" class="terminal-support soft-panel">
|
||||||
|
<summary>Guest terminal support</summary>
|
||||||
|
<p>Vsock and the guest agent are set up automatically when a microVM is created or started. Restart an older VM once to apply the setup. Manual repair is available here while the machine is stopped.</p>
|
||||||
|
<button id="session-install-agent" class="button button-small" type="button">Repair / update terminal support</button>
|
||||||
|
<span id="session-install-hint" class="muted"></span>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="page-create" class="page" aria-labelledby="create-heading" hidden>
|
<section id="page-create" class="page" aria-labelledby="create-heading" hidden>
|
||||||
@@ -193,7 +228,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 +237,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>
|
||||||
@@ -211,6 +246,6 @@
|
|||||||
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
|
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
|
||||||
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
|
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
|
||||||
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
|
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
|
||||||
<noscript><p class="noscript-message">Enable JavaScript to connect to your UVM workspace.</p></noscript>
|
<noscript><p class="noscript-message">Enable JavaScript to connect to your Nest workspace.</p></noscript>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||||||
const renderCache = new WeakMap();
|
const renderCache = new WeakMap();
|
||||||
const pages = {
|
const pages = {
|
||||||
overview: { label: 'Overview', title: 'Management', subtitle: 'Overview', icon: 'grid' },
|
overview: { label: 'Overview', title: 'Workspace', subtitle: 'Overview', icon: 'grid' },
|
||||||
vms: { label: 'Machines', title: 'Machine', subtitle: 'Collection', icon: 'server' },
|
vms: { label: 'Machines', title: 'Machine', subtitle: 'Collection', icon: 'server' },
|
||||||
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
|
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
|
||||||
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
|
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
page: 'overview',
|
page: 'overview',
|
||||||
detailId: null,
|
detailId: null,
|
||||||
detail: null,
|
detail: null,
|
||||||
|
detailTab: 'overview',
|
||||||
|
terminalId: null,
|
||||||
portEditing: false,
|
portEditing: false,
|
||||||
resizeEditing: false,
|
resizeEditing: false,
|
||||||
selected: new Set(),
|
selected: new Set(),
|
||||||
@@ -45,6 +47,13 @@
|
|||||||
menuTrigger: null,
|
menuTrigger: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sessions = new window.NestSessions({
|
||||||
|
api, getToken: () => state.token,
|
||||||
|
isSelectedVm: (id) => state.page === 'vm-detail' && state.detailId === id,
|
||||||
|
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
||||||
|
esc, formatDate, toast, confirmAction,
|
||||||
|
});
|
||||||
|
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
@@ -594,13 +603,26 @@
|
|||||||
text('#detail-heading', machineName(vm));
|
text('#detail-heading', machineName(vm));
|
||||||
text('#detail-id', vm.id);
|
text('#detail-id', vm.id);
|
||||||
html('#detail-status', badge(runtimeStatus(vm)));
|
html('#detail-status', badge(runtimeStatus(vm)));
|
||||||
document.title = `${machineName(vm)} · UVM Control`;
|
document.title = `${machineName(vm)} · Nest`;
|
||||||
const busy = state.busy.has(vm.id);
|
const busy = state.busy.has(vm.id);
|
||||||
const running = runtimeStatus(vm) === 'running';
|
const running = runtimeStatus(vm) === 'running';
|
||||||
const actionButton = (action, caption, symbol, primary = false) => `<button type="button" class="button${primary ? ' button-primary' : ''}" data-action="vm-action" data-operation="${action}" data-id="${esc(vm.id)}" ${busy ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`;
|
const actionButton = (action, caption, symbol, primary = false) => `<button type="button" class="button${primary ? ' button-primary' : ''}" data-action="vm-action" data-operation="${action}" data-id="${esc(vm.id)}" ${busy ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`;
|
||||||
html('#detail-actions', running
|
html('#detail-actions', (running
|
||||||
? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop')
|
? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop')
|
||||||
: actionButton('start', 'Start machine', 'play', true));
|
: actionButton('start', 'Start machine', 'play', true))
|
||||||
|
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
|
||||||
|
const showingSessions = state.detailTab === 'sessions';
|
||||||
|
$('#vm-overview-panel').hidden = showingSessions;
|
||||||
|
$('#vm-sessions-panel').hidden = !showingSessions;
|
||||||
|
$('#vm-overview-tab').href = vmHref(vm.id);
|
||||||
|
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
|
||||||
|
[['#vm-overview-tab', !showingSessions], ['#vm-sessions-tab', showingSessions]].forEach(([selector, active]) => {
|
||||||
|
$(selector).classList.toggle('active', active);
|
||||||
|
if (active) $(selector).setAttribute('aria-current', 'page');
|
||||||
|
else $(selector).removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : ''}`);
|
||||||
|
sessions.sync(vm, state.terminalId, showingSessions);
|
||||||
html('#detail-config', detailRows([
|
html('#detail-config', detailRows([
|
||||||
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
||||||
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
||||||
@@ -655,13 +677,14 @@
|
|||||||
state.detail = vm;
|
state.detail = vm;
|
||||||
state.detailId = vm.id;
|
state.detailId = vm.id;
|
||||||
renderDetail();
|
renderDetail();
|
||||||
await loadLogs();
|
if (state.detailTab === 'overview') await loadLogs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAborted(error) || request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return;
|
if (isAborted(error) || request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return;
|
||||||
if (state.detail && error.status !== 404) {
|
if (state.detail && error.status !== 404) {
|
||||||
text('#detail-error', error.message);
|
text('#detail-error', error.message);
|
||||||
$('#detail-error').hidden = false;
|
$('#detail-error').hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
sessions.leave();
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
$('#detail-content').hidden = true;
|
$('#detail-content').hidden = true;
|
||||||
$('#detail-loading').hidden = false;
|
$('#detail-loading').hidden = false;
|
||||||
@@ -1119,8 +1142,17 @@
|
|||||||
if (!state.authenticated) return;
|
if (!state.authenticated) return;
|
||||||
let value = location.hash.slice(1) || 'overview';
|
let value = location.hash.slice(1) || 'overview';
|
||||||
let identifier = null;
|
let identifier = null;
|
||||||
|
let detailTab = 'overview';
|
||||||
|
let terminalId = null;
|
||||||
if (value.startsWith('vm/')) {
|
if (value.startsWith('vm/')) {
|
||||||
try { identifier = decodeURIComponent(value.slice(3)); } catch { identifier = null; }
|
const parts = value.split('/');
|
||||||
|
try {
|
||||||
|
identifier = decodeURIComponent(parts[1]);
|
||||||
|
if (parts[2] === 'sessions' && parts.length <= 4) {
|
||||||
|
detailTab = 'sessions';
|
||||||
|
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
|
||||||
|
} else if (parts.length !== 2) identifier = null;
|
||||||
|
} catch { identifier = null; }
|
||||||
value = identifier ? 'vm-detail' : 'overview';
|
value = identifier ? 'vm-detail' : 'overview';
|
||||||
}
|
}
|
||||||
if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview';
|
if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview';
|
||||||
@@ -1129,6 +1161,9 @@
|
|||||||
state.requests.detail += 1;
|
state.requests.detail += 1;
|
||||||
state.requests.logs += 1;
|
state.requests.logs += 1;
|
||||||
state.detailId = identifier;
|
state.detailId = identifier;
|
||||||
|
state.detailTab = detailTab;
|
||||||
|
state.terminalId = terminalId;
|
||||||
|
sessions.leave();
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
state.portEditing = false;
|
state.portEditing = false;
|
||||||
state.resizeEditing = false;
|
state.resizeEditing = false;
|
||||||
@@ -1146,7 +1181,7 @@
|
|||||||
text('#sidebar-title', pages[value].title);
|
text('#sidebar-title', pages[value].title);
|
||||||
text('#sidebar-subtitle', pages[value].subtitle);
|
text('#sidebar-subtitle', pages[value].subtitle);
|
||||||
text('#breadcrumb', pages[value].label);
|
text('#breadcrumb', pages[value].label);
|
||||||
document.title = `${pages[value].label} · UVM Control`;
|
document.title = `${pages[value].label} · Nest`;
|
||||||
$('#main-content').scrollTop = 0;
|
$('#main-content').scrollTop = 0;
|
||||||
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
|
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
if (value === 'vm-detail') {
|
if (value === 'vm-detail') {
|
||||||
@@ -1163,8 +1198,8 @@
|
|||||||
|
|
||||||
function storeToken(token) {
|
function storeToken(token) {
|
||||||
try {
|
try {
|
||||||
if (token) sessionStorage.setItem('uvm_token', token);
|
if (token) sessionStorage.setItem('nest_token', token);
|
||||||
else sessionStorage.removeItem('uvm_token');
|
else sessionStorage.removeItem('nest_token');
|
||||||
} catch { /* A restricted browser can still use an in-memory session. */ }
|
} catch { /* A restricted browser can still use an in-memory session. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,6 +1252,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logout(message = '') {
|
function logout(message = '') {
|
||||||
|
sessions.leave();
|
||||||
state.session += 1;
|
state.session += 1;
|
||||||
state.controllers.forEach((controller) => controller.abort());
|
state.controllers.forEach((controller) => controller.abort());
|
||||||
state.controllers.clear();
|
state.controllers.clear();
|
||||||
@@ -1268,7 +1304,7 @@
|
|||||||
$('#auth-screen').hidden = false;
|
$('#auth-screen').hidden = false;
|
||||||
$('#refresh-btn').classList.remove('is-loading');
|
$('#refresh-btn').classList.remove('is-loading');
|
||||||
$('#toast-container').replaceChildren();
|
$('#toast-container').replaceChildren();
|
||||||
document.title = 'UVM Control';
|
document.title = 'Nest';
|
||||||
$('#token-input').focus();
|
$('#token-input').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,6 +1321,7 @@
|
|||||||
if (action === 'clear-filters') { state.query = ''; state.filter = 'all'; $('#vm-search').value = ''; renderMachines(); }
|
if (action === 'clear-filters') { state.query = ''; state.filter = 'all'; $('#vm-search').value = ''; renderMachines(); }
|
||||||
if (action === 'dismiss-results') $('#bulk-results').hidden = true;
|
if (action === 'dismiss-results') $('#bulk-results').hidden = true;
|
||||||
if (action === 'install') installAssets();
|
if (action === 'install') installAssets();
|
||||||
|
if (action === 'launch-session' && state.detail) sessions.launch(state.detail);
|
||||||
if (action === 'refresh-logs') loadLogs();
|
if (action === 'refresh-logs') loadLogs();
|
||||||
if (action === 'copy-ssh') copyText($('#ssh-command').textContent);
|
if (action === 'copy-ssh') copyText($('#ssh-command').textContent);
|
||||||
if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id);
|
if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id);
|
||||||
@@ -1388,6 +1425,7 @@
|
|||||||
if (target && !target.disabled) handleAction(target);
|
if (target && !target.disabled) handleAction(target);
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.target.closest('.xterm')) return;
|
||||||
const editing = event.target.closest('input, textarea, select, [contenteditable="true"]');
|
const editing = event.target.closest('input, textarea, select, [contenteditable="true"]');
|
||||||
if (state.authenticated && ((event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) || (event.key === '/' && !editing))) {
|
if (state.authenticated && ((event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) || (event.key === '/' && !editing))) {
|
||||||
if ($('#confirm-dialog').open) return;
|
if ($('#confirm-dialog').open) return;
|
||||||
@@ -1427,7 +1465,7 @@
|
|||||||
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
|
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
|
||||||
updateCreateSummary();
|
updateCreateSummary();
|
||||||
let stored = '';
|
let stored = '';
|
||||||
try { stored = sessionStorage.getItem('uvm_token') || ''; } catch { /* Session storage is optional. */ }
|
try { stored = sessionStorage.getItem('nest_token') || ''; } catch { /* Session storage is optional. */ }
|
||||||
if (stored) connect(stored);
|
if (stored) connect(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const href = (vmId, sessionId = '') => `#vm/${encodeURIComponent(vmId)}/sessions${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`;
|
||||||
|
const path = (vmId, suffix = '') => `/vms/${encodeURIComponent(vmId)}/sessions${suffix}`;
|
||||||
|
const bytes = (encoded) => Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
|
||||||
|
const encoded = (data) => btoa(String.fromCharCode(...data));
|
||||||
|
|
||||||
|
window.NestSessions = class {
|
||||||
|
constructor({ api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }) {
|
||||||
|
Object.assign(this, { api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction });
|
||||||
|
this.vm = null;
|
||||||
|
this.records = [];
|
||||||
|
this.current = null;
|
||||||
|
this.selectedId = null;
|
||||||
|
this.visible = false;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.generation = 0;
|
||||||
|
this.launching = new Set();
|
||||||
|
this.upgrading = new Set();
|
||||||
|
this.offset = 0;
|
||||||
|
this.renderPending = Promise.resolve();
|
||||||
|
$('#sessions-refresh').addEventListener('click', () => this.refresh());
|
||||||
|
$('#session-end').addEventListener('click', () => this.end());
|
||||||
|
$('#session-reconnect').addEventListener('click', () => this.connectSocket());
|
||||||
|
$('#session-install-agent').addEventListener('click', () => this.installAgent());
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(vm, selectedId, visible) {
|
||||||
|
if (this.vm?.id !== vm.id) {
|
||||||
|
this.leave();
|
||||||
|
this.vm = vm;
|
||||||
|
$('#vm-session-count').textContent = '—';
|
||||||
|
}
|
||||||
|
this.vm = vm;
|
||||||
|
if (this.selectedId !== selectedId || this.visible !== visible) {
|
||||||
|
this.detachTerminal();
|
||||||
|
this.showError('');
|
||||||
|
}
|
||||||
|
this.selectedId = selectedId;
|
||||||
|
this.visible = visible;
|
||||||
|
this.updateControls();
|
||||||
|
if (this.pendingError?.vmId === vm.id && visible) {
|
||||||
|
this.showError(this.pendingError.message);
|
||||||
|
$('#terminal-support').open = this.pendingError.setup;
|
||||||
|
this.pendingError = null;
|
||||||
|
}
|
||||||
|
this.renderList();
|
||||||
|
this.openSelected();
|
||||||
|
if (!this.loading && (!this.lastRefresh || Date.now() - this.lastRefresh > 3000)) this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
$('#sessions-error').textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateControls() {
|
||||||
|
const running = this.vm && (this.vm.observed_status || this.vm.status) === 'running'
|
||||||
|
&& !this.vm.operation && !this.vm.pending_resize;
|
||||||
|
document.querySelectorAll('[data-action="launch-session"]').forEach((button) => {
|
||||||
|
button.disabled = !running || this.launching.has(this.vm?.id);
|
||||||
|
button.querySelector('span').textContent = this.launching.has(this.vm?.id) ? 'Launching…' : 'Launch session';
|
||||||
|
button.title = running ? 'Open an interactive shell inside this microVM' : 'Start this machine before launching a session';
|
||||||
|
});
|
||||||
|
const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped'
|
||||||
|
&& !this.vm.operation && !this.vm.pending_resize;
|
||||||
|
$('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id);
|
||||||
|
$('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Repair / update terminal support';
|
||||||
|
$('#session-install-hint').textContent = stopped ? 'Setup runs automatically at startup. Manual repair is available now.' : 'Vsock is set up automatically on boot. Restart to apply updates.';
|
||||||
|
this.renderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
const markup = this.records.length ? this.records.map((record) => `
|
||||||
|
<a class="session-item${record.id === this.selectedId ? ' active' : ''}" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
|
||||||
|
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
|
||||||
|
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
|
||||||
|
</a>`).join('') : `<p class="session-list-empty">${this.loading ? 'Loading sessions…' : 'No sessions yet.<br />Launch a session to get started.'}</p>`;
|
||||||
|
if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
if (!this.vm) return;
|
||||||
|
const vmId = this.vm.id;
|
||||||
|
const sequence = ++this.sequence;
|
||||||
|
this.loading = true;
|
||||||
|
$('#sessions-refresh').disabled = true;
|
||||||
|
try {
|
||||||
|
const records = await this.api(path(vmId));
|
||||||
|
if (sequence !== this.sequence || this.vm?.id !== vmId) return;
|
||||||
|
if (!Array.isArray(records)) throw new Error('The session history response was not recognized.');
|
||||||
|
this.records = records;
|
||||||
|
if (this.current) {
|
||||||
|
const latest = records.find((record) => record.id === this.current.id);
|
||||||
|
if (latest && latest.updated_at >= this.current.updated_at) this.current = latest;
|
||||||
|
else if (latest) this.records[this.records.indexOf(latest)] = this.current;
|
||||||
|
}
|
||||||
|
this.lastRefresh = Date.now();
|
||||||
|
$('#vm-session-count').textContent = records.length;
|
||||||
|
this.openSelected();
|
||||||
|
this.renderStatus();
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence === this.sequence && error.name !== 'AbortError') this.showError(error.message);
|
||||||
|
} finally {
|
||||||
|
if (sequence === this.sequence) {
|
||||||
|
this.loading = false;
|
||||||
|
$('#sessions-refresh').disabled = false;
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async launch(vm) {
|
||||||
|
if (!vm || this.launching.has(vm.id)) return;
|
||||||
|
const token = this.getToken();
|
||||||
|
this.launching.add(vm.id);
|
||||||
|
this.showError('');
|
||||||
|
this.updateControls();
|
||||||
|
try {
|
||||||
|
const record = await this.api(path(vm.id), { method: 'POST', body: { rows: 24, cols: 80 } });
|
||||||
|
if (token === this.getToken() && this.isSelectedVm(vm.id)) location.hash = href(vm.id, record.id);
|
||||||
|
} catch (error) {
|
||||||
|
if (token === this.getToken() && error.name !== 'AbortError') {
|
||||||
|
this.toast(error.message, true);
|
||||||
|
if (this.isSelectedVm(vm.id)) {
|
||||||
|
this.pendingError = { vmId: vm.id, message: error.message, setup: error.status === 503 };
|
||||||
|
location.hash = href(vm.id);
|
||||||
|
if (this.visible) this.sync(vm, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.launching.delete(vm.id);
|
||||||
|
this.lastRefresh = 0;
|
||||||
|
this.updateControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installAgent() {
|
||||||
|
const vm = this.vm;
|
||||||
|
if (!vm || this.upgrading.has(vm.id)) return;
|
||||||
|
this.upgrading.add(vm.id);
|
||||||
|
this.showError('');
|
||||||
|
this.updateControls();
|
||||||
|
try {
|
||||||
|
await this.api(`/vms/${encodeURIComponent(vm.id)}/agent/install`, { method: 'POST' });
|
||||||
|
if (this.vm?.id === vm.id) {
|
||||||
|
this.toast('Terminal support updated. Start this machine, then launch a session.');
|
||||||
|
$('#session-install-hint').textContent = 'Updated. Start the machine to use the new agent.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.vm?.id === vm.id && error.name !== 'AbortError') this.showError(error.message);
|
||||||
|
} finally {
|
||||||
|
this.upgrading.delete(vm.id);
|
||||||
|
this.updateControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openSelected() {
|
||||||
|
if (!this.visible || !this.selectedId || this.current?.id === this.selectedId) return;
|
||||||
|
const record = this.records.find((item) => item.id === this.selectedId);
|
||||||
|
if (!record) {
|
||||||
|
if (this.lastRefresh) this.showError('This session was not found for the selected machine.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showError('');
|
||||||
|
this.detachTerminal();
|
||||||
|
this.current = record;
|
||||||
|
$('#session-blank').hidden = true;
|
||||||
|
$('#session-terminal').hidden = false;
|
||||||
|
this.term = new window.Terminal({
|
||||||
|
cols: record.cols, rows: record.rows, cursorBlink: true, fontSize: 13,
|
||||||
|
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||||
|
scrollback: 10000, disableStdin: true,
|
||||||
|
theme: { background: '#25222d', foreground: '#e8e3ef', cursor: '#ede39f', selectionBackground: '#74628780' },
|
||||||
|
});
|
||||||
|
this.fit = new window.FitAddon.FitAddon();
|
||||||
|
this.term.loadAddon(this.fit);
|
||||||
|
this.term.open($('#session-terminal'));
|
||||||
|
this.term.onData((text) => this.input(new TextEncoder().encode(text)));
|
||||||
|
this.term.onBinary((text) => this.input(Uint8Array.from(text, (character) => character.charCodeAt(0))));
|
||||||
|
this.term.onResize(({ rows, cols }) => this.sendSize(rows, cols));
|
||||||
|
this.observer = new ResizeObserver(() => this.fitTerminal());
|
||||||
|
this.observer.observe($('#session-terminal'));
|
||||||
|
this.fitTerminal();
|
||||||
|
this.connectSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
fitTerminal() {
|
||||||
|
if (this.term && this.visible && $('#session-terminal').clientWidth > 0) {
|
||||||
|
this.fit.fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input(data) {
|
||||||
|
if (!this.canType()) return;
|
||||||
|
if (data.length > 65536 || this.socket.bufferedAmount > 65536) {
|
||||||
|
this.showError('Terminal input is busy or the paste exceeds 64 KiB. Please send a smaller chunk.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < data.length; offset += 8192) {
|
||||||
|
this.socket.send(JSON.stringify({ type: 'input', data: encoded(data.subarray(offset, offset + 8192)) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canType() {
|
||||||
|
return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSize(rows, cols) {
|
||||||
|
if (this.canType()) this.socket.send(JSON.stringify({ type: 'resize', rows: Math.max(2, Math.min(200, rows)), cols: Math.max(2, Math.min(400, cols)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectSocket() {
|
||||||
|
if (!this.current || !this.visible || !this.getToken()) return;
|
||||||
|
const generation = this.generation;
|
||||||
|
await this.renderPending;
|
||||||
|
if (generation !== this.generation || !this.current || !this.visible) return;
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
const previous = this.socket;
|
||||||
|
this.socket = null;
|
||||||
|
previous?.close();
|
||||||
|
this.caughtUp = false;
|
||||||
|
this.replayEnd = null;
|
||||||
|
this.connection = 'Connecting…';
|
||||||
|
const url = new URL(path(this.vm.id, `/${encodeURIComponent(this.current.id)}/terminal`), location.origin);
|
||||||
|
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
this.socket = socket;
|
||||||
|
let rejected = false;
|
||||||
|
const current = () => generation === this.generation && this.socket === socket;
|
||||||
|
this.renderStatus();
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (current()) socket.send(JSON.stringify({ type: 'auth', token: this.getToken(), offset: this.offset }));
|
||||||
|
};
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (!current()) return;
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.type === 'session') {
|
||||||
|
const wasReady = this.caughtUp;
|
||||||
|
this.current = message.session;
|
||||||
|
if (message.offset > this.offset) {
|
||||||
|
this.term.reset();
|
||||||
|
this.offset = message.offset;
|
||||||
|
}
|
||||||
|
if (this.replayEnd == null) this.replayEnd = message.session.output_end;
|
||||||
|
this.caughtUp = this.offset >= this.replayEnd;
|
||||||
|
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
|
||||||
|
const index = this.records.findIndex((record) => record.id === message.session.id);
|
||||||
|
if (index >= 0) this.records[index] = message.session;
|
||||||
|
this.renderList();
|
||||||
|
this.renderStatus();
|
||||||
|
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
|
||||||
|
} else if (message.type === 'output') {
|
||||||
|
const data = bytes(message.data);
|
||||||
|
if (message.offset !== this.offset) throw new Error('Terminal output is out of sequence. Reconnect to load history.');
|
||||||
|
this.renderPending = new Promise((resolve) => {
|
||||||
|
this.finishRender = resolve;
|
||||||
|
this.term.write(data, () => {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.offset += data.length;
|
||||||
|
const wasReady = this.caughtUp;
|
||||||
|
this.caughtUp = this.offset >= this.replayEnd;
|
||||||
|
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
|
||||||
|
if (current()) socket.send(JSON.stringify({ type: 'ack', offset: this.offset }));
|
||||||
|
this.renderStatus();
|
||||||
|
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
|
||||||
|
this.finishRender = null;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (message.type === 'error') {
|
||||||
|
rejected = true;
|
||||||
|
this.showError(message.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
rejected = true;
|
||||||
|
this.showError(error.message);
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
if (!current()) return;
|
||||||
|
this.connection = 'Disconnected';
|
||||||
|
this.caughtUp = false;
|
||||||
|
this.renderStatus();
|
||||||
|
if (event.code === 4401) { this.onExpired(); return; }
|
||||||
|
if (!rejected && ['active', 'starting'].includes(this.current?.status)) {
|
||||||
|
this.reconnectTimer = window.setTimeout(() => this.connectSocket(), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStatus() {
|
||||||
|
const record = this.current;
|
||||||
|
const active = record?.status === 'active';
|
||||||
|
$('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal';
|
||||||
|
$('#session-status').textContent = record ? active ? this.connection || 'Connecting…' : `${record.status}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}` : 'Select a session';
|
||||||
|
$('#session-end').disabled = !active || this.ending === record?.id;
|
||||||
|
$('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN;
|
||||||
|
if (this.term) this.term.options.disableStdin = !this.canType();
|
||||||
|
$('#session-meta').textContent = record
|
||||||
|
? `${record.username || 'Guest'} · Started ${this.formatDate(record.created_at)}${record.ended_at ? ` · Ended ${this.formatDate(record.ended_at)}` : ' · Reopen this session to continue'}${record.truncated ? ' · Showing the latest 8 MiB of output' : ''}${record.error ? ` · ${record.error}` : ''}`
|
||||||
|
: 'Your sessions and terminal output are saved on the host.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async end() {
|
||||||
|
const record = this.current;
|
||||||
|
if (!record || this.ending) return;
|
||||||
|
const confirmed = await this.confirmAction(`End ${record.name}?`, 'Close this shell and its running processes. Its saved terminal output will be available in session history.', 'End session');
|
||||||
|
if (!confirmed) return;
|
||||||
|
this.ending = record.id;
|
||||||
|
this.renderStatus();
|
||||||
|
try {
|
||||||
|
const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' });
|
||||||
|
if (this.current?.id === record.id) this.current = ended;
|
||||||
|
if (this.vm?.id === record.vm_id) await this.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message);
|
||||||
|
} finally {
|
||||||
|
this.ending = null;
|
||||||
|
this.renderStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detachTerminal() {
|
||||||
|
this.generation += 1;
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.observer = null;
|
||||||
|
const socket = this.socket;
|
||||||
|
this.socket = null;
|
||||||
|
socket?.close();
|
||||||
|
this.finishRender?.();
|
||||||
|
this.finishRender = null;
|
||||||
|
this.renderPending = Promise.resolve();
|
||||||
|
this.term?.dispose();
|
||||||
|
this.term = null;
|
||||||
|
this.current = null;
|
||||||
|
this.offset = 0;
|
||||||
|
this.caughtUp = false;
|
||||||
|
$('#session-terminal').replaceChildren();
|
||||||
|
$('#session-terminal').hidden = true;
|
||||||
|
$('#session-blank').hidden = false;
|
||||||
|
this.renderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
leave() {
|
||||||
|
this.sequence += 1;
|
||||||
|
this.loading = false;
|
||||||
|
this.lastRefresh = 0;
|
||||||
|
this.detachTerminal();
|
||||||
|
this.visible = false;
|
||||||
|
this.vm = null;
|
||||||
|
this.records = [];
|
||||||
|
this.selectedId = null;
|
||||||
|
this.showError('');
|
||||||
|
$('#terminal-support').open = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# Local terminal assets
|
||||||
|
|
||||||
|
Runtime files from the published npm packages:
|
||||||
|
|
||||||
|
- `@xterm/xterm` **5.5.0** — `xterm/lib/xterm.js`, `xterm/css/xterm.css`
|
||||||
|
- `@xterm/addon-fit` **0.10.0** — `xterm-fit/lib/addon-fit.js`
|
||||||
|
|
||||||
|
Both use the MIT license; the original `LICENSE` is included in each directory.
|
||||||
|
Assets are served locally. Running Nest requires no npm install or frontend build.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||||
|
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
|
||||||
|
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
|
|||||||
|
"""Compose the Nest dashboard with its local UVM management backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from uvm.app import Application
|
||||||
|
from uvm.errors import UvmError
|
||||||
|
from uvm.server import create_api as create_uvm_api, run_server as run_api_server
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(application: Application | None = None, *, host: str | None = None) -> Any:
|
||||||
|
"""Build the Nest workspace after validating the intended server binding."""
|
||||||
|
if host is None:
|
||||||
|
raise UvmError("create_app requires an explicit host. Start the workspace with nest --serve.")
|
||||||
|
|
||||||
|
api = create_uvm_api(application, host=host)
|
||||||
|
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .dashboard.router import router as dashboard_router
|
||||||
|
from .session_routes import router as session_router
|
||||||
|
from .sessions import SessionManager
|
||||||
|
|
||||||
|
sessions = SessionManager(api.state.uvm_application)
|
||||||
|
api.state.nest_sessions = sessions
|
||||||
|
previous_lifespan = api.router.lifespan_context
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
async with previous_lifespan(app):
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await sessions.shutdown()
|
||||||
|
|
||||||
|
api.router.lifespan_context = lifespan
|
||||||
|
|
||||||
|
api.title = "Nest"
|
||||||
|
api.description = "Nest workspace API, with local Firecracker machine management powered by UVM."
|
||||||
|
static_dir = Path(__file__).parent / "dashboard" / "static"
|
||||||
|
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
|
||||||
|
api.include_router(dashboard_router, prefix="/dashboard")
|
||||||
|
api.include_router(session_router)
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
async def root() -> Any:
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(application: Application, *, host: str, port: int) -> None:
|
||||||
|
"""Launch Nest using the shared authenticated, TLS-aware server runner."""
|
||||||
|
run_api_server(application, host=host, port=port, api_factory=create_app)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""VM-scoped session history, creation, and authenticated browser terminal streams."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||||
|
|
||||||
|
from uvm.app import Application
|
||||||
|
from uvm.errors import UvmError
|
||||||
|
from uvm.guest_agent import MAX_TERMINAL_FRAME, ProtocolError, terminal_data
|
||||||
|
from uvm.routers.dependencies import get_authorized_application
|
||||||
|
|
||||||
|
from .sessions import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"])
|
||||||
|
|
||||||
|
|
||||||
|
class SessionCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
rows: StrictInt = Field(default=24, ge=2, le=200)
|
||||||
|
cols: StrictInt = Field(default=80, ge=2, le=400)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
vm_id: str
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
status: Literal["starting", "active", "closed", "interrupted", "failed"]
|
||||||
|
created_at: float
|
||||||
|
updated_at: float
|
||||||
|
ended_at: float | None
|
||||||
|
rows: int
|
||||||
|
cols: int
|
||||||
|
exit_code: int | None
|
||||||
|
error: str | None
|
||||||
|
output_start: int
|
||||||
|
output_end: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
def manager(request: Request, _application: Application = Depends(get_authorized_application)) -> SessionManager:
|
||||||
|
return request.app.state.nest_sessions
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SessionResponse])
|
||||||
|
async def list_sessions(vm_id: str, sessions: SessionManager = Depends(manager)):
|
||||||
|
return await sessions.list(vm_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SessionResponse, status_code=201)
|
||||||
|
async def create_session(vm_id: str, body: SessionCreate, sessions: SessionManager = Depends(manager)):
|
||||||
|
return await sessions.create(vm_id, body.rows, body.cols)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}", response_model=SessionResponse)
|
||||||
|
async def get_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
|
||||||
|
return await sessions.get(vm_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/close", response_model=SessionResponse)
|
||||||
|
async def close_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
|
||||||
|
return await sessions.end(vm_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_origin(websocket: WebSocket) -> bool:
|
||||||
|
origin = websocket.headers.get("origin")
|
||||||
|
if origin is None:
|
||||||
|
return True # Non-browser API clients must still authenticate.
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(origin)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
||||||
|
return parsed.scheme == scheme and parsed.netloc.lower() == websocket.headers.get("host", "").lower() and not parsed.path
|
||||||
|
|
||||||
|
|
||||||
|
async def _message(websocket: WebSocket, *, limit: int = MAX_TERMINAL_FRAME) -> dict:
|
||||||
|
text = await websocket.receive_text()
|
||||||
|
if len(text) > limit:
|
||||||
|
raise ProtocolError("terminal message exceeds its size limit")
|
||||||
|
message = json.loads(text)
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
raise ProtocolError("terminal message must be an object")
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{session_id}/terminal")
|
||||||
|
async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
|
||||||
|
if not _same_origin(websocket):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
await websocket.accept()
|
||||||
|
tasks = []
|
||||||
|
try:
|
||||||
|
# Browser WebSockets cannot set X-UVM-Token. Authenticate in the first
|
||||||
|
# frame so tokens never appear in URLs, access logs, or session history.
|
||||||
|
auth = await asyncio.wait_for(_message(websocket, limit=4096), timeout=10)
|
||||||
|
token = auth.get("token")
|
||||||
|
expected = websocket.app.state.uvm_application.settings.api_token
|
||||||
|
if (auth.get("type") != "auth" or not isinstance(token, str) or not token.isascii()
|
||||||
|
or not expected or not hmac.compare_digest(token, expected)):
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
sessions = websocket.app.state.nest_sessions
|
||||||
|
await sessions.get(vm_id, session_id)
|
||||||
|
offset = auth.get("offset", 0)
|
||||||
|
acknowledgements = asyncio.Queue(maxsize=1)
|
||||||
|
|
||||||
|
async def output():
|
||||||
|
cursor = offset
|
||||||
|
previous = None
|
||||||
|
while True:
|
||||||
|
record, start, data = await sessions.read(vm_id, session_id, cursor)
|
||||||
|
if previous != record or start != cursor:
|
||||||
|
await websocket.send_json({"type": "session", "session": SessionResponse(**record).model_dump(), "offset": start})
|
||||||
|
previous = record
|
||||||
|
cursor = start
|
||||||
|
if data:
|
||||||
|
await websocket.send_json({"type": "output", "offset": cursor, "data": base64.b64encode(data).decode("ascii")})
|
||||||
|
cursor += len(data)
|
||||||
|
# Only one output chunk may be waiting for the terminal renderer.
|
||||||
|
acknowledged = await asyncio.wait_for(acknowledgements.get(), timeout=30)
|
||||||
|
if type(acknowledged) is not int or acknowledged != cursor:
|
||||||
|
raise ProtocolError("invalid terminal output acknowledgement")
|
||||||
|
continue
|
||||||
|
if record["status"] not in {"active", "starting"}:
|
||||||
|
return
|
||||||
|
await sessions.wait(session_id)
|
||||||
|
|
||||||
|
async def controls():
|
||||||
|
while True:
|
||||||
|
message = await _message(websocket)
|
||||||
|
if set(message) == {"type", "data"} and message["type"] == "input":
|
||||||
|
await sessions.write(vm_id, session_id, terminal_data(message["data"]))
|
||||||
|
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
|
||||||
|
await sessions.resize(vm_id, session_id, message["rows"], message["cols"])
|
||||||
|
elif set(message) == {"type", "offset"} and message["type"] == "ack":
|
||||||
|
if acknowledgements.full():
|
||||||
|
raise ProtocolError("unexpected terminal output acknowledgement")
|
||||||
|
acknowledgements.put_nowait(message["offset"])
|
||||||
|
else:
|
||||||
|
raise ProtocolError("invalid terminal control message")
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(output()), asyncio.create_task(controls())]
|
||||||
|
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for task in done:
|
||||||
|
task.result()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except (ValueError, KeyError, TypeError, UnicodeError, RecursionError, TimeoutError):
|
||||||
|
with suppress(WebSocketDisconnect, RuntimeError):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."})
|
||||||
|
except UvmError as error:
|
||||||
|
with suppress(WebSocketDisconnect, RuntimeError):
|
||||||
|
await websocket.send_json({"type": "error", "message": str(error)})
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
with suppress(WebSocketDisconnect, RuntimeError):
|
||||||
|
await websocket.close()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Private, durable session metadata and bounded PTY transcripts, separate from VM state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from uvm.errors import ConflictError, NotFoundError, StateError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
def __init__(self, path: Path, *, max_output: int = MAX_TRANSCRIPT_BYTES) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.max_output = max_output
|
||||||
|
self._mutex = threading.RLock()
|
||||||
|
self._owner = None
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||||
|
if path.parent.is_symlink():
|
||||||
|
raise StateError("session history directory must not be a symlink")
|
||||||
|
path.parent.chmod(0o700)
|
||||||
|
self._owner = self._private_file(path.with_suffix(".lock"))
|
||||||
|
try:
|
||||||
|
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError as error:
|
||||||
|
raise ConflictError("Another Nest server is already managing sessions for this VM registry.") from error
|
||||||
|
descriptor = self._private_file(path)
|
||||||
|
os.close(descriptor)
|
||||||
|
with self._db() as db:
|
||||||
|
db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY, vm_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL, status TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL, updated_at REAL NOT NULL, ended_at REAL,
|
||||||
|
rows INTEGER NOT NULL, cols INTEGER NOT NULL, exit_code INTEGER, error TEXT,
|
||||||
|
output_start INTEGER NOT NULL DEFAULT 0, output_end INTEGER NOT NULL DEFAULT 0,
|
||||||
|
truncated INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS sessions_vm ON sessions(vm_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS output (
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id), offset INTEGER NOT NULL,
|
||||||
|
data BLOB NOT NULL, PRIMARY KEY (session_id, offset)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
now = time.time()
|
||||||
|
db.execute("""UPDATE sessions SET status='interrupted', ended_at=?, updated_at=?,
|
||||||
|
error='Nest restarted; this shell is no longer connected.'
|
||||||
|
WHERE status IN ('starting', 'active')""", (now, now))
|
||||||
|
except BaseException:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _private_file(path: Path) -> int:
|
||||||
|
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
|
||||||
|
raise StateError("session history must use private regular files")
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
return descriptor
|
||||||
|
except BaseException:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _db(self):
|
||||||
|
with self._mutex:
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
db = sqlite3.connect(self.path, timeout=5)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
with db:
|
||||||
|
yield db
|
||||||
|
except sqlite3.Error as error:
|
||||||
|
raise StateError("Could not read or save terminal session history.") from error
|
||||||
|
finally:
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record(row) -> dict:
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("Session not found for this VM.")
|
||||||
|
result = dict(row)
|
||||||
|
result["truncated"] = bool(result["truncated"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def create(self, vm_id: str, rows: int, cols: int) -> dict:
|
||||||
|
session_id = "session-" + uuid.uuid4().hex
|
||||||
|
now = time.time()
|
||||||
|
with self._db() as db:
|
||||||
|
number = db.execute("SELECT COUNT(*) FROM sessions WHERE vm_id=?", (vm_id,)).fetchone()[0] + 1
|
||||||
|
db.execute("""INSERT INTO sessions
|
||||||
|
(id, vm_id, name, username, status, created_at, updated_at, rows, cols)
|
||||||
|
VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""",
|
||||||
|
(session_id, vm_id, f"Session {number}", now, now, rows, cols))
|
||||||
|
return self.get(vm_id, session_id)
|
||||||
|
|
||||||
|
def list(self, vm_id: str) -> list[dict]:
|
||||||
|
with self._db() as db:
|
||||||
|
return [self._record(row) for row in db.execute(
|
||||||
|
"SELECT * FROM sessions WHERE vm_id=? ORDER BY created_at DESC, id DESC", (vm_id,),
|
||||||
|
)]
|
||||||
|
|
||||||
|
def get(self, vm_id: str, session_id: str) -> dict:
|
||||||
|
with self._db() as db:
|
||||||
|
return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
|
||||||
|
|
||||||
|
def activate(self, session_id: str, username: str) -> None:
|
||||||
|
with self._db() as db:
|
||||||
|
db.execute("UPDATE sessions SET status='active', username=?, updated_at=? WHERE id=?",
|
||||||
|
(username, time.time(), session_id))
|
||||||
|
|
||||||
|
def resize(self, session_id: str, rows: int, cols: int) -> None:
|
||||||
|
with self._db() as db:
|
||||||
|
db.execute("UPDATE sessions SET rows=?, cols=? WHERE id=?", (rows, cols, session_id))
|
||||||
|
|
||||||
|
def finish(self, session_id: str, status: str, exit_code: int | None = None, error: str | None = None) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with self._db() as db:
|
||||||
|
db.execute("""UPDATE sessions SET status=?, ended_at=?, updated_at=?, exit_code=?, error=?
|
||||||
|
WHERE id=? AND status IN ('starting', 'active')""", (status, now, now, exit_code, error, session_id))
|
||||||
|
|
||||||
|
def append(self, session_id: str, data: bytes) -> None:
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
with self._db() as db:
|
||||||
|
row = db.execute("SELECT output_end FROM sessions WHERE id=?", (session_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("Session not found.")
|
||||||
|
offset = row[0]
|
||||||
|
end = offset + len(data)
|
||||||
|
start = max(0, end - self.max_output)
|
||||||
|
db.execute("INSERT INTO output VALUES (?, ?, ?)", (session_id, offset, data))
|
||||||
|
db.execute("DELETE FROM output WHERE session_id=? AND offset + length(data)<=?", (session_id, start))
|
||||||
|
first = db.execute("SELECT offset, data FROM output WHERE session_id=? AND offset<?", (session_id, start)).fetchone()
|
||||||
|
if first is not None:
|
||||||
|
db.execute("UPDATE output SET offset=?, data=? WHERE session_id=? AND offset=?",
|
||||||
|
(start, first["data"][start - first["offset"]:], session_id, first["offset"]))
|
||||||
|
db.execute("UPDATE sessions SET output_start=?, output_end=?, truncated=?, updated_at=? WHERE id=?",
|
||||||
|
(start, end, int(start > 0), time.time(), session_id))
|
||||||
|
|
||||||
|
def read(self, vm_id: str, session_id: str, offset: int, limit: int = 65536) -> tuple[dict, int, bytes]:
|
||||||
|
with self._db() as db:
|
||||||
|
session = self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
|
||||||
|
if type(offset) is not int or not 0 <= offset <= session["output_end"]:
|
||||||
|
raise ValidationError("invalid terminal output offset")
|
||||||
|
start = max(offset, session["output_start"])
|
||||||
|
data = bytearray()
|
||||||
|
for row in db.execute("""SELECT offset, data FROM output
|
||||||
|
WHERE session_id=? AND offset + length(data)>? ORDER BY offset""", (session_id, start)):
|
||||||
|
part = row["data"][max(0, start - row["offset"]):]
|
||||||
|
data.extend(part[:limit - len(data)])
|
||||||
|
if len(data) >= limit:
|
||||||
|
break
|
||||||
|
return session, start, bytes(data)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._owner is not None:
|
||||||
|
os.close(self._owner)
|
||||||
|
self._owner = None
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Nest-owned live terminal sessions; browser attachments do not own guest shells."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from uvm.app import Application
|
||||||
|
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError, UvmError
|
||||||
|
from uvm.guest_terminal import GuestTerminal, validate_size
|
||||||
|
|
||||||
|
from .session_store import SessionStore
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist(function, *arguments):
|
||||||
|
# Cancellation cannot release the history-owner lock while a worker still
|
||||||
|
# has an outstanding write to that database.
|
||||||
|
task = asyncio.create_task(asyncio.to_thread(function, *arguments))
|
||||||
|
try:
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await task
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _LiveSession:
|
||||||
|
vm_id: str
|
||||||
|
terminal: GuestTerminal
|
||||||
|
changed: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
def __init__(self, application: Application) -> None:
|
||||||
|
self.application = application
|
||||||
|
self._store: SessionStore | None = None
|
||||||
|
self._live: dict[str, _LiveSession] = {}
|
||||||
|
self._pending: set[asyncio.Task] = set()
|
||||||
|
self._create_lock = asyncio.Lock()
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
|
def _history(self) -> SessionStore:
|
||||||
|
if self._closing:
|
||||||
|
raise ConflictError("Nest is shutting down.")
|
||||||
|
if self._store is None:
|
||||||
|
try:
|
||||||
|
self._store = SessionStore(Path(self.application.settings.base) / "nest" / "sessions.sqlite3")
|
||||||
|
except OSError as error:
|
||||||
|
raise StateError("Could not open private terminal history; run Nest with access to the VM registry.") from error
|
||||||
|
return self._store
|
||||||
|
|
||||||
|
async def _vm(self, vm_id: str) -> None:
|
||||||
|
# Exact IDs prevent a session from following a reused VM name or IP.
|
||||||
|
await asyncio.to_thread(self.application.state_store.get, vm_id)
|
||||||
|
|
||||||
|
async def list(self, vm_id: str) -> list[dict]:
|
||||||
|
await self._vm(vm_id)
|
||||||
|
return await asyncio.to_thread(self._history().list, vm_id)
|
||||||
|
|
||||||
|
async def get(self, vm_id: str, session_id: str) -> dict:
|
||||||
|
await self._vm(vm_id)
|
||||||
|
return await asyncio.to_thread(self._history().get, vm_id, session_id)
|
||||||
|
|
||||||
|
async def create(self, vm_id: str, rows: int, cols: int) -> dict:
|
||||||
|
validate_size(rows, cols)
|
||||||
|
await self._vm(vm_id)
|
||||||
|
history = self._history()
|
||||||
|
# A dropped HTTP request must not abandon a just-created guest connection.
|
||||||
|
task = asyncio.create_task(self._create(history, vm_id, rows, cols))
|
||||||
|
self._pending.add(task)
|
||||||
|
|
||||||
|
def completed(future):
|
||||||
|
self._pending.discard(future)
|
||||||
|
if not future.cancelled():
|
||||||
|
future.exception()
|
||||||
|
|
||||||
|
task.add_done_callback(completed)
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
|
||||||
|
async def _create(self, history: SessionStore, vm_id: str, rows: int, cols: int) -> dict:
|
||||||
|
async with self._create_lock:
|
||||||
|
if self._closing:
|
||||||
|
raise ConflictError("Nest is shutting down.")
|
||||||
|
if len(self._live) >= 32:
|
||||||
|
raise ConflictError("There are already 32 active terminal sessions. End a session before launching another.")
|
||||||
|
record = await asyncio.to_thread(history.create, vm_id, rows, cols)
|
||||||
|
terminal = None
|
||||||
|
try:
|
||||||
|
terminal, username = await asyncio.to_thread(self.application.lifecycle.open_terminal, vm_id, rows=rows, cols=cols)
|
||||||
|
await asyncio.to_thread(history.activate, record["id"], username)
|
||||||
|
live = _LiveSession(vm_id, terminal)
|
||||||
|
self._live[record["id"]] = live
|
||||||
|
live.task = asyncio.create_task(self._read(history, record["id"], live))
|
||||||
|
except BaseException as error:
|
||||||
|
if terminal is not None:
|
||||||
|
terminal.close()
|
||||||
|
message = str(error) if isinstance(error, UvmError) else "Could not launch the guest terminal."
|
||||||
|
await asyncio.to_thread(history.finish, record["id"], "failed", None, message)
|
||||||
|
raise
|
||||||
|
return await asyncio.to_thread(history.get, vm_id, record["id"])
|
||||||
|
|
||||||
|
async def _read(self, history: SessionStore, session_id: str, live: _LiveSession) -> None:
|
||||||
|
status, exit_code, error = "interrupted", None, None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await live.terminal.read()
|
||||||
|
if message["type"] == "exit":
|
||||||
|
status, exit_code = "closed", message["exit_code"]
|
||||||
|
break
|
||||||
|
await _persist(history.append, session_id, message["data"])
|
||||||
|
live.changed.set()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
error = "Nest stopped; this shell is no longer connected." if self._closing else "The guest terminal connection was interrupted."
|
||||||
|
except (UvmError, OSError) as exception:
|
||||||
|
error = str(exception) if isinstance(exception, UvmError) else "The guest terminal connection was interrupted."
|
||||||
|
finally:
|
||||||
|
live.terminal.close()
|
||||||
|
try:
|
||||||
|
await _persist(history.finish, session_id, status, exit_code, error)
|
||||||
|
finally:
|
||||||
|
live.changed.set()
|
||||||
|
self._live.pop(session_id, None)
|
||||||
|
|
||||||
|
async def read(self, vm_id: str, session_id: str, offset: int) -> tuple[dict, int, bytes]:
|
||||||
|
return await asyncio.to_thread(self._history().read, vm_id, session_id, offset)
|
||||||
|
|
||||||
|
async def wait(self, session_id: str) -> None:
|
||||||
|
live = self._live.get(session_id)
|
||||||
|
if live is None:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(live.changed.wait(), timeout=1)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
live.changed.clear()
|
||||||
|
|
||||||
|
async def _active(self, vm_id: str, session_id: str) -> _LiveSession:
|
||||||
|
live = self._live.get(session_id)
|
||||||
|
if live is None:
|
||||||
|
await self.get(vm_id, session_id)
|
||||||
|
raise ConflictError("This session has ended. Launch a new session to run commands.")
|
||||||
|
if live.vm_id != vm_id:
|
||||||
|
raise NotFoundError("Session not found for this VM.")
|
||||||
|
return live
|
||||||
|
|
||||||
|
async def write(self, vm_id: str, session_id: str, data: bytes) -> None:
|
||||||
|
live = await self._active(vm_id, session_id)
|
||||||
|
try:
|
||||||
|
await live.terminal.write(data)
|
||||||
|
except OSError as error:
|
||||||
|
live.task.cancel()
|
||||||
|
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
|
||||||
|
|
||||||
|
async def resize(self, vm_id: str, session_id: str, rows: int, cols: int) -> None:
|
||||||
|
validate_size(rows, cols)
|
||||||
|
live = await self._active(vm_id, session_id)
|
||||||
|
try:
|
||||||
|
await live.terminal.resize(rows, cols)
|
||||||
|
await asyncio.to_thread(self._history().resize, session_id, rows, cols)
|
||||||
|
except OSError as error:
|
||||||
|
live.task.cancel()
|
||||||
|
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
|
||||||
|
|
||||||
|
async def end(self, vm_id: str, session_id: str) -> dict:
|
||||||
|
record = await self.get(vm_id, session_id)
|
||||||
|
live = self._live.get(session_id)
|
||||||
|
if live is not None:
|
||||||
|
try:
|
||||||
|
await live.terminal.end()
|
||||||
|
await asyncio.wait_for(asyncio.shield(live.task), timeout=8)
|
||||||
|
except (OSError, UvmError):
|
||||||
|
live.task.cancel()
|
||||||
|
await asyncio.gather(live.task, return_exceptions=True)
|
||||||
|
record = await self.get(vm_id, session_id)
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
self._closing = True
|
||||||
|
await asyncio.gather(*self._pending, return_exceptions=True)
|
||||||
|
tasks = [live.task for live in self._live.values()]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if self._store is not None:
|
||||||
|
self._store.close()
|
||||||
+6
-3
@@ -5,22 +5,25 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "uvm-cli"
|
name = "uvm-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Local Firecracker microVM command-line manager"
|
description = "Nest workspace and local Firecracker microVM management"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1",
|
"fastapi>=0.115,<1",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"uvicorn>=0.30,<1",
|
"uvicorn>=0.30,<1",
|
||||||
|
"websockets>=13,<17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
test = ["httpx>=0.27,<1"]
|
test = ["httpx>=0.27,<1"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
nest = "nest.cli:main"
|
||||||
uvm = "uvm.cli:main"
|
uvm = "uvm.cli:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"]
|
packages = ["nest", "nest.dashboard", "uvm", "uvm.firecracker", "uvm.routers"]
|
||||||
|
include-package-data = false
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"]
|
"nest.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt", "static/vendor/*.md", "static/vendor/*/LICENSE", "static/vendor/*/lib/*.js", "static/vendor/*/css/*.css"]
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Isolated browser-test server: all VM and terminal operations are fakes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from nest.server import create_app
|
||||||
|
from tests.test_server import FakeLifecycle, fake_application
|
||||||
|
from tests.test_sessions import FakeTerminal
|
||||||
|
from uvm.errors import ConflictError, NotFoundError
|
||||||
|
from uvm.lifecycle import ListedVm
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTerminal(FakeTerminal):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.command = bytearray()
|
||||||
|
|
||||||
|
async def write(self, data):
|
||||||
|
await self.events.put({"type": "output", "data": data.replace(b"\r", b"\r\n")})
|
||||||
|
for byte in data:
|
||||||
|
if byte == 13:
|
||||||
|
await self.events.put({"type": "output", "data": b"result:" + bytes(self.command) + b"\r\nguest$ "})
|
||||||
|
self.command.clear()
|
||||||
|
elif byte == 3:
|
||||||
|
self.command.clear()
|
||||||
|
else:
|
||||||
|
self.command.append(byte)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserLifecycle(FakeLifecycle):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.vm.username = "worker"
|
||||||
|
self.records = {self.vm.id: self.vm, "vm-other": replace(self.vm, id="vm-other", name="second-vm", status="stopped")}
|
||||||
|
|
||||||
|
def get(self, vm_id):
|
||||||
|
try:
|
||||||
|
return self.records[vm_id]
|
||||||
|
except KeyError as error:
|
||||||
|
raise NotFoundError("VM not found") from error
|
||||||
|
|
||||||
|
def inspect(self, identifier):
|
||||||
|
for vm in self.records.values():
|
||||||
|
if identifier in {vm.id, vm.name, vm.guest_ip}:
|
||||||
|
return ListedVm(vm, vm.status, "enforced" if vm.status == "running" else "inactive")
|
||||||
|
raise NotFoundError("VM not found")
|
||||||
|
|
||||||
|
def list_vms(self):
|
||||||
|
return [self.inspect(vm_id) for vm_id in self.records]
|
||||||
|
|
||||||
|
def open_terminal(self, vm_id, **_size):
|
||||||
|
if self.get(vm_id).status != "running":
|
||||||
|
raise ConflictError("Start the machine first")
|
||||||
|
return BrowserTerminal(), "worker"
|
||||||
|
|
||||||
|
def install_guest_agent(self, vm_id):
|
||||||
|
return self.get(vm_id)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--base", type=Path, required=True)
|
||||||
|
parser.add_argument("--port", type=int, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
application = fake_application(api_token="browser-test-token")
|
||||||
|
application.settings.base = args.base
|
||||||
|
application.lifecycle = BrowserLifecycle()
|
||||||
|
application.state_store = SimpleNamespace(get=application.lifecycle.get)
|
||||||
|
api = create_app(application, host="127.0.0.1")
|
||||||
|
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
|
||||||
|
api.state.uvicorn_server = server
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Optional integration test: requires Playwright + Chromium and the project's test extra.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { mkdtemp, rm } = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-'));
|
||||||
|
const port = process.env.NEST_BROWSER_TEST_PORT || '18765';
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
|
||||||
|
['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port],
|
||||||
|
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let logs = '';
|
||||||
|
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
|
||||||
|
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
|
||||||
|
let browser;
|
||||||
|
let page;
|
||||||
|
try {
|
||||||
|
let ready = false;
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${origin}/health`);
|
||||||
|
if (response.ok) { ready = true; break; }
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
assert.ok(ready, `Fixture server did not start: ${logs}`);
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
|
||||||
|
const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value);
|
||||||
|
await page.goto(`${origin}/dashboard#vm/vm-test`);
|
||||||
|
await page.locator('#token-input').fill('browser-test-token');
|
||||||
|
await page.locator('#auth-btn').click();
|
||||||
|
await page.locator('#detail-actions [data-action="launch-session"]').click();
|
||||||
|
await connected();
|
||||||
|
const firstUrl = page.url();
|
||||||
|
assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/);
|
||||||
|
await page.keyboard.type('echo alpha');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await output('result:echo alpha');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await connected();
|
||||||
|
await output('result:echo alpha');
|
||||||
|
assert.equal(await page.locator('.session-item').count(), 1);
|
||||||
|
|
||||||
|
await page.locator('#detail-actions [data-action="launch-session"]').click();
|
||||||
|
await page.waitForURL((url) => url.href !== firstUrl);
|
||||||
|
await connected();
|
||||||
|
assert.equal(await page.locator('.session-item').count(), 2);
|
||||||
|
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
|
||||||
|
await page.keyboard.type('echo beta');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await output('result:echo beta');
|
||||||
|
await page.locator('.session-item').filter({ hasText: 'Session 1' }).click();
|
||||||
|
await connected();
|
||||||
|
await output('result:echo alpha');
|
||||||
|
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
|
||||||
|
|
||||||
|
await page.goto(`${origin}/dashboard#vm/vm-other/sessions`);
|
||||||
|
await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' });
|
||||||
|
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
|
||||||
|
assert.equal(await page.locator('.session-item').count(), 0);
|
||||||
|
assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled());
|
||||||
|
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha'));
|
||||||
|
|
||||||
|
await page.goto(firstUrl);
|
||||||
|
await connected();
|
||||||
|
await output('result:echo alpha');
|
||||||
|
await page.locator('#session-end').click();
|
||||||
|
await page.locator('#confirm-ok').click();
|
||||||
|
await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed'));
|
||||||
|
await page.reload();
|
||||||
|
await output('result:echo alpha');
|
||||||
|
assert.ok(await page.locator('#session-end').isDisabled());
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
|
||||||
|
assert.ok(fits, 'mobile layout overflows the viewport');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
|
||||||
|
console.log('Browser sessions passed: launch, terminal input, reload/reconnect, multiple sessions, VM isolation, closed history, and mobile layout.');
|
||||||
|
} catch (error) {
|
||||||
|
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
|
||||||
|
console.error(logs);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
server.kill('SIGTERM');
|
||||||
|
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Opt-in checks against an already-provisioned disposable VM on an isolated host."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("UVM_EXEC_INTEGRATION") == "1", "set UVM_EXEC_INTEGRATION=1 on an isolated VM host")
|
||||||
|
class GuestExecIntegrationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.api_url = os.environ.get("UVM_API_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
cls.token = os.environ["UVM_API_TOKEN"]
|
||||||
|
cls.vm_id = os.environ["UVM_EXEC_VM_ID"]
|
||||||
|
cls.path = f"/vms/{quote(cls.vm_id, safe='')}"
|
||||||
|
vm = cls.request("GET", cls.path)
|
||||||
|
if (vm["id"] != cls.vm_id or vm["observed_status"] != "running"
|
||||||
|
or vm["username"] == "root" or vm["ports"] != []):
|
||||||
|
raise RuntimeError("provide a running disposable VM with a non-root login, installed agent, and empty TCP allowlist")
|
||||||
|
cls.username = vm["username"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def request(cls, method, path, body=None):
|
||||||
|
request = Request(
|
||||||
|
cls.api_url + path, method=method,
|
||||||
|
data=json.dumps(body).encode("utf-8") if body is not None else None,
|
||||||
|
headers={"X-UVM-Token": cls.token, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
def execute(self, command, *, sudo=False, timeout=10):
|
||||||
|
return self.request("POST", self.path + "/exec", {
|
||||||
|
"command": command, "sudo": sudo, "timeout_seconds": timeout,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_user_and_root_execution_work_with_all_tcp_ports_closed(self) -> None:
|
||||||
|
ordinary = self.execute("id -u; whoami")
|
||||||
|
self.assertEqual(ordinary["exit_code"], 0)
|
||||||
|
uid, username = ordinary["stdout"].splitlines()
|
||||||
|
self.assertNotEqual(uid, "0")
|
||||||
|
self.assertEqual(username, self.username)
|
||||||
|
elevated = self.execute("id -u", sudo=True)
|
||||||
|
self.assertEqual(elevated["exit_code"], 0)
|
||||||
|
self.assertEqual(elevated["stdout"], "0\n")
|
||||||
|
|
||||||
|
def test_multiline_script_returns_output_and_nonzero_status(self) -> None:
|
||||||
|
result = self.execute("printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7")
|
||||||
|
self.assertEqual(result, {
|
||||||
|
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_timeout_kills_a_descendant_that_started_a_new_session(self) -> None:
|
||||||
|
result = self.execute("""python3 - <<'PY'
|
||||||
|
import os, time
|
||||||
|
child = os.fork()
|
||||||
|
if child == 0:
|
||||||
|
os.setsid()
|
||||||
|
else:
|
||||||
|
print(child, flush=True)
|
||||||
|
time.sleep(30)
|
||||||
|
PY""", timeout=2)
|
||||||
|
self.assertTrue(result["timed_out"])
|
||||||
|
self.assertIsNone(result["exit_code"])
|
||||||
|
child = int(result["stdout"].strip())
|
||||||
|
checked = self.execute(f"""python3 - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
try:
|
||||||
|
state = Path('/proc/{child}/stat').read_text().rsplit(') ', 1)[1].split()[0]
|
||||||
|
except FileNotFoundError:
|
||||||
|
state = 'gone'
|
||||||
|
assert state in ('gone', 'Z'), state
|
||||||
|
PY""")
|
||||||
|
self.assertEqual(checked["exit_code"], 0, checked["stderr"])
|
||||||
|
|
||||||
|
def test_output_is_bounded_while_both_pipes_are_drained(self) -> None:
|
||||||
|
result = self.execute("""python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
for _ in range(32):
|
||||||
|
os.write(1, b'o' * 65536)
|
||||||
|
os.write(2, b'e' * 65536)
|
||||||
|
PY""")
|
||||||
|
self.assertEqual(result["exit_code"], 0)
|
||||||
|
self.assertTrue(result["truncated"])
|
||||||
|
self.assertEqual(len(result["stdout"]) + len(result["stderr"]), 1024 * 1024)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -44,7 +46,12 @@ class FirecrackerConfigTests(unittest.TestCase):
|
|||||||
arguments = config["boot-source"]["boot_args"].split()
|
arguments = config["boot-source"]["boot_args"].split()
|
||||||
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.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"})
|
||||||
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(
|
||||||
@@ -72,6 +79,7 @@ class FirecrackerConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual(config["drives"][0]["path_on_host"], "/vm/rootfs.ext4")
|
self.assertEqual(config["drives"][0]["path_on_host"], "/vm/rootfs.ext4")
|
||||||
self.assertIn("ip=10.42.0.2::10.42.0.1", config["boot-source"]["boot_args"])
|
self.assertIn("ip=10.42.0.2::10.42.0.1", config["boot-source"]["boot_args"])
|
||||||
self.assertNotIn("password", str(config))
|
self.assertNotIn("password", str(config))
|
||||||
|
self.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"})
|
||||||
|
|
||||||
def test_configures_firecracker_in_required_order_before_start(self) -> None:
|
def test_configures_firecracker_in_required_order_before_start(self) -> None:
|
||||||
vm = VmRecord(
|
vm = VmRecord(
|
||||||
@@ -101,6 +109,8 @@ class FirecrackerConfigTests(unittest.TestCase):
|
|||||||
"/boot-source",
|
"/boot-source",
|
||||||
"/drives/rootfs",
|
"/drives/rootfs",
|
||||||
"/network-interfaces/eth0",
|
"/network-interfaces/eth0",
|
||||||
|
"/vsock",
|
||||||
|
"/entropy",
|
||||||
"/actions",
|
"/actions",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -110,6 +120,62 @@ 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": {},
|
||||||
|
"vsock": {"guest_cid": 3, "uds_path": "/tmp/vsock.sock"},
|
||||||
|
}
|
||||||
|
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_missing_invalid_or_failed_vsock_configuration_prevents_guest_boot(self) -> None:
|
||||||
|
config = {
|
||||||
|
"machine-config": {"vcpu_count": 1, "mem_size_mib": 512}, "boot-source": {},
|
||||||
|
"drives": [{}], "network-interfaces": [{}], "entropy": {},
|
||||||
|
}
|
||||||
|
for vsock in (None, {}, {"guest_cid": True, "uds_path": "/tmp/vsock.sock"},
|
||||||
|
{"guest_cid": 2, "uds_path": "/tmp/vsock.sock"}, {"guest_cid": 3, "uds_path": ""}):
|
||||||
|
with self.subTest(vsock=vsock):
|
||||||
|
client = RecordingFirecrackerClient()
|
||||||
|
config["vsock"] = vsock
|
||||||
|
with self.assertRaisesRegex(FirecrackerError, "vsock"):
|
||||||
|
client.configure_and_start(config)
|
||||||
|
self.assertEqual(client.calls, [])
|
||||||
|
config["vsock"] = {"guest_cid": 3, "uds_path": "/tmp/vsock.sock"}
|
||||||
|
client = RecordingFirecrackerClient()
|
||||||
|
|
||||||
|
def request(method, path, body):
|
||||||
|
client.calls.append((method, path, body))
|
||||||
|
if path == "/vsock":
|
||||||
|
raise FirecrackerError("vsock unavailable")
|
||||||
|
|
||||||
|
with patch.object(client, "_request", side_effect=request), self.assertRaises(FirecrackerError):
|
||||||
|
client.configure_and_start(config)
|
||||||
|
self.assertNotIn("/actions", [path for _method, path, _body in 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"),
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from uvm.config import Settings
|
||||||
|
from uvm.errors import UvmError
|
||||||
|
from uvm.images import GUEST_AGENT_PREPARE, ImageStore, guest_agent_files
|
||||||
|
from uvm.system import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
|
class GuestRuntimeBootstrapTests(unittest.TestCase):
|
||||||
|
def test_runtime_install_is_guest_only_skipped_when_ready_and_stops_on_apt_failure(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
python = directory / "python3"
|
||||||
|
apt = directory / "apt-get"
|
||||||
|
ready = directory / "ready"
|
||||||
|
python.write_text(
|
||||||
|
f"#!{sys.executable}\nimport os, sys\nfrom pathlib import Path\n"
|
||||||
|
"sys.exit(0 if Path(os.environ['READY']).exists() else 1)\n", encoding="utf-8",
|
||||||
|
)
|
||||||
|
apt.write_text(
|
||||||
|
f"#!{sys.executable}\nimport json, os, sys\nfrom pathlib import Path\n"
|
||||||
|
"print(json.dumps(sys.argv[1:]))\n"
|
||||||
|
"if sys.argv[-1] == 'update' and os.environ['FAIL_UPDATE'] == '1': sys.exit(100)\n"
|
||||||
|
"if sys.argv[-1] == 'python3': Path(os.environ['READY']).touch()\n", encoding="utf-8",
|
||||||
|
)
|
||||||
|
python.chmod(0o755)
|
||||||
|
apt.chmod(0o755)
|
||||||
|
script = GUEST_AGENT_PREPARE.replace("/usr/bin/python3", str(python)).replace("/usr/bin/apt-get", str(apt))
|
||||||
|
for installed, fail in ((True, False), (False, False), (False, True)):
|
||||||
|
with self.subTest(installed=installed, fail=fail):
|
||||||
|
ready.unlink(missing_ok=True)
|
||||||
|
if installed:
|
||||||
|
ready.touch()
|
||||||
|
result = CommandRunner(emit=None).run((
|
||||||
|
"env", f"READY={ready}", f"FAIL_UPDATE={int(fail)}", "/bin/sh", "-c", script,
|
||||||
|
), check=False, capture=True)
|
||||||
|
calls = [json.loads(line) for line in result.stdout.splitlines()]
|
||||||
|
self.assertEqual(result.returncode, 100 if fail else 0)
|
||||||
|
self.assertEqual(len(calls), 0 if installed else 1 if fail else 2)
|
||||||
|
if calls:
|
||||||
|
self.assertEqual(calls[0][-1], "update")
|
||||||
|
self.assertIn("APT::Update::Error-Mode=any", calls[0])
|
||||||
|
if not installed and not fail:
|
||||||
|
self.assertEqual(calls[-1][-2:], ["install", "python3"])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
|
||||||
|
"e2fsprogs and coreutils are required for offline guest agent checks")
|
||||||
|
class GuestAgentImageTests(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.images = ImageStore(self.settings, self.runner)
|
||||||
|
self.retained = {
|
||||||
|
"/etc/shadow": "worker:$6$retained-hash:1:0:99999:7:::\n",
|
||||||
|
"/etc/ssh/ssh_host_ed25519_key": "retained private SSH key\n",
|
||||||
|
"/home/worker/data": "retained application data\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_disk(self, marker="uvm-ubuntu-jammy-v3\n") -> None:
|
||||||
|
root = self.base / "tree"
|
||||||
|
if root.exists():
|
||||||
|
shutil.rmtree(root)
|
||||||
|
payloads = {
|
||||||
|
**self.retained,
|
||||||
|
"/usr/lib/systemd/systemd": "systemd fixture", "/usr/bin/apt-get": "apt fixture",
|
||||||
|
}
|
||||||
|
if marker is not None:
|
||||||
|
payloads["/etc/uvm-image"] = marker
|
||||||
|
for path, content in payloads.items():
|
||||||
|
target = root / path.lstrip("/")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(content, encoding="utf-8")
|
||||||
|
target.chmod(0o644)
|
||||||
|
for directory in (root, *(path for path in root.rglob("*") if path.is_dir())):
|
||||||
|
directory.chmod(0o755)
|
||||||
|
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)
|
||||||
|
# mkfs -d retains the creating user's ownership. Model the root-built
|
||||||
|
# guest while allowing this test itself to run without root.
|
||||||
|
paths = {"/"}
|
||||||
|
for path in payloads:
|
||||||
|
paths.add(path)
|
||||||
|
paths.update(str(parent) for parent in Path(path).parents)
|
||||||
|
commands = "\n".join(f"set_inode_field {path} {field} 0" for path in paths for field in ("uid", "gid"))
|
||||||
|
self.runner.run(("debugfs", "-w", self.settings.rootfs_image), input_text=commands + "\n", capture=True)
|
||||||
|
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
|
||||||
|
self.disk.chmod(0o600)
|
||||||
|
|
||||||
|
def guest(self, command: str, *, disk: Path | None = None) -> str:
|
||||||
|
return self.runner.run(("debugfs", "-R", command, disk or 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 replace_guest_file(self, path: str, contents: str, *, disk: Path | None = None) -> None:
|
||||||
|
source = self.base / "edited"
|
||||||
|
source.write_text(contents, encoding="utf-8")
|
||||||
|
source.chmod(0o644)
|
||||||
|
self.runner.run(("debugfs", "-w", disk or self.disk), input_text=(
|
||||||
|
f"rm {path}\nwrite {source} {path}\nset_inode_field {path} uid 0\nset_inode_field {path} gid 0\n"
|
||||||
|
), capture=True)
|
||||||
|
|
||||||
|
def test_existing_guest_is_upgraded_atomically_with_private_identity_and_retained_data(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
|
||||||
|
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
|
||||||
|
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"})
|
||||||
|
self.assertRegex(self.guest("stat /etc/uvm-agent.json"), r"Mode:\s+0?600")
|
||||||
|
self.assertRegex(self.guest("stat /etc/uvm-agent.json"), r"User:\s+0\s+Group:\s+0")
|
||||||
|
for path, content in guest_agent_files().items():
|
||||||
|
self.assertEqual(self.guest(f"cat {path}"), content.decode("utf-8"))
|
||||||
|
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
|
||||||
|
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
|
||||||
|
self.assertEqual(self.guest("cat /etc/uvm-image"), "uvm-ubuntu-jammy-v3\n")
|
||||||
|
for path, content in self.retained.items():
|
||||||
|
self.assertEqual(self.guest(f"cat {path}"), content)
|
||||||
|
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template)
|
||||||
|
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
|
||||||
|
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
self.assertEqual(self.guest("cat /home/worker/data"), self.retained["/home/worker/data"])
|
||||||
|
|
||||||
|
def test_all_compatible_images_get_vsock_agent_setup_without_opt_in(self) -> None:
|
||||||
|
for marker in ("uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v3\n", "uvm-ubuntu-jammy-v4\n",
|
||||||
|
"uvm-ubuntu-jammy-v5\n", "custom-image\n", None):
|
||||||
|
with self.subTest(marker=marker):
|
||||||
|
self.make_disk(marker)
|
||||||
|
template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
|
||||||
|
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
|
||||||
|
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"})
|
||||||
|
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
|
||||||
|
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
|
||||||
|
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
|
||||||
|
for path, content in self.retained.items():
|
||||||
|
self.assertEqual(self.guest(f"cat {path}"), content)
|
||||||
|
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template)
|
||||||
|
|
||||||
|
def test_current_agent_setup_is_read_only_on_later_boots_and_tracks_vm_identity(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
|
||||||
|
before = self.snapshot()
|
||||||
|
with patch.object(self.images, "_copy_sparse") as copy:
|
||||||
|
self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
|
||||||
|
copy.assert_not_called()
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-recreated", "worker"))
|
||||||
|
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-recreated", "username": "worker"})
|
||||||
|
|
||||||
|
def test_default_setup_reenables_an_existing_agent_service(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
|
||||||
|
self.runner.run(("debugfs", "-w", "-R", "rm /etc/systemd/system/multi-user.target.wants/uvm-agent.service", self.disk), capture=True)
|
||||||
|
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
|
||||||
|
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
|
||||||
|
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
|
||||||
|
|
||||||
|
def test_incompatible_images_fail_default_setup_without_changing_the_disk(self) -> None:
|
||||||
|
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
|
||||||
|
with self.subTest(binary=binary):
|
||||||
|
self.make_disk(marker=None)
|
||||||
|
self.runner.run(("debugfs", "-w", "-R", f"rm {binary}", self.disk), capture=True)
|
||||||
|
before = self.snapshot()
|
||||||
|
with self.assertRaisesRegex(UvmError, "automatic vsock guest-agent setup requires a systemd/apt image"):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
|
||||||
|
def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
self.runner.run(("debugfs", "-w", "-R", "rm /etc/uvm-agent-files.json", self.disk), capture=True)
|
||||||
|
old_payload = "previous packaged agent\n"
|
||||||
|
self.replace_guest_file("/usr/lib/uvm/agent.py", old_payload)
|
||||||
|
before = self.snapshot()
|
||||||
|
with self.assertRaisesRegex(UvmError, "customized guest agent"):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
with patch("uvm.images._V4_TEMPLATE_AGENT_SHA256", hashlib.sha256(old_payload.encode()).hexdigest()):
|
||||||
|
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
|
||||||
|
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
|
||||||
|
|
||||||
|
def test_agent_updates_use_recorded_hashes_but_preserve_guest_customizations(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
updated = guest_agent_files()
|
||||||
|
updated["/usr/lib/uvm/agent.py"] += b"\n# next packaged revision\n"
|
||||||
|
with patch("uvm.images.guest_agent_files", return_value=updated):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
|
||||||
|
self.assertTrue(self.guest("cat /usr/lib/uvm/agent.py").endswith("# next packaged revision\n"))
|
||||||
|
self.replace_guest_file("/usr/lib/uvm/agent.py", "guest customization\n")
|
||||||
|
before = self.snapshot()
|
||||||
|
with self.assertRaisesRegex(UvmError, "customized guest agent file"):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
|
||||||
|
|
||||||
|
def test_failure_during_provisioning_keeps_the_original_disk(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
before = self.snapshot()
|
||||||
|
write = self.images._write_guest_file
|
||||||
|
|
||||||
|
def fail(candidate, path, source):
|
||||||
|
self.assertNotEqual(candidate, self.disk)
|
||||||
|
write(candidate, path, source)
|
||||||
|
raise UvmError("simulated write failure")
|
||||||
|
|
||||||
|
with patch.object(self.images, "_write_guest_file", side_effect=fail), self.assertRaisesRegex(UvmError, "write failure"):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
|
||||||
|
|
||||||
|
def test_guest_edits_revealed_by_journal_replay_are_checked_before_replacement(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
before = self.snapshot()
|
||||||
|
run = self.runner.run
|
||||||
|
|
||||||
|
def replay(command, **options):
|
||||||
|
result = run(command, **options)
|
||||||
|
if command[:3] == ("e2fsck", "-f", "-p"):
|
||||||
|
self.replace_guest_file("/usr/lib/uvm/agent.py", "journal-committed guest edit\n", disk=command[-1])
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(self.runner, "run", side_effect=replay), self.assertRaisesRegex(UvmError, "customized"):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
|
||||||
|
def test_unsafe_guest_directory_and_shared_disk_are_rejected(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
with self.assertRaisesRegex(UvmError, "unsafe"):
|
||||||
|
self.images.provision_guest_agent(self.settings.rootfs_image, "vm-test", "worker", install=True)
|
||||||
|
self.runner.run(("debugfs", "-w", "-R", "set_inode_field /etc mode 040777", self.disk), capture=True)
|
||||||
|
before = self.snapshot()
|
||||||
|
with self.assertRaisesRegex(UvmError, "unsafe guest agent directory"):
|
||||||
|
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import shlex
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from uvm.errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
|
||||||
|
from uvm import guest_agent as agent
|
||||||
|
from uvm.guest_exec import GuestSession, validate_exec
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScope:
|
||||||
|
"""Use real subprocesses/process groups without touching the host's cgroups."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.path = Path(self.temporary.name)
|
||||||
|
(self.path / "cgroup.procs").touch()
|
||||||
|
self.kills = 0
|
||||||
|
self.joined_pid = None
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
self.kills += 1
|
||||||
|
self.joined_pid = (self.path / "cgroup.procs").read_text()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class GuestCommandTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.account = pwd.getpwuid(os.geteuid())
|
||||||
|
self.scope = FakeScope()
|
||||||
|
self.addCleanup(self.scope.temporary.cleanup)
|
||||||
|
|
||||||
|
def run_command(self, command, timeout=5):
|
||||||
|
return agent.run_command(command, self.account.pw_name, timeout, scope_factory=lambda: self.scope)
|
||||||
|
|
||||||
|
def test_multiline_script_has_separate_output_exit_status_and_no_stdin(self) -> None:
|
||||||
|
result = self.run_command("printf 'hello\\n'\nprintf 'problem\\n' >&2\nread input || exit 7")
|
||||||
|
self.assertEqual(result, {
|
||||||
|
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
|
||||||
|
})
|
||||||
|
self.assertTrue(self.scope.joined_pid.isdigit())
|
||||||
|
self.assertGreater(self.scope.kills, 0)
|
||||||
|
|
||||||
|
def test_command_enters_scope_before_running_and_uses_account_home_and_environment(self) -> None:
|
||||||
|
with patch.dict(os.environ, {"UVM_API_TOKEN": "must-not-reach-guest-command"}):
|
||||||
|
result = self.run_command('printf "%s\\n" "$$" "$HOME" "$USER" "$UVM_API_TOKEN"; pwd; id -u')
|
||||||
|
self.assertEqual(result["exit_code"], 0)
|
||||||
|
self.assertEqual(result["stdout"].splitlines(), [
|
||||||
|
self.scope.joined_pid, self.account.pw_dir, self.account.pw_name, "", self.account.pw_dir, str(os.geteuid()),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_timeout_kills_the_shell_and_children_that_ignore_sigterm(self) -> None:
|
||||||
|
start = time.monotonic()
|
||||||
|
result = self.run_command("trap '' TERM; sleep 30 & printf '%s\\n' \"$!\"; wait", timeout=0.25)
|
||||||
|
self.assertTrue(result["timed_out"])
|
||||||
|
self.assertIsNone(result["exit_code"])
|
||||||
|
self.assertLess(time.monotonic() - start, 4)
|
||||||
|
child = int(result["stdout"])
|
||||||
|
try:
|
||||||
|
state = Path(f"/proc/{child}/stat").read_text().split(") ", 1)[1].split()[0]
|
||||||
|
except FileNotFoundError:
|
||||||
|
state = "gone"
|
||||||
|
self.assertIn(state, {"gone", "Z"})
|
||||||
|
|
||||||
|
def test_large_stdout_and_stderr_are_drained_without_unbounded_capture(self) -> None:
|
||||||
|
script = "import os; [(os.write(1, b'o'*65536), os.write(2, b'e'*65536)) for _ in range(32)]"
|
||||||
|
result = self.run_command(f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}")
|
||||||
|
self.assertEqual(result["exit_code"], 0)
|
||||||
|
self.assertEqual(len(result["stdout"]) + len(result["stderr"]), agent.MAX_OUTPUT_BYTES)
|
||||||
|
self.assertTrue(result["truncated"])
|
||||||
|
self.assertFalse(result["timed_out"])
|
||||||
|
|
||||||
|
def test_non_utf8_output_is_replaced_without_losing_the_exit_code(self) -> None:
|
||||||
|
result = self.run_command("printf '\\377\\000'; exit 255")
|
||||||
|
self.assertEqual(result["stdout"], "\ufffd\0")
|
||||||
|
self.assertEqual(result["exit_code"], 255)
|
||||||
|
|
||||||
|
def test_cgroup_cleanup_failure_is_an_unknown_outcome_and_still_reaps_the_process(self) -> None:
|
||||||
|
with patch.object(self.scope, "kill", side_effect=OSError("cannot kill scope")):
|
||||||
|
with self.assertRaises(agent.ExecutionFailure) as raised:
|
||||||
|
self.run_command("exit 0")
|
||||||
|
self.assertTrue(raised.exception.outcome_unknown)
|
||||||
|
|
||||||
|
def test_privilege_drop_initializes_groups_before_uid_and_exec(self) -> None:
|
||||||
|
account = Mock(pw_name="worker", pw_uid=1001, pw_gid=1002, pw_dir="/home/worker")
|
||||||
|
events = []
|
||||||
|
with (
|
||||||
|
patch.object(agent.pwd, "getpwnam", return_value=account),
|
||||||
|
patch.object(agent.os, "geteuid", return_value=0),
|
||||||
|
patch.object(agent.os, "initgroups", side_effect=lambda *args: events.append(("groups", args))),
|
||||||
|
patch.object(agent.os, "setgid", side_effect=lambda value: events.append(("gid", value))),
|
||||||
|
patch.object(agent.os, "setuid", side_effect=lambda value: events.append(("uid", value))),
|
||||||
|
patch.object(agent.os, "chdir") as chdir,
|
||||||
|
patch.object(agent.os, "umask"),
|
||||||
|
patch.object(agent.os, "execve") as execute,
|
||||||
|
):
|
||||||
|
agent.child_main(self.scope.path, "worker", "printf x > file")
|
||||||
|
self.assertEqual(events, [("groups", ("worker", 1002)), ("gid", 1002), ("uid", 1001)])
|
||||||
|
chdir.assert_called_once_with("/home/worker")
|
||||||
|
self.assertEqual(execute.call_args.args[:2], ("/bin/sh", ["sh", "-c", "printf x > file"]))
|
||||||
|
self.assertEqual(execute.call_args.args[2]["HOME"], "/home/worker")
|
||||||
|
|
||||||
|
|
||||||
|
class GuestProtocolTests(unittest.TestCase):
|
||||||
|
def test_validates_byte_limits_types_finite_deadlines_and_text_before_transport(self) -> None:
|
||||||
|
for command, sudo, timeout in (
|
||||||
|
("", False, 1), (" \n", False, 1), ("a\0b", False, 1), ("\ud800", False, 1),
|
||||||
|
("x" * (agent.MAX_COMMAND_BYTES + 1), False, 1),
|
||||||
|
("\u00e9" * agent.MAX_COMMAND_BYTES, False, 1),
|
||||||
|
("id", "false", 1), ("id", 0, 1), ("id", False, True),
|
||||||
|
("id", False, 0), ("id", False, -1), ("id", False, float("nan")),
|
||||||
|
("id", False, float("inf")), ("id", False, 3601), ("id", False, "60"),
|
||||||
|
):
|
||||||
|
with self.subTest(command=command[:20], sudo=sudo, timeout=timeout), self.assertRaises(ValidationError):
|
||||||
|
validate_exec(command, sudo, timeout)
|
||||||
|
validate_exec("id", False, 0.1)
|
||||||
|
|
||||||
|
def test_oversized_frame_is_rejected_before_reading_a_body(self) -> None:
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
with left, right:
|
||||||
|
left.sendall(struct.pack("!I", agent.MAX_REQUEST_BYTES + 1))
|
||||||
|
with self.assertRaises(agent.ProtocolError):
|
||||||
|
agent.receive_frame(right, agent.MAX_REQUEST_BYTES, time.monotonic() + 1)
|
||||||
|
|
||||||
|
def test_read_deadline_applies_to_the_whole_frame(self) -> None:
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
with left, right:
|
||||||
|
left.sendall(struct.pack("!I", 5) + b"{")
|
||||||
|
start = time.monotonic()
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
agent.receive_frame(right, 1024, time.monotonic() + 0.05)
|
||||||
|
self.assertLess(time.monotonic() - start, 0.5)
|
||||||
|
|
||||||
|
def test_agent_maps_sudo_to_root_and_rejects_bad_requests_without_launching(self) -> None:
|
||||||
|
config = {"vm_id": "vm-test", "username": "worker"}
|
||||||
|
for sudo in (False, True, "true"):
|
||||||
|
with self.subTest(sudo=sudo):
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
result = {"exit_code": 0, "stdout": "", "stderr": "", "timed_out": False, "truncated": False}
|
||||||
|
runner = Mock(return_value=result)
|
||||||
|
thread = threading.Thread(target=agent.serve_connection, args=(right, config), kwargs={"runner": runner})
|
||||||
|
thread.start()
|
||||||
|
with left:
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
self.assertEqual(agent.receive_frame(left, 4096, deadline), {"version": 1, **config})
|
||||||
|
agent.send_frame(left, {"version": 1, "command": "id", "sudo": sudo, "timeout_seconds": 2}, 4096, deadline)
|
||||||
|
reply = agent.receive_frame(left, 4096, deadline)
|
||||||
|
thread.join(2)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
if type(sudo) is bool:
|
||||||
|
self.assertEqual(reply, result)
|
||||||
|
runner.assert_called_once_with("id", "root" if sudo else "worker", 2)
|
||||||
|
else:
|
||||||
|
self.assertFalse(reply["outcome_unknown"])
|
||||||
|
runner.assert_not_called()
|
||||||
|
|
||||||
|
def test_guest_local_connections_are_rejected_before_a_worker_is_started(self) -> None:
|
||||||
|
connection = Mock()
|
||||||
|
listener = Mock()
|
||||||
|
listener.__enter__ = Mock(return_value=listener)
|
||||||
|
listener.__exit__ = Mock(return_value=False)
|
||||||
|
listener.accept.side_effect = [(connection, (3, 9999)), KeyboardInterrupt()]
|
||||||
|
with (
|
||||||
|
patch.object(agent.sys, "argv", ["agent.py"]), patch.object(agent.os, "geteuid", return_value=0),
|
||||||
|
patch.object(agent, "load_config", return_value={"vm_id": "vm-test", "username": "root"}),
|
||||||
|
patch.object(agent.socket, "socket", return_value=listener), patch.object(agent.threading, "Thread") as worker,
|
||||||
|
self.assertRaises(KeyboardInterrupt),
|
||||||
|
):
|
||||||
|
agent.main()
|
||||||
|
connection.close.assert_called_once()
|
||||||
|
worker.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class GuestTransportTests(unittest.TestCase):
|
||||||
|
@contextmanager
|
||||||
|
def firecracker(self, handler):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
path = Path(temporary) / "vsock.sock"
|
||||||
|
errors = []
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
|
||||||
|
listener.bind(str(path))
|
||||||
|
listener.listen(1)
|
||||||
|
listener.settimeout(3)
|
||||||
|
|
||||||
|
def serve():
|
||||||
|
try:
|
||||||
|
connection, _peer = listener.accept()
|
||||||
|
with connection:
|
||||||
|
handler(connection)
|
||||||
|
except (BrokenPipeError, EOFError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
except BaseException as error:
|
||||||
|
errors.append(error)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=serve, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield path
|
||||||
|
finally:
|
||||||
|
thread.join(4)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
if errors:
|
||||||
|
raise errors[0]
|
||||||
|
|
||||||
|
def handshake(self, connection, **changes):
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
self.assertEqual(agent.receive_exact(connection, len(b"CONNECT 1070\n"), deadline), b"CONNECT 1070\n")
|
||||||
|
# The acknowledgement and JSON frame can be coalesced on the wire.
|
||||||
|
connection.sendall(b"OK 1234\n")
|
||||||
|
agent.send_frame(connection, {"version": 1, "vm_id": "vm-test", "username": "worker", **changes}, 4096, deadline)
|
||||||
|
|
||||||
|
def test_round_trip_preserves_command_text_and_a_nonzero_exit(self) -> None:
|
||||||
|
command = "printf '%s\\n' 'quotes; $(literal)'\nexit 7"
|
||||||
|
expected = {"exit_code": 7, "stdout": "quotes; $(literal)\n", "stderr": "", "timed_out": False, "truncated": False}
|
||||||
|
|
||||||
|
def handle(connection):
|
||||||
|
self.handshake(connection)
|
||||||
|
request = agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2)
|
||||||
|
self.assertEqual(request, {"version": 1, "command": command, "sudo": True, "timeout_seconds": 3})
|
||||||
|
agent.send_frame(connection, expected, agent.MAX_RESPONSE_BYTES, time.monotonic() + 2)
|
||||||
|
|
||||||
|
with self.firecracker(handle) as path:
|
||||||
|
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
|
||||||
|
try:
|
||||||
|
result = session.execute(command, sudo=True, timeout_seconds=3)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
self.assertEqual(result.exit_code, 7)
|
||||||
|
self.assertEqual(result.stdout, expected["stdout"])
|
||||||
|
|
||||||
|
def test_wrong_vm_or_process_identity_prevents_command_submission(self) -> None:
|
||||||
|
for changes, pid in (({"vm_id": "vm-other"}, os.getpid()), ({"version": 2}, os.getpid()),
|
||||||
|
({"username": "root"}, os.getpid()), ({}, os.getpid() + 100000)):
|
||||||
|
with self.subTest(changes=changes, pid=pid):
|
||||||
|
def handle(connection):
|
||||||
|
self.handshake(connection, **changes)
|
||||||
|
self.assertEqual(connection.recv(1), b"")
|
||||||
|
|
||||||
|
with self.firecracker(handle) as path, self.assertRaises(GuestAgentUnavailableError):
|
||||||
|
GuestSession.connect(path, "vm-test", "worker", pid)
|
||||||
|
|
||||||
|
def test_connection_loss_after_submission_has_unknown_outcome_without_retry(self) -> None:
|
||||||
|
submissions = []
|
||||||
|
|
||||||
|
def handle(connection):
|
||||||
|
self.handshake(connection)
|
||||||
|
submissions.append(agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2))
|
||||||
|
|
||||||
|
with self.firecracker(handle) as path:
|
||||||
|
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
|
||||||
|
with self.assertRaises(GuestExecutionError) as raised:
|
||||||
|
try:
|
||||||
|
session.execute("touch example", sudo=False, timeout_seconds=1)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
self.assertTrue(raised.exception.outcome_unknown)
|
||||||
|
self.assertEqual(len(submissions), 1)
|
||||||
|
|
||||||
|
def test_malformed_result_is_not_reported_as_success(self) -> None:
|
||||||
|
for result in ({"exit_code": True, "stdout": "", "stderr": "", "timed_out": False, "truncated": False},
|
||||||
|
{"error": "bad", "outcome_unknown": "false"}, {"error": "\ud800", "outcome_unknown": False},
|
||||||
|
{"exit_code": 0}):
|
||||||
|
with self.subTest(result=result):
|
||||||
|
def handle(connection):
|
||||||
|
self.handshake(connection)
|
||||||
|
agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2)
|
||||||
|
agent.send_frame(connection, result, agent.MAX_RESPONSE_BYTES, time.monotonic() + 2)
|
||||||
|
|
||||||
|
with self.firecracker(handle) as path:
|
||||||
|
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
|
||||||
|
with self.assertRaises(GuestExecutionError) as raised:
|
||||||
|
try:
|
||||||
|
session.execute("id", sudo=False, timeout_seconds=1)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
self.assertTrue(raised.exception.outcome_unknown)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from tests.test_lifecycle import LifecycleTestCase
|
||||||
|
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError
|
||||||
|
from uvm.guest_exec import ExecResult
|
||||||
|
|
||||||
|
|
||||||
|
class GuestExecLifecycleTests(LifecycleTestCase):
|
||||||
|
def test_create_provisions_agent_before_the_disk_is_ready_or_the_guest_is_started(self) -> None:
|
||||||
|
def provision(disk, vm_id, username):
|
||||||
|
self.host.call("images.provision_agent", vm_id)
|
||||||
|
vm = self.store.get(vm_id)
|
||||||
|
self.assertFalse(vm.disk_ready)
|
||||||
|
self.assertEqual(self.process.observe(vm), "gone")
|
||||||
|
self.assertEqual(disk, Path(vm.disk))
|
||||||
|
self.assertEqual(username, "worker")
|
||||||
|
|
||||||
|
self.provision_agent.side_effect = provision
|
||||||
|
vm = self.create_vm(username="worker")
|
||||||
|
self.assert_call_order(vm, "images.provision_agent", "process.start")
|
||||||
|
|
||||||
|
def test_stopped_boots_automatically_provision_agent_before_starting_the_vmm(self) -> None:
|
||||||
|
vm = self.create_vm(username="worker", password="retained-password")
|
||||||
|
identity = (vm.id, vm.guest_ip, vm.mac, vm.username, vm.password)
|
||||||
|
|
||||||
|
def provision(disk, vm_id, username):
|
||||||
|
self.host.call("images.provision_agent", vm_id)
|
||||||
|
record = self.store.get(vm_id)
|
||||||
|
self.assertTrue(self.store.operation_active)
|
||||||
|
self.assertEqual(self.process.observe(record), "gone")
|
||||||
|
self.assertEqual((record.id, record.guest_ip, record.mac, record.username, record.password), identity)
|
||||||
|
self.assertEqual((disk, username), (Path(vm.disk), "worker"))
|
||||||
|
|
||||||
|
self.provision_agent.side_effect = provision
|
||||||
|
for action in ("start", "restart", "resize"):
|
||||||
|
with self.subTest(action=action):
|
||||||
|
if action == "start":
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
self.host.events.clear()
|
||||||
|
self.provision_agent.reset_mock()
|
||||||
|
self.provision.reset_mock()
|
||||||
|
if action == "resize":
|
||||||
|
started = self.service.resize(vm.id, cpu=2)
|
||||||
|
else:
|
||||||
|
started = getattr(self.service, action)(vm.id)
|
||||||
|
self.assertEqual(started.status, "running")
|
||||||
|
self.provision_agent.assert_called_once_with(Path(vm.disk), vm.id, "worker")
|
||||||
|
self.provision.assert_not_called()
|
||||||
|
self.assert_call_order(vm, "images.provision_agent", "process.start")
|
||||||
|
|
||||||
|
def test_live_or_ambiguous_vms_never_run_offline_agent_provisioning(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.provision_agent.reset_mock()
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.process.unknown.add(vm.id)
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.provision_agent.assert_not_called()
|
||||||
|
|
||||||
|
def test_default_agent_setup_failure_blocks_boot_and_keeps_the_retained_disk(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
before = Path(vm.disk).read_bytes()
|
||||||
|
count = len(self.process.started)
|
||||||
|
self.provision_agent.side_effect = UvmError("agent setup failed")
|
||||||
|
with self.assertRaisesRegex(UvmError, "agent setup failed"):
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.assertEqual(len(self.process.started), count)
|
||||||
|
self.assertEqual(Path(vm.disk).read_bytes(), before)
|
||||||
|
self.assertEqual(self.store.get(vm.id).status, "failed")
|
||||||
|
self.provision_agent.side_effect = None
|
||||||
|
self.assertEqual(self.service.start(vm.id).status, "running")
|
||||||
|
|
||||||
|
def test_default_agent_setup_failure_rolls_back_a_new_vm_before_boot(self) -> None:
|
||||||
|
self.provision_agent.side_effect = UvmError("unsupported guest image")
|
||||||
|
with self.assertRaisesRegex(UvmError, "unsupported guest image"):
|
||||||
|
self.create_vm()
|
||||||
|
self.assertEqual(self.store.load().vms, {})
|
||||||
|
self.assertEqual(self.process.started, [])
|
||||||
|
|
||||||
|
def test_execution_pins_the_exact_vm_under_lock_and_waits_without_holding_it(self) -> None:
|
||||||
|
vm = self.create_vm(name="worker", ports=())
|
||||||
|
result = ExecResult(7, "output", "problem", False, False)
|
||||||
|
session = Mock()
|
||||||
|
|
||||||
|
def connect(path, vm_id, username, pid):
|
||||||
|
self.assertTrue(self.store.operation_active)
|
||||||
|
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
|
||||||
|
return session
|
||||||
|
|
||||||
|
def execute(command, **options):
|
||||||
|
self.assertFalse(self.store.operation_active)
|
||||||
|
self.assertFalse(self.store.transaction_active)
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
self.service._guest_connector = Mock(side_effect=connect)
|
||||||
|
session.execute.side_effect = execute
|
||||||
|
self.assertEqual(self.service.execute(vm.id, "exit 7", sudo=True, timeout_seconds=2), result)
|
||||||
|
session.execute.assert_called_once_with("exit 7", sudo=True, timeout_seconds=2)
|
||||||
|
session.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_execution_rejects_aliases_stopped_unknown_and_pending_vms_before_connecting(self) -> None:
|
||||||
|
vm = self.create_vm(name="worker")
|
||||||
|
self.service._guest_connector = Mock()
|
||||||
|
for alias in (vm.name, vm.guest_ip):
|
||||||
|
with self.assertRaises(NotFoundError):
|
||||||
|
self.service.execute(alias, "id")
|
||||||
|
for changes in ({"status": "stopped"}, {"operation": "policy"}, {"operation": "delete"}):
|
||||||
|
self.persist(vm, **changes)
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.service.execute(vm.id, "id")
|
||||||
|
self.persist(vm, status="running", operation=None)
|
||||||
|
self.process.unknown.add(vm.id)
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.service.execute(vm.id, "id")
|
||||||
|
self.service._guest_connector.assert_not_called()
|
||||||
|
|
||||||
|
def test_unavailable_or_lost_agent_does_not_retry_or_mutate_vm_state(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
before = self.settings.state_path.read_bytes()
|
||||||
|
self.service._guest_connector = Mock(side_effect=GuestAgentUnavailableError("not ready"))
|
||||||
|
with self.assertRaises(GuestAgentUnavailableError):
|
||||||
|
self.service.execute(vm.id, "id")
|
||||||
|
self.service._guest_connector.assert_called_once()
|
||||||
|
session = Mock()
|
||||||
|
session.execute.side_effect = GuestExecutionError("connection lost", outcome_unknown=True)
|
||||||
|
self.service._guest_connector = Mock(return_value=session)
|
||||||
|
with self.assertRaises(GuestExecutionError):
|
||||||
|
self.service.execute(vm.id, "id")
|
||||||
|
session.execute.assert_called_once()
|
||||||
|
session.close.assert_called_once()
|
||||||
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
|
|
||||||
|
def test_agent_upgrade_requires_a_stopped_proven_offline_disk_and_retains_identity(self) -> None:
|
||||||
|
vm = self.create_vm(username="worker")
|
||||||
|
self.provision_agent.reset_mock()
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.service.install_guest_agent(vm.id)
|
||||||
|
self.provision_agent.assert_not_called()
|
||||||
|
stopped = self.service.stop(vm.id)
|
||||||
|
before = stopped.to_dict()
|
||||||
|
|
||||||
|
def provision(disk, vm_id, username, *, install):
|
||||||
|
self.assertTrue(self.store.operation_active)
|
||||||
|
self.assertEqual(self.process.observe(self.store.get(vm_id)), "gone")
|
||||||
|
self.assertEqual((disk, username, install), (Path(vm.disk), "worker", True))
|
||||||
|
|
||||||
|
self.provision_agent.side_effect = provision
|
||||||
|
self.assertEqual(self.service.install_guest_agent(vm.id).to_dict(), before)
|
||||||
|
self.provision_agent.assert_called_once()
|
||||||
|
self.assertEqual(self.store.get(vm.id).to_dict(), before)
|
||||||
|
self.assertEqual(len(self.process.started), 1)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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),
|
||||||
|
("uvm-ubuntu-jammy-v4\n", GUEST_NETWORK_TEMPLATE),
|
||||||
|
("uvm-ubuntu-jammy-v5\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])
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from tests.test_guest_exec import FakeScope
|
||||||
|
from tests.test_lifecycle import LifecycleTestCase
|
||||||
|
from uvm import guest_agent as agent
|
||||||
|
from uvm.errors import ConflictError, GuestAgentUnavailableError, NotFoundError
|
||||||
|
from uvm.guest_terminal import GuestTerminal
|
||||||
|
|
||||||
|
|
||||||
|
class GuestTerminalTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.scope = FakeScope()
|
||||||
|
self.addCleanup(self.scope.temporary.cleanup)
|
||||||
|
self.home = self.enterContext(tempfile.TemporaryDirectory())
|
||||||
|
username = pwd.getpwuid(os.geteuid()).pw_name
|
||||||
|
original_popen = subprocess.Popen
|
||||||
|
# Exercise the actual guest child entry point with a temporary account
|
||||||
|
# home, so interactive shells never read/write the developer's dotfiles.
|
||||||
|
script = f"""
|
||||||
|
import os, runpy
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
agent = runpy.run_path({agent.__file__!r})
|
||||||
|
agent['pwd'].getpwnam = lambda _: SimpleNamespace(pw_name={username!r}, pw_uid=os.geteuid(),
|
||||||
|
pw_gid=os.getegid(), pw_dir={self.home!r}, pw_shell='/bin/sh')
|
||||||
|
agent['child_main'](Path({str(self.scope.path)!r}), {username!r}, None)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def launch(_args, **kwargs):
|
||||||
|
return original_popen([sys.executable, "-I", "-c", script], **kwargs)
|
||||||
|
|
||||||
|
self.enterContext(patch.object(agent.subprocess, "Popen", side_effect=launch))
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
self.terminal = GuestTerminal(left)
|
||||||
|
self.addCleanup(self.terminal.close)
|
||||||
|
|
||||||
|
def runner(connection, account, rows, cols):
|
||||||
|
return agent.run_terminal(connection, account, rows, cols, scope_factory=lambda: self.scope)
|
||||||
|
|
||||||
|
self.thread = threading.Thread(target=agent.serve_connection,
|
||||||
|
args=(right, {"vm_id": "vm-test", "username": username}),
|
||||||
|
kwargs={"terminal_runner": runner}, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.addCleanup(self.thread.join, 3)
|
||||||
|
agent.receive_frame(left, 4096, time.monotonic() + 2)
|
||||||
|
await asyncio.to_thread(self.terminal.start, 24, 80)
|
||||||
|
self.prompt = b"# " if os.geteuid() == 0 else b"$ "
|
||||||
|
await self.until(self.prompt)
|
||||||
|
|
||||||
|
async def until(self, wanted):
|
||||||
|
data = bytearray()
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while wanted not in data:
|
||||||
|
message = await self.terminal.read()
|
||||||
|
self.assertEqual(message["type"], "output", message)
|
||||||
|
data.extend(message["data"])
|
||||||
|
except TimeoutError:
|
||||||
|
self.fail(f"Missing {wanted!r} in terminal output: {bytes(data)!r}")
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
self.terminal.close()
|
||||||
|
await asyncio.to_thread(self.thread.join, 3)
|
||||||
|
self.assertFalse(self.thread.is_alive())
|
||||||
|
|
||||||
|
async def test_terminal_has_tty_persistent_shell_state_and_exit_code(self):
|
||||||
|
await self.terminal.write(b"test -t 0 && printf '\\nREAL-TTY\\n'\r")
|
||||||
|
await self.until(b"\r\nREAL-TTY\r\n")
|
||||||
|
await self.terminal.write(b"export NEST_TEST=hello; cd /\r")
|
||||||
|
await self.terminal.write(b"printf 'STATE:%s:%s\\n' \"$NEST_TEST\" \"$PWD\"\r")
|
||||||
|
await self.until(b"STATE:hello:/\r\n")
|
||||||
|
await self.terminal.write(b"exit 7\r")
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while True:
|
||||||
|
message = await self.terminal.read()
|
||||||
|
if message["type"] == "exit":
|
||||||
|
self.assertEqual(message["exit_code"], 7)
|
||||||
|
break
|
||||||
|
|
||||||
|
async def test_resize_and_control_c_reach_the_guest_foreground_job(self):
|
||||||
|
await self.terminal.resize(40, 120)
|
||||||
|
await self.terminal.write(b"stty size\r")
|
||||||
|
await self.until(b"40 120\r\n")
|
||||||
|
await self.terminal.write(b"sh -c 'printf \"\\nJOB-STARTED\\n\"; exec sleep 20'\r")
|
||||||
|
await self.until(b"\r\nJOB-STARTED\r\n")
|
||||||
|
await self.terminal.write(b"\x03")
|
||||||
|
await self.until(self.prompt)
|
||||||
|
await self.terminal.write(b"printf '\\nINTERRUPTED\\n'\r")
|
||||||
|
await self.until(b"\r\nINTERRUPTED\r\n")
|
||||||
|
|
||||||
|
async def test_explicit_end_terminates_the_shell(self):
|
||||||
|
await self.terminal.write(b"printf '\\nREADY\\n'\r")
|
||||||
|
await self.until(b"\r\nREADY\r\n")
|
||||||
|
await self.terminal.end()
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while (message := await self.terminal.read())["type"] != "exit":
|
||||||
|
pass
|
||||||
|
self.assertIsInstance(message["exit_code"], int)
|
||||||
|
self.assertGreater(self.scope.kills, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalCompatibilityTests(unittest.TestCase):
|
||||||
|
def test_old_agent_rejection_explains_how_to_upgrade(self):
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
terminal = GuestTerminal(left)
|
||||||
|
self.addCleanup(terminal.close)
|
||||||
|
|
||||||
|
def old_agent():
|
||||||
|
with right:
|
||||||
|
agent.receive_frame(right, 4096, time.monotonic() + 2)
|
||||||
|
agent.send_frame(right, {"error": "invalid execution request fields", "outcome_unknown": False},
|
||||||
|
4096, time.monotonic() + 2)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=old_agent)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
with self.assertRaisesRegex(GuestAgentUnavailableError, "Restart this VM to apply automatic guest-agent setup"):
|
||||||
|
terminal.start(24, 80)
|
||||||
|
finally:
|
||||||
|
thread.join(3)
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalLifecycleTests(LifecycleTestCase):
|
||||||
|
def test_terminal_connects_under_lock_and_opens_without_holding_the_lock(self):
|
||||||
|
vm = self.create_vm(name="worker", ports=())
|
||||||
|
terminal = Mock()
|
||||||
|
|
||||||
|
def connect(path, vm_id, username, pid):
|
||||||
|
self.assertTrue(self.store.operation_active)
|
||||||
|
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
|
||||||
|
return terminal
|
||||||
|
|
||||||
|
def start(rows, cols):
|
||||||
|
self.assertFalse(self.store.operation_active)
|
||||||
|
self.assertEqual((rows, cols), (40, 120))
|
||||||
|
|
||||||
|
self.service._terminal_connector = Mock(side_effect=connect)
|
||||||
|
terminal.start.side_effect = start
|
||||||
|
self.assertEqual(self.service.open_terminal(vm.id, rows=40, cols=120), (terminal, vm.username))
|
||||||
|
terminal.close.assert_not_called()
|
||||||
|
|
||||||
|
def test_aliases_and_stopped_vms_cannot_create_a_terminal(self):
|
||||||
|
vm = self.create_vm(name="worker")
|
||||||
|
self.service._terminal_connector = Mock()
|
||||||
|
for alias in (vm.name, vm.guest_ip):
|
||||||
|
with self.assertRaises(NotFoundError):
|
||||||
|
self.service.open_terminal(alias)
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.service.open_terminal(vm.id)
|
||||||
|
self.service._terminal_connector.assert_not_called()
|
||||||
+72
-1
@@ -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")
|
||||||
|
|||||||
+264
-4
@@ -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,254 @@ 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", "python3")
|
||||||
|
) + "\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/bin/python3": "python 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("python3", 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("socket.AF_VSOCK", guest("cat /usr/lib/uvm/agent.py").stdout)
|
||||||
|
self.assertIn("Delegate=yes", guest("cat /etc/systemd/system/uvm-agent.service").stdout)
|
||||||
|
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))
|
||||||
|
|||||||
+53
-1
@@ -307,6 +307,10 @@ 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.provision_agent = self.enterContext(patch.object(self.images, "provision_guest_agent"))
|
||||||
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 +344,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 +493,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 +511,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"):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from nest.cli import main
|
||||||
|
from nest.server import create_app, run_server
|
||||||
|
from tests.test_server import FASTAPI_AVAILABLE, FakeLifecycle, fake_application
|
||||||
|
from uvm.errors import UvmError
|
||||||
|
|
||||||
|
|
||||||
|
if FASTAPI_AVAILABLE:
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class NestCliTests(unittest.TestCase):
|
||||||
|
def test_serve_launches_the_nest_workspace(self) -> None:
|
||||||
|
application = fake_application(api_token="test-token")
|
||||||
|
with (
|
||||||
|
patch("nest.cli.build_application", return_value=application),
|
||||||
|
patch("nest.server.run_server") as launch,
|
||||||
|
):
|
||||||
|
result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"])
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
launch.assert_called_once_with(application, host="127.0.0.1", port=8123)
|
||||||
|
|
||||||
|
def test_invalid_invocations_do_not_build_host_services(self) -> None:
|
||||||
|
for arguments in (
|
||||||
|
[], ["--serve", "list"], ["--serve", "--port", "0"],
|
||||||
|
["--serve", "--port", "65536"], ["--serve", "--port", "1.5"],
|
||||||
|
):
|
||||||
|
with self.subTest(arguments=arguments), redirect_stderr(io.StringIO()):
|
||||||
|
with patch("nest.cli.build_application") as build:
|
||||||
|
with self.assertRaises(SystemExit) as raised:
|
||||||
|
main(arguments)
|
||||||
|
self.assertEqual(raised.exception.code, 2)
|
||||||
|
build.assert_not_called()
|
||||||
|
|
||||||
|
def test_startup_errors_are_reported_as_nest_errors(self) -> None:
|
||||||
|
output = io.StringIO()
|
||||||
|
with (
|
||||||
|
patch("nest.cli.build_application", side_effect=UvmError("invalid settings")),
|
||||||
|
redirect_stderr(output),
|
||||||
|
):
|
||||||
|
result = main(["--serve"])
|
||||||
|
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertEqual(output.getvalue(), "nest: error: invalid settings\n")
|
||||||
|
|
||||||
|
def test_factory_requires_an_explicit_host(self) -> None:
|
||||||
|
with self.assertRaisesRegex(UvmError, "nest --serve"):
|
||||||
|
create_app(fake_application(api_token="test-token"))
|
||||||
|
|
||||||
|
def test_workspace_enforces_backend_token_and_tls_requirements(self) -> None:
|
||||||
|
for token, host in ((None, "127.0.0.1"), ("test-token", "0.0.0.0")):
|
||||||
|
for launch in (create_app, run_server):
|
||||||
|
with self.subTest(token=token, host=host, launch=launch.__name__):
|
||||||
|
options = {"port": 8000} if launch is run_server else {}
|
||||||
|
with self.assertRaises(UvmError):
|
||||||
|
launch(fake_application(api_token=token), host=host, **options)
|
||||||
|
|
||||||
|
|
||||||
|
class _DashboardAssets(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.urls: list[str] = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs) -> None:
|
||||||
|
attributes = dict(attrs)
|
||||||
|
if tag == "link" and attributes.get("rel") in {"preload", "stylesheet"}:
|
||||||
|
self.urls.append(attributes["href"])
|
||||||
|
if tag == "script" and attributes.get("src"):
|
||||||
|
self.urls.append(attributes["src"])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for workspace tests")
|
||||||
|
class NestWorkspaceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.application = fake_application(api_token="test-token")
|
||||||
|
self.application.lifecycle = FakeLifecycle()
|
||||||
|
self.application.installer = SimpleNamespace(install=Mock())
|
||||||
|
self.headers = {"X-UVM-Token": "test-token"}
|
||||||
|
self.api = create_app(self.application, host="127.0.0.1")
|
||||||
|
self.client = TestClient(self.api)
|
||||||
|
self.addCleanup(self.client.close)
|
||||||
|
|
||||||
|
def test_nest_dashboard_and_its_linked_assets_are_public(self) -> None:
|
||||||
|
root = self.client.get("/", follow_redirects=False)
|
||||||
|
self.assertEqual(root.status_code, 307)
|
||||||
|
self.assertEqual(root.headers["location"], "/dashboard")
|
||||||
|
dashboard = self.client.get(root.headers["location"])
|
||||||
|
self.assertEqual(dashboard.status_code, 200)
|
||||||
|
self.assertIn("<title>Nest</title>", dashboard.text)
|
||||||
|
self.assertIn("Welcome to Nest", dashboard.text)
|
||||||
|
self.assertNotIn("test-token", dashboard.text)
|
||||||
|
|
||||||
|
assets = _DashboardAssets()
|
||||||
|
assets.feed(dashboard.text)
|
||||||
|
self.assertTrue(assets.urls)
|
||||||
|
for url in assets.urls:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
self.assertTrue(url.startswith("/dashboard/static/"))
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.content)
|
||||||
|
self.assertNotIn("text/html", response.headers["content-type"])
|
||||||
|
|
||||||
|
font = self.client.get("/dashboard/static/fonts/manrope-latin.woff2")
|
||||||
|
self.assertTrue(font.content.startswith(b"wOF2"))
|
||||||
|
license_response = self.client.get("/dashboard/static/fonts/OFL.txt")
|
||||||
|
self.assertEqual(license_response.status_code, 200)
|
||||||
|
self.assertIn("SIL OPEN FONT LICENSE", license_response.text)
|
||||||
|
self.assertEqual(self.client.get("/dashboard/static/missing.js").status_code, 404)
|
||||||
|
|
||||||
|
def test_machine_controls_use_the_authenticated_backend(self) -> None:
|
||||||
|
for method, path in (
|
||||||
|
("GET", "/vms"), ("GET", "/diagnostics"),
|
||||||
|
("POST", "/vms/vm-test/stop"), ("POST", "/install"), ("POST", "/exit"),
|
||||||
|
):
|
||||||
|
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
|
||||||
|
with self.subTest(method=method, path=path, headers=headers):
|
||||||
|
response = self.client.request(method, path, headers=headers, json={})
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.application.installer.install.assert_not_called()
|
||||||
|
self.assertEqual(self.application.lifecycle.vm.status, "running")
|
||||||
|
|
||||||
|
listed = self.client.get("/vms", headers=self.headers)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
self.assertEqual(listed.json()[0]["id"], "vm-test")
|
||||||
|
self.assertNotIn("password", listed.json()[0])
|
||||||
|
stopped = self.client.post("/vms/vm-test/stop", headers=self.headers)
|
||||||
|
self.assertEqual(stopped.status_code, 200)
|
||||||
|
self.assertEqual(stopped.json()["status"], "stopped")
|
||||||
|
self.assertEqual(self.application.lifecycle.vm.status, "stopped")
|
||||||
|
|
||||||
|
def test_workspace_preserves_api_errors_and_liveness(self) -> None:
|
||||||
|
missing = self.client.get("/vms/missing", headers=self.headers)
|
||||||
|
self.assertEqual(missing.status_code, 404)
|
||||||
|
self.assertEqual(missing.json(), {"error": {"message": "VM not found: missing"}})
|
||||||
|
health = self.client.get("/health")
|
||||||
|
self.assertEqual(health.status_code, 200)
|
||||||
|
self.assertEqual(health.json(), {"status": "ok"})
|
||||||
|
|
||||||
|
def test_workspace_api_reference_includes_the_dashboard_and_machine_routes(self) -> None:
|
||||||
|
schema = self.client.get("/openapi.json").json()
|
||||||
|
self.assertEqual(schema["info"]["title"], "Nest")
|
||||||
|
for path in ("/", "/dashboard", "/vms", "/diagnostics", "/vms/{vm_id}/resize"):
|
||||||
|
self.assertIn(path, schema["paths"])
|
||||||
|
|
||||||
|
def test_launcher_serves_nest_and_wires_graceful_shutdown(self) -> None:
|
||||||
|
server = SimpleNamespace(run=Mock(), should_exit=False)
|
||||||
|
config = object()
|
||||||
|
fake_uvicorn = SimpleNamespace(Config=Mock(return_value=config), Server=Mock(return_value=server))
|
||||||
|
with patch.dict(sys.modules, {"uvicorn": fake_uvicorn}):
|
||||||
|
run_server(self.application, host="127.0.0.1", port=8123)
|
||||||
|
|
||||||
|
api = fake_uvicorn.Config.call_args.args[0]
|
||||||
|
fake_uvicorn.Config.assert_called_once_with(api, host="127.0.0.1", port=8123)
|
||||||
|
fake_uvicorn.Server.assert_called_once_with(config)
|
||||||
|
server.run.assert_called_once_with()
|
||||||
|
self.assertIs(api.state.uvicorn_server, server)
|
||||||
|
with TestClient(api) as client:
|
||||||
|
self.assertIn("<title>Nest</title>", client.get("/dashboard").text)
|
||||||
|
response = client.post("/exit", headers=self.headers)
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
self.assertEqual(response.json(), {"status": "stopping"})
|
||||||
|
self.assertTrue(server.should_exit)
|
||||||
+75
-39
@@ -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}"):
|
||||||
|
|||||||
@@ -113,6 +113,28 @@ class ProcessTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class FirecrackerProcessSafetyTests(ProcessTestCase):
|
class FirecrackerProcessSafetyTests(ProcessTestCase):
|
||||||
|
def test_vsock_socket_is_removed_only_after_confirmed_process_exit(self) -> None:
|
||||||
|
path = self.manager._settings.guest_socket_path(self.vm.id)
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.touch()
|
||||||
|
self.vm.process_start_time = None
|
||||||
|
with self.assertRaises(FirecrackerError):
|
||||||
|
self.manager.terminate(self.vm)
|
||||||
|
self.assertTrue(path.exists())
|
||||||
|
self.vm.boot_id = OLD_BOOT_ID
|
||||||
|
self.manager.terminate(self.vm)
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_unowned_vsock_socket_keeps_a_missing_pid_ambiguous(self) -> None:
|
||||||
|
path = self.manager._settings.guest_socket_path(self.vm.id)
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.touch()
|
||||||
|
self.vm.pid = self.vm.process_start_time = None
|
||||||
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
||||||
|
with self.assertRaises(FirecrackerError):
|
||||||
|
self.manager.terminate(self.vm)
|
||||||
|
self.assertTrue(path.exists())
|
||||||
|
|
||||||
def test_observes_a_matching_identity_as_alive(self) -> None:
|
def test_observes_a_matching_identity_as_alive(self) -> None:
|
||||||
self.assertEqual(self.manager.observe(self.vm), "alive")
|
self.assertEqual(self.manager.observe(self.vm), "alive")
|
||||||
self.assertTrue(self.manager.is_alive(self.vm))
|
self.assertTrue(self.manager.is_alive(self.vm))
|
||||||
@@ -808,6 +830,10 @@ class FirecrackerLegacyAdoptionTests(ProcessTestCase):
|
|||||||
def root_lstat(path: Path) -> os.stat_result:
|
def root_lstat(path: Path) -> os.stat_result:
|
||||||
metadata = list(real_lstat(path))
|
metadata = list(real_lstat(path))
|
||||||
metadata[4] = 0
|
metadata[4] = 0
|
||||||
|
# Model protected system ancestors independently of the caller's
|
||||||
|
# TMPDIR permissions. Unsafe-path fixtures live inside self.directory.
|
||||||
|
if path in self.directory.parents:
|
||||||
|
metadata[0] &= ~0o022
|
||||||
return os.stat_result(metadata)
|
return os.stat_result(metadata)
|
||||||
|
|
||||||
self.root_lstat = root_lstat
|
self.root_lstat = root_lstat
|
||||||
|
|||||||
+139
-26
@@ -7,12 +7,16 @@ 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
|
||||||
from uvm.domain import PortRule, VmRecord, VmResources, VmSpec
|
from uvm.domain import PortRule, VmRecord, VmResources, VmSpec
|
||||||
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
|
from uvm.errors import (
|
||||||
|
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
|
||||||
|
NotFoundError, UvmError, ValidationError,
|
||||||
|
)
|
||||||
|
from uvm.guest_exec import ExecResult
|
||||||
from uvm.lifecycle import ListedVm, OperationResult
|
from uvm.lifecycle import ListedVm, OperationResult
|
||||||
from uvm.server import _http_status_for, create_api, run_server
|
from uvm.server import _http_status_for, create_api, run_server
|
||||||
|
|
||||||
@@ -99,19 +103,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", ""):
|
||||||
@@ -129,6 +145,8 @@ class ServerErrorTests(unittest.TestCase):
|
|||||||
(ConfigurationError("invalid settings"), 422),
|
(ConfigurationError("invalid settings"), 422),
|
||||||
(UvmError("this operation needs root"), 403),
|
(UvmError("this operation needs root"), 403),
|
||||||
(UvmError("host operation failed"), 500),
|
(UvmError("host operation failed"), 500),
|
||||||
|
(GuestAgentUnavailableError("not ready"), 503),
|
||||||
|
(GuestExecutionError("lost connection", outcome_unknown=True), 502),
|
||||||
):
|
):
|
||||||
with self.subTest(error=error):
|
with self.subTest(error=error):
|
||||||
self.assertEqual(_http_status_for(error), expected)
|
self.assertEqual(_http_status_for(error), expected)
|
||||||
@@ -147,6 +165,7 @@ class FakeLifecycle:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.last_spec: VmSpec | None = None
|
self.last_spec: VmSpec | None = None
|
||||||
self.last_resize: dict | None = None
|
self.last_resize: dict | None = None
|
||||||
|
self.last_execution: dict | None = None
|
||||||
self.observed_status = "running"
|
self.observed_status = "running"
|
||||||
self.policy_status = "enforced"
|
self.policy_status = "enforced"
|
||||||
self.vm = VmRecord(
|
self.vm = VmRecord(
|
||||||
@@ -244,6 +263,19 @@ class FakeLifecycle:
|
|||||||
self.inspect(identifier)
|
self.inspect(identifier)
|
||||||
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
|
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
|
||||||
|
|
||||||
|
def execute(self, vm_id: str, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
|
||||||
|
if vm_id != self.vm.id:
|
||||||
|
raise NotFoundError(f"VM not found: {vm_id}")
|
||||||
|
self.last_execution = dict(vm_id=vm_id, command=command, sudo=sudo, timeout_seconds=timeout_seconds)
|
||||||
|
return ExecResult(7, "hello\n", "problem\n", False, False)
|
||||||
|
|
||||||
|
def install_guest_agent(self, vm_id: str) -> VmRecord:
|
||||||
|
if vm_id != self.vm.id:
|
||||||
|
raise NotFoundError(f"VM not found: {vm_id}")
|
||||||
|
if self.vm.status != "stopped":
|
||||||
|
raise ConflictError("stop the VM before installing the guest agent")
|
||||||
|
return self.vm
|
||||||
|
|
||||||
def diagnostics(self) -> list[dict]:
|
def diagnostics(self) -> list[dict]:
|
||||||
return [{"name": "kvm", "ok": True, "message": "available"}]
|
return [{"name": "kvm", "ok": True, "message": "available"}]
|
||||||
|
|
||||||
@@ -300,7 +332,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:
|
||||||
@@ -309,20 +342,10 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json(), {"status": "ok"})
|
self.assertEqual(response.json(), {"status": "ok"})
|
||||||
|
|
||||||
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
|
def test_standalone_uvm_api_does_not_serve_the_nest_dashboard(self) -> None:
|
||||||
root = self.client.get("/", follow_redirects=False)
|
for path in ("/", "/dashboard", "/dashboard/static/js/dashboard.js"):
|
||||||
dashboard = self.client.get("/dashboard")
|
with self.subTest(path=path):
|
||||||
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
|
self.assertEqual(self.client.get(path).status_code, 404)
|
||||||
script = self.client.get("/dashboard/static/js/dashboard.js")
|
|
||||||
|
|
||||||
self.assertEqual(root.status_code, 307)
|
|
||||||
self.assertEqual(root.headers["location"], "/dashboard")
|
|
||||||
self.assertEqual(dashboard.status_code, 200)
|
|
||||||
self.assertIn("UVM Control", dashboard.text)
|
|
||||||
self.assertEqual(stylesheet.status_code, 200)
|
|
||||||
self.assertIn("--acid", stylesheet.text)
|
|
||||||
self.assertEqual(script.status_code, 200)
|
|
||||||
self.assertIn("sessionStorage", script.text)
|
|
||||||
|
|
||||||
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
|
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
|
||||||
for method, path, payload in (
|
for method, path, payload in (
|
||||||
@@ -338,7 +361,10 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
("DELETE", "/vms/test-vm", {}),
|
("DELETE", "/vms/test-vm", {}),
|
||||||
("POST", "/vms/test-vm/ports", {}),
|
("POST", "/vms/test-vm/ports", {}),
|
||||||
("POST", "/vms/test-vm/ports/sync", {}),
|
("POST", "/vms/test-vm/ports/sync", {}),
|
||||||
|
("POST", "/vms/vm-test/exec", {"command": "id"}),
|
||||||
|
("POST", "/vms/vm-test/agent/install", {}),
|
||||||
("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 +379,24 @@ 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 nest --serve or 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",
|
||||||
@@ -633,6 +677,74 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(sync.json()["id"], "vm-test")
|
self.assertEqual(sync.json()["id"], "vm-test")
|
||||||
self.assertEqual(sync.json()["policy_status"], "inactive")
|
self.assertEqual(sync.json()["policy_status"], "inactive")
|
||||||
|
|
||||||
|
def test_exec_preserves_script_text_privileges_and_nonzero_exit_results(self) -> None:
|
||||||
|
command = "printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7"
|
||||||
|
response = self.client.post("/vms/vm-test/exec", headers=self.headers,
|
||||||
|
json={"command": command, "sudo": True, "timeout_seconds": 12.5})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual(response.json(), {
|
||||||
|
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
|
||||||
|
})
|
||||||
|
self.assertEqual(self.lifecycle.last_execution, {
|
||||||
|
"vm_id": "vm-test", "command": command, "sudo": True, "timeout_seconds": 12.5,
|
||||||
|
})
|
||||||
|
self.assertNotIn("guest-secret", response.text)
|
||||||
|
default = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
|
||||||
|
self.assertEqual(default.status_code, 200)
|
||||||
|
self.assertEqual(self.lifecycle.last_execution["sudo"], False)
|
||||||
|
self.assertEqual(self.lifecycle.last_execution["timeout_seconds"], 60)
|
||||||
|
|
||||||
|
def test_exec_rejects_malformed_requests_before_calling_the_service(self) -> None:
|
||||||
|
for payload in (
|
||||||
|
{}, {"command": ""}, {"command": " \n"}, {"command": "a\0b"}, {"command": 42},
|
||||||
|
{"command": "x" * 65537}, {"command": "\u00e9" * 40000},
|
||||||
|
{"command": "id", "sudo": "false"}, {"command": "id", "sudo": 1},
|
||||||
|
{"command": "id", "timeout_seconds": True}, {"command": "id", "timeout_seconds": "10"},
|
||||||
|
{"command": "id", "timeout_seconds": 0}, {"command": "id", "timeout_seconds": 3601},
|
||||||
|
{"command": "id", "username": "root"}, {"command": "id", "host_path": "/etc/passwd"},
|
||||||
|
):
|
||||||
|
with self.subTest(payload=str(payload)[:100]):
|
||||||
|
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json=payload)
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
self.assertIsNone(self.lifecycle.last_execution)
|
||||||
|
|
||||||
|
def test_exec_errors_distinguish_vm_state_agent_readiness_and_unknown_outcomes(self) -> None:
|
||||||
|
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("stopped"), 409),
|
||||||
|
(GuestAgentUnavailableError("not ready"), 503),
|
||||||
|
(GuestExecutionError("lost response", outcome_unknown=True), 502)):
|
||||||
|
with self.subTest(error=error), patch.object(self.lifecycle, "execute", side_effect=error):
|
||||||
|
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
|
||||||
|
self.assertEqual(response.status_code, expected)
|
||||||
|
if isinstance(error, GuestExecutionError):
|
||||||
|
self.assertTrue(response.json()["error"]["outcome_unknown"])
|
||||||
|
with patch.object(self.lifecycle, "execute", return_value=ExecResult(None, "partial", "", True, True)):
|
||||||
|
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "sleep 10"})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.json()["exit_code"])
|
||||||
|
self.assertTrue(response.json()["timed_out"])
|
||||||
|
self.assertTrue(response.json()["truncated"])
|
||||||
|
|
||||||
|
def test_exec_rejects_nonfinite_and_invalid_unicode_json_without_serialization_errors(self) -> None:
|
||||||
|
for body in ('{"command":"id","timeout_seconds":NaN}',
|
||||||
|
'{"command":"id","timeout_seconds":Infinity}',
|
||||||
|
'{"command":"\\ud800"}', '{"command":"id","sudo":NaN}'):
|
||||||
|
with self.subTest(body=body):
|
||||||
|
response = self.client.post("/vms/vm-test/exec", headers={**self.headers, "Content-Type": "application/json"},
|
||||||
|
content=body)
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
self.assertIsNone(self.lifecycle.last_execution)
|
||||||
|
|
||||||
|
def test_agent_install_requires_stopping_and_uses_the_existing_vm_disk(self) -> None:
|
||||||
|
blocked = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
|
||||||
|
self.assertEqual(blocked.status_code, 409)
|
||||||
|
self.client.post("/vms/vm-test/stop", headers=self.headers)
|
||||||
|
with patch.object(self.lifecycle, "install_guest_agent", wraps=self.lifecycle.install_guest_agent) as install:
|
||||||
|
response = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"id": "vm-test", "status": "installed", "restart_required": True})
|
||||||
|
install.assert_called_once_with("vm-test")
|
||||||
|
self.assertEqual(self.lifecycle.vm.status, "stopped")
|
||||||
|
|
||||||
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
|
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
|
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
|
||||||
@@ -743,15 +855,14 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
|
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
|
||||||
|
|
||||||
def test_api_schema_exposes_dashboard_and_management_operations(self) -> None:
|
def test_api_schema_exposes_management_operations(self) -> None:
|
||||||
schema = self.client.get("/openapi.json").json()
|
schema = self.client.get("/openapi.json").json()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{(path, method) for path, methods in schema["paths"].items() for method in methods},
|
{(path, method) for path, methods in schema["paths"].items() for method in methods},
|
||||||
{
|
{
|
||||||
("/", "get"),
|
|
||||||
("/dashboard", "get"),
|
|
||||||
("/health", "get"),
|
("/health", "get"),
|
||||||
|
("/exit", "post"),
|
||||||
("/install", "post"),
|
("/install", "post"),
|
||||||
("/vms", "get"),
|
("/vms", "get"),
|
||||||
("/vms", "post"),
|
("/vms", "post"),
|
||||||
@@ -765,6 +876,8 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
("/vms/{vm_id}", "delete"),
|
("/vms/{vm_id}", "delete"),
|
||||||
("/vms/{vm_id}/ports", "post"),
|
("/vms/{vm_id}/ports", "post"),
|
||||||
("/vms/{vm_id}/ports/sync", "post"),
|
("/vms/{vm_id}/ports/sync", "post"),
|
||||||
|
("/vms/{vm_id}/exec", "post"),
|
||||||
|
("/vms/{vm_id}/agent/install", "post"),
|
||||||
("/diagnostics", "get"),
|
("/diagnostics", "get"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from nest.server import create_app
|
||||||
|
from nest.session_store import SessionStore
|
||||||
|
from nest.sessions import SessionManager
|
||||||
|
from tests.test_server import FASTAPI_AVAILABLE, fake_application
|
||||||
|
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
if FASTAPI_AVAILABLE:
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTerminal:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.events = asyncio.Queue()
|
||||||
|
self.events.put_nowait({"type": "output", "data": b"guest$ "})
|
||||||
|
self.input = []
|
||||||
|
self.sizes = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def read(self):
|
||||||
|
event = await self.events.get()
|
||||||
|
if isinstance(event, Exception):
|
||||||
|
raise event
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def write(self, data):
|
||||||
|
self.input.append(data)
|
||||||
|
await self.events.put({"type": "output", "data": data + b"\r\nguest$ "})
|
||||||
|
|
||||||
|
async def resize(self, rows, cols):
|
||||||
|
self.sizes.append((rows, cols))
|
||||||
|
|
||||||
|
async def end(self):
|
||||||
|
await self.events.put({"type": "exit", "exit_code": 0})
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def session_application(base):
|
||||||
|
application = fake_application(api_token="test-token")
|
||||||
|
application.settings.base = Path(base)
|
||||||
|
application.terminals = []
|
||||||
|
application.vm_ids = {"vm-test", "vm-other"}
|
||||||
|
|
||||||
|
def get(vm_id):
|
||||||
|
if vm_id not in application.vm_ids:
|
||||||
|
raise NotFoundError("VM not found")
|
||||||
|
return SimpleNamespace(id=vm_id)
|
||||||
|
|
||||||
|
def open_terminal(vm_id, **_size):
|
||||||
|
get(vm_id)
|
||||||
|
terminal = FakeTerminal()
|
||||||
|
application.terminals.append(terminal)
|
||||||
|
return terminal, "worker"
|
||||||
|
|
||||||
|
application.state_store = SimpleNamespace(get=get)
|
||||||
|
application.lifecycle = SimpleNamespace(open_terminal=open_terminal)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStoreTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.path = Path(temporary.name) / "nest" / "sessions.sqlite3"
|
||||||
|
self.store = SessionStore(self.path, max_output=8)
|
||||||
|
self.addCleanup(self.store.close)
|
||||||
|
|
||||||
|
def test_history_is_scoped_by_exact_vm_id_and_survives_reopening(self):
|
||||||
|
record = self.store.create("vm-a", 24, 80)
|
||||||
|
self.store.activate(record["id"], "worker")
|
||||||
|
self.store.append(record["id"], b"hello")
|
||||||
|
self.store.finish(record["id"], "closed", 7)
|
||||||
|
self.assertEqual(self.store.list("vm-b"), [])
|
||||||
|
with self.assertRaises(NotFoundError):
|
||||||
|
self.store.get("vm-b", record["id"])
|
||||||
|
self.store.close()
|
||||||
|
reopened = SessionStore(self.path)
|
||||||
|
self.addCleanup(reopened.close)
|
||||||
|
saved, start, output = reopened.read("vm-a", record["id"], 0)
|
||||||
|
self.assertEqual((saved["status"], saved["exit_code"], saved["username"], start, output), ("closed", 7, "worker", 0, b"hello"))
|
||||||
|
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertEqual(self.path.parent.stat().st_mode & 0o777, 0o700)
|
||||||
|
|
||||||
|
def test_restarted_server_marks_only_live_sessions_interrupted(self):
|
||||||
|
live = self.store.create("vm-a", 24, 80)
|
||||||
|
ended = self.store.create("vm-a", 24, 80)
|
||||||
|
self.store.activate(live["id"], "worker")
|
||||||
|
self.store.finish(ended["id"], "closed", 0)
|
||||||
|
self.store.close()
|
||||||
|
reopened = SessionStore(self.path)
|
||||||
|
self.addCleanup(reopened.close)
|
||||||
|
self.assertEqual(reopened.get("vm-a", live["id"])["status"], "interrupted")
|
||||||
|
self.assertIsNotNone(reopened.get("vm-a", live["id"])["ended_at"])
|
||||||
|
self.assertEqual(reopened.get("vm-a", ended["id"])["status"], "closed")
|
||||||
|
|
||||||
|
def test_transcript_is_bounded_and_offsets_support_exact_replay(self):
|
||||||
|
record = self.store.create("vm-a", 24, 80)
|
||||||
|
self.store.append(record["id"], b"abcdefghij")
|
||||||
|
self.store.append(record["id"], b"kl")
|
||||||
|
saved, start, output = self.store.read("vm-a", record["id"], 0)
|
||||||
|
self.assertEqual((start, output, saved["output_end"], saved["truncated"]), (4, b"efghijkl", 12, True))
|
||||||
|
self.assertEqual(self.store.read("vm-a", record["id"], 9)[1:], (9, b"jkl"))
|
||||||
|
self.assertEqual(self.store.read("vm-a", record["id"], 12)[2], b"")
|
||||||
|
|
||||||
|
def test_second_server_cannot_reconcile_another_servers_live_sessions(self):
|
||||||
|
record = self.store.create("vm-a", 24, 80)
|
||||||
|
self.store.activate(record["id"], "worker")
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
SessionStore(self.path)
|
||||||
|
self.assertEqual(self.store.get("vm-a", record["id"])["status"], "active")
|
||||||
|
|
||||||
|
def test_symlinked_history_file_is_not_opened_or_rewritten(self):
|
||||||
|
self.store.close()
|
||||||
|
self.path.unlink()
|
||||||
|
target = self.path.parent / "unrelated"
|
||||||
|
target.write_bytes(b"keep this")
|
||||||
|
self.path.symlink_to(target)
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
SessionStore(self.path)
|
||||||
|
self.assertEqual(target.read_bytes(), b"keep this")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.application = session_application(self.temporary.name)
|
||||||
|
self.manager = SessionManager(self.application)
|
||||||
|
self.addAsyncCleanup(self.manager.shutdown)
|
||||||
|
|
||||||
|
async def test_failed_launch_is_saved_in_history(self):
|
||||||
|
def unavailable(*_args, **_kwargs):
|
||||||
|
raise GuestAgentUnavailableError("update the guest agent")
|
||||||
|
|
||||||
|
self.application.lifecycle.open_terminal = unavailable
|
||||||
|
with self.assertRaises(GuestAgentUnavailableError):
|
||||||
|
await self.manager.create("vm-test", 24, 80)
|
||||||
|
history = await self.manager.list("vm-test")
|
||||||
|
self.assertEqual(history[0]["status"], "failed")
|
||||||
|
self.assertEqual(history[0]["error"], "update the guest agent")
|
||||||
|
|
||||||
|
async def test_shutdown_closes_live_channels_and_saves_interrupted_history(self):
|
||||||
|
record = await self.manager.create("vm-test", 24, 80)
|
||||||
|
await self.manager.shutdown()
|
||||||
|
self.assertTrue(self.application.terminals[0].closed)
|
||||||
|
store = SessionStore(Path(self.temporary.name) / "nest" / "sessions.sqlite3")
|
||||||
|
self.addCleanup(store.close)
|
||||||
|
self.assertEqual(store.get("vm-test", record["id"])["status"], "interrupted")
|
||||||
|
|
||||||
|
async def test_cancelling_the_create_request_does_not_orphan_the_shell(self):
|
||||||
|
import threading
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
connect = self.application.lifecycle.open_terminal
|
||||||
|
|
||||||
|
def slow_connect(*args, **kwargs):
|
||||||
|
entered.set()
|
||||||
|
release.wait(2)
|
||||||
|
return connect(*args, **kwargs)
|
||||||
|
|
||||||
|
self.application.lifecycle.open_terminal = slow_connect
|
||||||
|
request = asyncio.create_task(self.manager.create("vm-test", 24, 80))
|
||||||
|
await asyncio.to_thread(entered.wait, 2)
|
||||||
|
request.cancel()
|
||||||
|
release.set()
|
||||||
|
await asyncio.gather(request, return_exceptions=True)
|
||||||
|
await self.manager.shutdown()
|
||||||
|
self.assertTrue(self.application.terminals[0].closed)
|
||||||
|
|
||||||
|
async def test_guest_loss_ends_the_session_and_retains_output(self):
|
||||||
|
record = await self.manager.create("vm-test", 24, 80)
|
||||||
|
terminal = self.application.terminals[0]
|
||||||
|
await terminal.events.put(GuestExecutionError("VM connection lost", outcome_unknown=True))
|
||||||
|
async with asyncio.timeout(2):
|
||||||
|
while (await self.manager.get("vm-test", record["id"]))["status"] == "active":
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
saved, _start, output = await self.manager.read("vm-test", record["id"], 0)
|
||||||
|
self.assertEqual(saved["status"], "interrupted")
|
||||||
|
self.assertEqual(output, b"guest$ ")
|
||||||
|
self.assertTrue(terminal.closed)
|
||||||
|
|
||||||
|
async def test_shutdown_finishes_inflight_output_before_releasing_history_ownership(self):
|
||||||
|
import threading
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
history = self.manager._history()
|
||||||
|
append = history.append
|
||||||
|
|
||||||
|
def slow_append(*args):
|
||||||
|
entered.set()
|
||||||
|
release.wait(2)
|
||||||
|
append(*args)
|
||||||
|
|
||||||
|
with patch.object(history, "append", side_effect=slow_append):
|
||||||
|
record = await self.manager.create("vm-test", 24, 80)
|
||||||
|
self.assertTrue(await asyncio.to_thread(entered.wait, 2))
|
||||||
|
closing = asyncio.create_task(self.manager.shutdown())
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
self.assertFalse(closing.done())
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
SessionStore(history.path)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
await closing
|
||||||
|
reopened = SessionStore(history.path)
|
||||||
|
self.addCleanup(reopened.close)
|
||||||
|
self.assertEqual(reopened.read("vm-test", record["id"], 0)[2], b"guest$ ")
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
|
||||||
|
class SessionApiTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
|
||||||
|
self.application = session_application(self.temporary)
|
||||||
|
self.api = create_app(self.application, host="127.0.0.1")
|
||||||
|
self.client = self.enterContext(TestClient(self.api))
|
||||||
|
self.headers = {"X-UVM-Token": "test-token"}
|
||||||
|
self.base = "/vms/vm-test/sessions"
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
response = self.client.post(self.base, headers=self.headers, json={})
|
||||||
|
self.assertEqual(response.status_code, 201, response.text)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def connect(self, record, *, vm_id="vm-test", offset=0):
|
||||||
|
ws = self.enterContext(self.client.websocket_connect(f"/vms/{vm_id}/sessions/{record['id']}/terminal"))
|
||||||
|
ws.send_json({"type": "auth", "token": "test-token", "offset": offset})
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def output(self, ws):
|
||||||
|
while True:
|
||||||
|
message = ws.receive_json()
|
||||||
|
if message["type"] == "output":
|
||||||
|
data = base64.b64decode(message["data"])
|
||||||
|
end = message["offset"] + len(data)
|
||||||
|
ws.send_json({"type": "ack", "offset": end})
|
||||||
|
return data, end
|
||||||
|
self.assertEqual(message["type"], "session", message)
|
||||||
|
|
||||||
|
def test_all_http_operations_and_websocket_output_require_authentication(self):
|
||||||
|
for method, path in (("GET", self.base), ("POST", self.base),
|
||||||
|
("GET", self.base + "/session-unknown"), ("POST", self.base + "/session-unknown/close")):
|
||||||
|
self.assertEqual(self.client.request(method, path, json={}).status_code, 401)
|
||||||
|
for token in ("wrong", "", "\u00e9"):
|
||||||
|
with self.client.websocket_connect(self.base + "/session-unknown/terminal") as ws:
|
||||||
|
ws.send_json({"type": "auth", "token": token})
|
||||||
|
with self.assertRaises(WebSocketDisconnect) as raised:
|
||||||
|
ws.receive_json()
|
||||||
|
self.assertEqual(raised.exception.code, 4401)
|
||||||
|
self.assertEqual(self.application.terminals, [])
|
||||||
|
self.assertFalse((Path(self.temporary) / "nest").exists())
|
||||||
|
|
||||||
|
def test_foreign_browser_origin_is_rejected(self):
|
||||||
|
with self.assertRaises(WebSocketDisconnect):
|
||||||
|
with self.client.websocket_connect(self.base + "/unknown/terminal", headers={"Origin": "https://foreign.example"}):
|
||||||
|
self.fail("foreign origin accepted")
|
||||||
|
|
||||||
|
def test_create_rejects_unknown_vm_aliases_and_invalid_sizes(self):
|
||||||
|
for body in ({"rows": 0}, {"cols": True}, {"cols": 401}, {"command": "id"}):
|
||||||
|
self.assertEqual(self.client.post(self.base, headers=self.headers, json=body).status_code, 422)
|
||||||
|
self.assertEqual(self.client.post("/vms/name-alias/sessions", headers=self.headers, json={}).status_code, 404)
|
||||||
|
self.assertEqual(self.application.terminals, [])
|
||||||
|
|
||||||
|
def test_sessions_are_nested_under_the_selected_vm(self):
|
||||||
|
record = self.create()
|
||||||
|
self.assertEqual(record["username"], "worker")
|
||||||
|
other = "/vms/vm-other/sessions"
|
||||||
|
self.assertEqual(self.client.get(other, headers=self.headers).json(), [])
|
||||||
|
self.assertEqual(self.client.get(other + "/" + record["id"], headers=self.headers).status_code, 404)
|
||||||
|
self.assertEqual(self.client.post(other + "/" + record["id"] + "/close", headers=self.headers).status_code, 404)
|
||||||
|
ws = self.connect(record, vm_id="vm-other")
|
||||||
|
self.assertEqual(ws.receive_json()["type"], "error")
|
||||||
|
self.assertFalse(self.application.terminals[0].closed)
|
||||||
|
|
||||||
|
def test_disconnect_reconnect_input_resize_and_closed_history(self):
|
||||||
|
record = self.create()
|
||||||
|
ws = self.connect(record)
|
||||||
|
output, offset = self.output(ws)
|
||||||
|
self.assertEqual(output, b"guest$ ")
|
||||||
|
ws.send_json({"type": "resize", "rows": 35, "cols": 120})
|
||||||
|
ws.send_json({"type": "input", "data": base64.b64encode(b"pwd\r").decode()})
|
||||||
|
output, offset = self.output(ws)
|
||||||
|
self.assertEqual(output, b"pwd\r\r\nguest$ ")
|
||||||
|
self.assertEqual(self.application.terminals[0].sizes, [(35, 120)])
|
||||||
|
ws.close()
|
||||||
|
self.assertFalse(self.application.terminals[0].closed)
|
||||||
|
reopened = self.connect(record, offset=offset)
|
||||||
|
self.assertEqual(reopened.receive_json()["session"]["status"], "active")
|
||||||
|
self.assertEqual(len(self.application.terminals), 1)
|
||||||
|
closed = self.client.post(self.base + "/" + record["id"] + "/close", headers=self.headers)
|
||||||
|
self.assertEqual(closed.json()["status"], "closed")
|
||||||
|
self.assertEqual(closed.json()["exit_code"], 0)
|
||||||
|
replay = self.connect(record)
|
||||||
|
transcript, _end = self.output(replay)
|
||||||
|
self.assertEqual(transcript, b"guest$ pwd\r\r\nguest$ ")
|
||||||
|
self.assertEqual(self.client.get(self.base, headers=self.headers).json()[0]["status"], "closed")
|
||||||
|
self.assertTrue(self.application.terminals[0].closed)
|
||||||
|
|
||||||
|
def test_malformed_input_ends_only_the_browser_attachment(self):
|
||||||
|
record = self.create()
|
||||||
|
ws = self.connect(record)
|
||||||
|
self.output(ws)
|
||||||
|
ws.send_json({"type": "input", "data": "not base64!"})
|
||||||
|
while (message := ws.receive_json())["type"] == "session":
|
||||||
|
pass
|
||||||
|
self.assertEqual(message["type"], "error")
|
||||||
|
self.assertEqual(self.application.terminals[0].input, [])
|
||||||
|
self.assertFalse(self.application.terminals[0].closed)
|
||||||
|
self.assertEqual(self.client.get(self.base + "/" + record["id"], headers=self.headers).json()["status"], "active")
|
||||||
+42
-1
@@ -5,15 +5,22 @@ from __future__ import annotations
|
|||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
||||||
|
|
||||||
from .domain import VmRecord
|
from .domain import VmRecord
|
||||||
|
from .errors import ValidationError
|
||||||
|
from .guest_agent import DEFAULT_TIMEOUT_SECONDS, MAX_COMMAND_BYTES, MAX_TIMEOUT_SECONDS
|
||||||
|
from .guest_exec import validate_exec
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
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")
|
||||||
|
|
||||||
@@ -108,6 +115,40 @@ class VmLogResponse(BaseModel):
|
|||||||
truncated: bool
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class VmExecRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
command: str = Field(strict=True, min_length=1, max_length=MAX_COMMAND_BYTES, repr=False,
|
||||||
|
description="Command or multiline script interpreted by /bin/sh inside the guest; at most 64 KiB UTF-8")
|
||||||
|
sudo: bool = Field(default=False, strict=True, description="Run as guest root instead of the VM's configured login user")
|
||||||
|
timeout_seconds: float = Field(default=DEFAULT_TIMEOUT_SECONDS, strict=True, gt=0,
|
||||||
|
le=MAX_TIMEOUT_SECONDS, allow_inf_nan=False)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_execution(cls, value: object) -> object:
|
||||||
|
# Use the domain error envelope: echoing NaN or unpaired surrogates in
|
||||||
|
# FastAPI's validation-error input can itself fail JSON serialization.
|
||||||
|
if not isinstance(value, dict) or set(value) - {"command", "sudo", "timeout_seconds"}:
|
||||||
|
raise ValidationError("execution requires an object with command, sudo, and timeout_seconds fields only")
|
||||||
|
validate_exec(value.get("command"), value.get("sudo", False), value.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class VmExecResponse(BaseModel):
|
||||||
|
exit_code: int | None
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
timed_out: bool
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class VmAgentInstallResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: Literal["installed"] = "installed"
|
||||||
|
restart_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
class DiagnosticResponse(BaseModel):
|
class DiagnosticResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
ok: bool
|
ok: bool
|
||||||
|
|||||||
+7
-6
@@ -1,4 +1,4 @@
|
|||||||
"""The sole public interface of this project: the local uvm CLI."""
|
"""Command-line interface for UVM's local machine management services."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--serve",
|
"--serve",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="launch the FastAPI management server instead of running a CLI command",
|
help="launch the UVM management API; use nest --serve for the workspace dashboard",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
@@ -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(
|
||||||
|
|||||||
+8
-5
@@ -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(
|
||||||
@@ -122,6 +122,9 @@ class Settings:
|
|||||||
def vm_dir(self, vm_id: str) -> Path:
|
def vm_dir(self, vm_id: str) -> Path:
|
||||||
return self.vms_dir / vm_id
|
return self.vms_dir / vm_id
|
||||||
|
|
||||||
|
def guest_socket_path(self, vm_id: str) -> Path:
|
||||||
|
return self.vm_dir(vm_id) / "vsock.sock"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_environment(cls) -> "Settings":
|
def from_environment(cls) -> "Settings":
|
||||||
"""Load optional deployment overrides while retaining script defaults."""
|
"""Load optional deployment overrides while retaining script defaults."""
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# UVM Dashboard static assets package.
|
|
||||||
@@ -39,6 +39,18 @@ class FirecrackerError(UvmError):
|
|||||||
"""Raised when Firecracker cannot be started or configured."""
|
"""Raised when Firecracker cannot be started or configured."""
|
||||||
|
|
||||||
|
|
||||||
|
class GuestAgentUnavailableError(UvmError):
|
||||||
|
"""The VM's execution agent could not be reached before submission."""
|
||||||
|
|
||||||
|
|
||||||
|
class GuestExecutionError(UvmError):
|
||||||
|
"""Guest execution failed; a lost response does not prove the command failed."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.outcome_unknown = outcome_unknown
|
||||||
|
|
||||||
|
|
||||||
class TapCreationError(UvmError):
|
class TapCreationError(UvmError):
|
||||||
"""Raised when TAP setup fails after creating a host interface."""
|
"""Raised when TAP setup fails after creating a host interface."""
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,20 @@ 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")
|
||||||
|
vsock = config.get("vsock")
|
||||||
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")
|
||||||
|
if (not isinstance(vsock, Mapping) or type(vsock.get("guest_cid")) is not int
|
||||||
|
or not 3 <= vsock["guest_cid"] < 2**32 - 1
|
||||||
|
or not isinstance(vsock.get("uds_path"), str) or not vsock["uds_path"]):
|
||||||
|
raise FirecrackerError("Firecracker configuration has no valid vsock device")
|
||||||
|
|
||||||
self._request(
|
self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -54,6 +62,8 @@ 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", "/vsock", vsock)
|
||||||
|
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:
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ 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": {},
|
||||||
|
# Each Firecracker instance has its own Unix-socket-backed vsock device;
|
||||||
|
# CIDs need not be allocated across these independent devices.
|
||||||
|
"vsock": {"guest_cid": 3, "uds_path": str(settings.guest_socket_path(vm.id))},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class FirecrackerProcessManager:
|
|||||||
) from error
|
) from error
|
||||||
|
|
||||||
socket_path = Path(vm.socket)
|
socket_path = Path(vm.socket)
|
||||||
self._remove_socket(socket_path)
|
self._remove_runtime_sockets(vm)
|
||||||
|
|
||||||
log_path = Path(vm.log)
|
log_path = Path(vm.log)
|
||||||
process: _PopenLike | None = None
|
process: _PopenLike | None = None
|
||||||
@@ -275,14 +275,16 @@ class FirecrackerProcessManager:
|
|||||||
if vm.pid is None:
|
if vm.pid is None:
|
||||||
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
|
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
for socket_path in (Path(vm.socket), self._settings.guest_socket_path(vm.id)):
|
||||||
try:
|
try:
|
||||||
Path(vm.socket).lstat()
|
socket_path.lstat()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return "gone"
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
# An unowned socket may belong to a child whose identity was never saved.
|
# An unowned socket may belong to a child whose identity was never saved.
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
return "gone"
|
||||||
|
|
||||||
stat = self._process_stat(vm.pid)
|
stat = self._process_stat(vm.pid)
|
||||||
if stat is None:
|
if stat is None:
|
||||||
@@ -330,14 +332,14 @@ class FirecrackerProcessManager:
|
|||||||
f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous"
|
f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous"
|
||||||
)
|
)
|
||||||
if observation == "gone":
|
if observation == "gone":
|
||||||
self._remove_socket(Path(vm.socket))
|
self._remove_runtime_sockets(vm)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pidfd = _open_pidfd(vm.pid)
|
pidfd = _open_pidfd(vm.pid)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
self._reap_child(vm.pid)
|
self._reap_child(vm.pid)
|
||||||
self._remove_socket(Path(vm.socket))
|
self._remove_runtime_sockets(vm)
|
||||||
return
|
return
|
||||||
except (OSError, NotImplementedError) as error:
|
except (OSError, NotImplementedError) as error:
|
||||||
raise FirecrackerError(
|
raise FirecrackerError(
|
||||||
@@ -369,7 +371,7 @@ class FirecrackerProcessManager:
|
|||||||
"process identity and socket retained"
|
"process identity and socket retained"
|
||||||
)
|
)
|
||||||
self._reap_child(vm.pid)
|
self._reap_child(vm.pid)
|
||||||
self._remove_socket(Path(vm.socket))
|
self._remove_runtime_sockets(vm)
|
||||||
except (OSError, NotImplementedError) as error:
|
except (OSError, NotImplementedError) as error:
|
||||||
raise FirecrackerError(
|
raise FirecrackerError(
|
||||||
f"could not safely stop Firecracker process {vm.pid}: {error}"
|
f"could not safely stop Firecracker process {vm.pid}: {error}"
|
||||||
@@ -455,6 +457,10 @@ class FirecrackerProcessManager:
|
|||||||
if process is not None and process.poll() is not None:
|
if process is not None and process.poll() is not None:
|
||||||
self._children.pop(pid, None)
|
self._children.pop(pid, None)
|
||||||
|
|
||||||
|
def _remove_runtime_sockets(self, vm: VmRecord) -> None:
|
||||||
|
self._remove_socket(self._settings.guest_socket_path(vm.id))
|
||||||
|
self._remove_socket(Path(vm.socket))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_socket(socket_path: Path) -> None:
|
def _remove_socket(socket_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
"""Standalone, stdlib-only guest execution agent (Python 3.10+).
|
||||||
|
|
||||||
|
This file is copied into guest images and must not import the host application.
|
||||||
|
The host also imports its wire-format helpers; importing it never starts a server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import errno
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import selectors
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = 1
|
||||||
|
AGENT_PORT = 1070
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 60.0
|
||||||
|
MAX_TIMEOUT_SECONDS = 3600.0
|
||||||
|
MAX_COMMAND_BYTES = 65536
|
||||||
|
MAX_OUTPUT_BYTES = 1024 * 1024 # Combined stdout and stderr, before UTF-8 decoding.
|
||||||
|
MAX_REQUEST_BYTES = 6 * MAX_COMMAND_BYTES + 4096
|
||||||
|
MAX_RESPONSE_BYTES = 6 * MAX_OUTPUT_BYTES + 4096
|
||||||
|
CONNECT_TIMEOUT_SECONDS = 5.0
|
||||||
|
CLEANUP_SECONDS = 5.0
|
||||||
|
MAX_CONCURRENT_COMMANDS = 8
|
||||||
|
MAX_TERMINAL_BYTES = 8192
|
||||||
|
MAX_TERMINAL_FRAME = 16384
|
||||||
|
CONFIG_PATH = Path("/etc/uvm-agent.json")
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(ValueError):
|
||||||
|
"""An invalid or oversized wire message; never include command text."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionFailure(Exception):
|
||||||
|
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.outcome_unknown = outcome_unknown
|
||||||
|
|
||||||
|
|
||||||
|
def validate_terminal_size(rows: object, cols: object) -> None:
|
||||||
|
if type(rows) is not int or type(cols) is not int or not (2 <= rows <= 200 and 2 <= cols <= 400):
|
||||||
|
raise ProtocolError("terminal dimensions must be integers: 2–200 rows and 2–400 columns")
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_data(value: object) -> bytes:
|
||||||
|
if not isinstance(value, str) or len(value) > 4 * ((MAX_TERMINAL_BYTES + 2) // 3):
|
||||||
|
raise ProtocolError("terminal data exceeds its size limit")
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(value, validate=True)
|
||||||
|
except (ValueError, UnicodeError) as error:
|
||||||
|
raise ProtocolError("invalid terminal data") from error
|
||||||
|
if len(data) > MAX_TERMINAL_BYTES:
|
||||||
|
raise ProtocolError("terminal data exceeds its size limit")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_command(command: object, sudo: object, timeout_seconds: object) -> None:
|
||||||
|
if not isinstance(command, str) or not command.strip() or "\0" in command:
|
||||||
|
raise ProtocolError("command must be non-empty text without NUL characters")
|
||||||
|
try:
|
||||||
|
size = len(command.encode("utf-8"))
|
||||||
|
except UnicodeError as error:
|
||||||
|
raise ProtocolError("command must be valid UTF-8 text") from error
|
||||||
|
if size > MAX_COMMAND_BYTES:
|
||||||
|
raise ProtocolError(f"command exceeds {MAX_COMMAND_BYTES} UTF-8 bytes")
|
||||||
|
if type(sudo) is not bool:
|
||||||
|
raise ProtocolError("sudo must be a boolean")
|
||||||
|
try:
|
||||||
|
valid_timeout = (
|
||||||
|
type(timeout_seconds) in (int, float)
|
||||||
|
and math.isfinite(timeout_seconds)
|
||||||
|
and 0 < timeout_seconds <= MAX_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except OverflowError:
|
||||||
|
valid_timeout = False
|
||||||
|
if not valid_timeout:
|
||||||
|
raise ProtocolError(f"timeout_seconds must be finite and greater than 0, up to {MAX_TIMEOUT_SECONDS:g}")
|
||||||
|
|
||||||
|
|
||||||
|
def _remaining(deadline: float) -> float:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise TimeoutError("guest protocol deadline exceeded")
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
|
def receive_exact(connection: socket.socket, size: int, deadline: float) -> bytes:
|
||||||
|
parts = bytearray()
|
||||||
|
while len(parts) < size:
|
||||||
|
connection.settimeout(_remaining(deadline))
|
||||||
|
chunk = connection.recv(size - len(parts))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError("guest connection closed")
|
||||||
|
parts.extend(chunk)
|
||||||
|
return bytes(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def receive_frame(connection: socket.socket, limit: int, deadline: float) -> dict:
|
||||||
|
size, = struct.unpack("!I", receive_exact(connection, 4, deadline))
|
||||||
|
if not 0 < size <= limit:
|
||||||
|
raise ProtocolError("invalid guest message length")
|
||||||
|
try:
|
||||||
|
message = json.loads(receive_exact(connection, size, deadline))
|
||||||
|
except (ValueError, UnicodeError, RecursionError) as error:
|
||||||
|
raise ProtocolError("invalid guest JSON message") from error
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
raise ProtocolError("guest message must be an object")
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def send_frame(connection: socket.socket, message: dict, limit: int, deadline: float) -> None:
|
||||||
|
payload = json.dumps(message, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
|
||||||
|
if len(payload) > limit:
|
||||||
|
raise ProtocolError("guest message exceeds its size limit")
|
||||||
|
connection.settimeout(_remaining(deadline))
|
||||||
|
connection.sendall(struct.pack("!I", len(payload)) + payload)
|
||||||
|
|
||||||
|
|
||||||
|
class JobScope:
|
||||||
|
"""A delegated guest cgroup, keeping detached descendants in the same job."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
entries = Path("/proc/self/cgroup").read_text(encoding="ascii").splitlines()
|
||||||
|
unified = next((entry[3:] for entry in entries if entry.startswith("0::")), None)
|
||||||
|
if unified is None or ".." in Path(unified).parts:
|
||||||
|
raise ExecutionFailure("guest execution requires a delegated cgroup v2 hierarchy")
|
||||||
|
parent = Path("/sys/fs/cgroup") / unified.lstrip("/")
|
||||||
|
self.path = parent / f"uvm-exec-{uuid.uuid4().hex}"
|
||||||
|
self.path.mkdir(mode=0o700)
|
||||||
|
if not (self.path / "cgroup.kill").is_file():
|
||||||
|
self.path.rmdir()
|
||||||
|
raise ExecutionFailure("guest execution requires cgroup.kill (Linux 5.14 or newer)")
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
(self.path / "cgroup.kill").write_text("1", encoding="ascii")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
# cgroup.events reports descendants as well as processes in this cgroup.
|
||||||
|
deadline = time.monotonic() + 1.0
|
||||||
|
while "populated 1" in (self.path / "cgroup.events").read_text(encoding="ascii"):
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
|
||||||
|
time.sleep(0.01)
|
||||||
|
for child in sorted(self.path.rglob("*"), key=lambda path: len(path.parts), reverse=True):
|
||||||
|
if child.is_dir():
|
||||||
|
child.rmdir()
|
||||||
|
self.path.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_group(process: subprocess.Popen, number: int) -> None:
|
||||||
|
# Do not reap the group leader until after signaling: its PID pins the PGID.
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, number)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _exited(process: subprocess.Popen) -> bool:
|
||||||
|
return os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_job(process: subprocess.Popen | None, scope: JobScope) -> None:
|
||||||
|
failed = False
|
||||||
|
try:
|
||||||
|
scope.kill()
|
||||||
|
except (OSError, ExecutionFailure):
|
||||||
|
failed = True
|
||||||
|
if process is not None:
|
||||||
|
try:
|
||||||
|
_signal_group(process, signal.SIGKILL)
|
||||||
|
process.wait(timeout=1.0)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
failed = True
|
||||||
|
finally:
|
||||||
|
for name in ("stdout", "stderr"):
|
||||||
|
stream = getattr(process, name)
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
try:
|
||||||
|
scope.close()
|
||||||
|
except (OSError, ExecutionFailure):
|
||||||
|
failed = True
|
||||||
|
if failed:
|
||||||
|
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=process is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: str, username: str, timeout_seconds: float, *, scope_factory=JobScope) -> dict:
|
||||||
|
"""Run one non-interactive guest job, drain both pipes, and bound its lifetime."""
|
||||||
|
scope = scope_factory()
|
||||||
|
process = None
|
||||||
|
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
||||||
|
captured = 0
|
||||||
|
truncated = False
|
||||||
|
timed_out = False
|
||||||
|
try:
|
||||||
|
# The fresh child joins its scope BEFORE dropping credentials or executing
|
||||||
|
# user text. Avoid preexec_fn in the threaded agent.
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[sys.executable, "-I", str(Path(__file__).resolve()), "--child", str(scope.path), username, command],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True, env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
terminating = None
|
||||||
|
killed = False
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
for name in buffers:
|
||||||
|
stream = getattr(process, name)
|
||||||
|
os.set_blocking(stream.fileno(), False)
|
||||||
|
selector.register(stream, selectors.EVENT_READ, name)
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
exited = _exited(process)
|
||||||
|
if terminating is None and (exited or now >= deadline):
|
||||||
|
timed_out = not exited
|
||||||
|
terminating = now
|
||||||
|
_signal_group(process, signal.SIGTERM if timed_out else signal.SIGKILL)
|
||||||
|
if terminating is not None:
|
||||||
|
if not killed and (exited or now >= terminating + 0.5):
|
||||||
|
scope.kill()
|
||||||
|
_signal_group(process, signal.SIGKILL)
|
||||||
|
killed = True
|
||||||
|
if killed and exited and not selector.get_map():
|
||||||
|
break
|
||||||
|
if now >= terminating + 3.0:
|
||||||
|
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
|
||||||
|
for key, _events in selector.select(0.05):
|
||||||
|
chunk = os.read(key.fileobj.fileno(), 65536)
|
||||||
|
if not chunk:
|
||||||
|
selector.unregister(key.fileobj)
|
||||||
|
continue
|
||||||
|
keep = min(len(chunk), MAX_OUTPUT_BYTES - captured)
|
||||||
|
buffers[key.data].extend(chunk[:keep])
|
||||||
|
captured += keep
|
||||||
|
truncated |= keep < len(chunk)
|
||||||
|
# Finally below kills the scope and reaps the leader before returning.
|
||||||
|
except OSError as error:
|
||||||
|
raise ExecutionFailure("guest command execution failed", outcome_unknown=process is not None) from error
|
||||||
|
finally:
|
||||||
|
_cleanup_job(process, scope)
|
||||||
|
return {
|
||||||
|
"exit_code": None if timed_out else process.returncode,
|
||||||
|
"stdout": buffers["stdout"].decode("utf-8", errors="replace"),
|
||||||
|
"stderr": buffers["stderr"].decode("utf-8", errors="replace"),
|
||||||
|
"timed_out": timed_out,
|
||||||
|
"truncated": truncated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def child_main(scope: Path, username: str, command: str | None) -> None:
|
||||||
|
"""Enter the job cgroup and account in a fresh interpreter, then replace it."""
|
||||||
|
(scope / "cgroup.procs").write_text(str(os.getpid()), encoding="ascii")
|
||||||
|
if command is None:
|
||||||
|
# Popen's start_new_session already performed setsid in this fresh child.
|
||||||
|
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||||
|
account = pwd.getpwnam(username)
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
os.initgroups(account.pw_name, account.pw_gid)
|
||||||
|
os.setgid(account.pw_gid)
|
||||||
|
os.setuid(account.pw_uid)
|
||||||
|
elif (account.pw_uid, account.pw_gid) != (os.geteuid(), os.getegid()):
|
||||||
|
raise PermissionError("guest agent cannot assume the requested account")
|
||||||
|
os.chdir(account.pw_dir)
|
||||||
|
os.umask(0o022)
|
||||||
|
environment = {
|
||||||
|
"HOME": account.pw_dir, "USER": account.pw_name, "LOGNAME": account.pw_name,
|
||||||
|
"SHELL": "/bin/sh", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
|
"LANG": "C.UTF-8",
|
||||||
|
}
|
||||||
|
if command is None:
|
||||||
|
shell = account.pw_shell or "/bin/sh"
|
||||||
|
environment.update(SHELL=shell, TERM="xterm-256color", COLORTERM="truecolor")
|
||||||
|
os.execve(shell, [shell, "-i"], environment)
|
||||||
|
else:
|
||||||
|
os.execve("/bin/sh", ["sh", "-c", command], environment)
|
||||||
|
|
||||||
|
|
||||||
|
def run_terminal(connection: socket.socket, username: str, rows: int, cols: int, *, scope_factory=JobScope) -> None:
|
||||||
|
"""Bridge one real guest PTY until shell exit, explicit close, or host loss."""
|
||||||
|
validate_terminal_size(rows, cols)
|
||||||
|
scope = scope_factory()
|
||||||
|
process = None
|
||||||
|
master = slave = None
|
||||||
|
try:
|
||||||
|
master, slave = os.openpty()
|
||||||
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[sys.executable, "-I", str(Path(__file__).resolve()), "--terminal-child", str(scope.path), username],
|
||||||
|
stdin=slave, stdout=slave, stderr=slave, start_new_session=True,
|
||||||
|
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
|
||||||
|
)
|
||||||
|
os.close(slave)
|
||||||
|
slave = None
|
||||||
|
os.set_blocking(master, False)
|
||||||
|
send_frame(connection, {"type": "ready"}, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
pending = bytearray()
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
selector.register(connection, selectors.EVENT_READ, "host")
|
||||||
|
selector.register(master, selectors.EVENT_READ, "pty")
|
||||||
|
finished = False
|
||||||
|
while not finished:
|
||||||
|
for key, events in selector.select(0.1):
|
||||||
|
if key.data == "host":
|
||||||
|
message = receive_frame(connection, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
if message == {"type": "close"}:
|
||||||
|
finished = True
|
||||||
|
break
|
||||||
|
if set(message) == {"type", "data"} and message["type"] == "input":
|
||||||
|
pending.extend(terminal_data(message["data"]))
|
||||||
|
if len(pending) > 65536:
|
||||||
|
raise ProtocolError("terminal input backlog exceeded")
|
||||||
|
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
|
||||||
|
validate_terminal_size(message["rows"], message["cols"])
|
||||||
|
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", message["rows"], message["cols"], 0, 0))
|
||||||
|
else:
|
||||||
|
raise ProtocolError("invalid terminal control message")
|
||||||
|
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
|
||||||
|
else:
|
||||||
|
if events & selectors.EVENT_READ:
|
||||||
|
try:
|
||||||
|
data = os.read(master, MAX_TERMINAL_BYTES)
|
||||||
|
except OSError as error:
|
||||||
|
if error.errno != errno.EIO:
|
||||||
|
raise
|
||||||
|
data = b""
|
||||||
|
if not data:
|
||||||
|
finished = True
|
||||||
|
break
|
||||||
|
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
|
||||||
|
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
if events & selectors.EVENT_WRITE and pending:
|
||||||
|
try:
|
||||||
|
del pending[:os.write(master, pending)]
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
|
||||||
|
if _exited(process):
|
||||||
|
# Drain the leader's final output before terminating any background jobs.
|
||||||
|
for _ in range(16):
|
||||||
|
try:
|
||||||
|
data = os.read(master, MAX_TERMINAL_BYTES)
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
|
||||||
|
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_cleanup_job(process, scope)
|
||||||
|
finally:
|
||||||
|
for descriptor in (master, slave):
|
||||||
|
if descriptor is not None:
|
||||||
|
os.close(descriptor)
|
||||||
|
send_frame(connection, {"type": "exit", "exit_code": process.returncode}, MAX_TERMINAL_FRAME,
|
||||||
|
time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def serve_connection(connection: socket.socket, config: dict, *, runner=run_command, terminal_runner=run_terminal) -> None:
|
||||||
|
with connection:
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
|
||||||
|
send_frame(connection, {"version": PROTOCOL_VERSION, **config}, 4096, deadline)
|
||||||
|
request = receive_frame(connection, MAX_REQUEST_BYTES, deadline)
|
||||||
|
if set(request) == {"version", "terminal"}:
|
||||||
|
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
|
||||||
|
raise ProtocolError("unsupported guest protocol version")
|
||||||
|
size = request["terminal"]
|
||||||
|
if not isinstance(size, dict) or set(size) != {"rows", "cols"}:
|
||||||
|
raise ProtocolError("invalid terminal request")
|
||||||
|
validate_terminal_size(size["rows"], size["cols"])
|
||||||
|
terminal_runner(connection, config["username"], size["rows"], size["cols"])
|
||||||
|
return
|
||||||
|
if set(request) != {"version", "command", "sudo", "timeout_seconds"}:
|
||||||
|
raise ProtocolError("invalid execution request fields")
|
||||||
|
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
|
||||||
|
raise ProtocolError("unsupported guest protocol version")
|
||||||
|
validate_command(request["command"], request["sudo"], request["timeout_seconds"])
|
||||||
|
username = "root" if request["sudo"] else config["username"]
|
||||||
|
result = runner(request["command"], username, request["timeout_seconds"])
|
||||||
|
send_frame(connection, result, MAX_RESPONSE_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
except (ProtocolError, ExecutionFailure, OSError, KeyError) as error:
|
||||||
|
# Never log request text, guest output, or credentials to the console.
|
||||||
|
try:
|
||||||
|
send_frame(connection, {
|
||||||
|
"error": "guest execution failed: " + str(error),
|
||||||
|
"outcome_unknown": isinstance(error, ExecutionFailure) and error.outcome_unknown,
|
||||||
|
}, 4096, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
except (OSError, ProtocolError):
|
||||||
|
pass
|
||||||
|
except EOFError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path = CONFIG_PATH) -> dict:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
with os.fdopen(descriptor, encoding="utf-8") as source:
|
||||||
|
metadata = os.fstat(source.fileno())
|
||||||
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0
|
||||||
|
or metadata.st_mode & 0o077 or metadata.st_size > 4096):
|
||||||
|
raise ValueError("guest agent configuration must be a root-owned private regular file")
|
||||||
|
config = json.load(source)
|
||||||
|
if (not isinstance(config, dict) or set(config) != {"vm_id", "username"}
|
||||||
|
or not isinstance(config["vm_id"], str)
|
||||||
|
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", config["vm_id"]) is None
|
||||||
|
or not isinstance(config["username"], str)
|
||||||
|
or re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", config["username"]) is None):
|
||||||
|
raise ValueError("invalid guest agent configuration")
|
||||||
|
pwd.getpwnam(config["username"])
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) == 4 and sys.argv[1] == "--terminal-child":
|
||||||
|
try:
|
||||||
|
child_main(Path(sys.argv[2]), sys.argv[3], None)
|
||||||
|
except (OSError, KeyError) as error:
|
||||||
|
print(f"uvm-agent: could not launch guest terminal: {error}", file=sys.stderr)
|
||||||
|
sys.exit(126)
|
||||||
|
return
|
||||||
|
if len(sys.argv) == 5 and sys.argv[1] == "--child":
|
||||||
|
try:
|
||||||
|
child_main(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
|
||||||
|
except (OSError, KeyError) as error:
|
||||||
|
print(f"uvm-agent: could not launch guest command: {error}", file=sys.stderr)
|
||||||
|
sys.exit(126)
|
||||||
|
return
|
||||||
|
if len(sys.argv) != 1 or os.geteuid() != 0:
|
||||||
|
raise SystemExit("uvm-agent must run as a root systemd service")
|
||||||
|
config = load_config()
|
||||||
|
slots = threading.BoundedSemaphore(MAX_CONCURRENT_COMMANDS)
|
||||||
|
|
||||||
|
def handle(connection: socket.socket) -> None:
|
||||||
|
try:
|
||||||
|
serve_connection(connection, config)
|
||||||
|
finally:
|
||||||
|
slots.release()
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) as listener:
|
||||||
|
listener.bind((socket.VMADDR_CID_ANY, AGENT_PORT))
|
||||||
|
listener.listen(MAX_CONCURRENT_COMMANDS)
|
||||||
|
while True:
|
||||||
|
connection, peer = listener.accept()
|
||||||
|
# Guest-local callers must not use the root agent to elevate themselves.
|
||||||
|
if peer[0] != socket.VMADDR_CID_HOST:
|
||||||
|
connection.close()
|
||||||
|
continue
|
||||||
|
if not slots.acquire(blocking=False):
|
||||||
|
connection.close()
|
||||||
|
continue
|
||||||
|
threading.Thread(target=handle, args=(connection,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Host-side, one-shot guest execution over Firecracker's vsock Unix socket."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
|
||||||
|
from .guest_agent import (
|
||||||
|
AGENT_PORT, CLEANUP_SECONDS, CONNECT_TIMEOUT_SECONDS, MAX_OUTPUT_BYTES,
|
||||||
|
MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION, ProtocolError,
|
||||||
|
receive_exact, receive_frame, send_frame, validate_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExecResult:
|
||||||
|
exit_code: int | None
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
timed_out: bool
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exec(command: object, sudo: object, timeout_seconds: object) -> None:
|
||||||
|
try:
|
||||||
|
validate_command(command, sudo, timeout_seconds)
|
||||||
|
except ProtocolError as error:
|
||||||
|
raise ValidationError(str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
class GuestSession:
|
||||||
|
"""A connected session pins the selected VMM; never reconnect after submission."""
|
||||||
|
|
||||||
|
def __init__(self, connection: socket.socket) -> None:
|
||||||
|
self._connection = connection
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def connect(cls, path: Path, vm_id: str, username: str, pid: int) -> GuestSession:
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
|
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISSOCK(metadata.st_mode):
|
||||||
|
raise ProtocolError("guest socket is not a Unix socket")
|
||||||
|
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
|
||||||
|
connection.settimeout(CONNECT_TIMEOUT_SECONDS)
|
||||||
|
connection.connect(str(path))
|
||||||
|
peer_pid, _uid, _gid = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||||
|
if peer_pid != pid:
|
||||||
|
raise ProtocolError("guest socket does not belong to the selected VM process")
|
||||||
|
connection.sendall(f"CONNECT {AGENT_PORT}\n".encode("ascii"))
|
||||||
|
acknowledgement = bytearray()
|
||||||
|
while not acknowledgement.endswith(b"\n") and len(acknowledgement) < 32:
|
||||||
|
acknowledgement.extend(receive_exact(connection, 1, deadline))
|
||||||
|
parts = bytes(acknowledgement).split()
|
||||||
|
if (not acknowledgement.endswith(b"\n") or len(parts) != 2
|
||||||
|
or parts[0] != b"OK" or not parts[1].isdigit()):
|
||||||
|
raise ProtocolError("invalid Firecracker vsock acknowledgement")
|
||||||
|
hello = receive_frame(connection, 4096, deadline)
|
||||||
|
if (set(hello) != {"version", "vm_id", "username"}
|
||||||
|
or type(hello["version"]) is not int or hello["version"] != PROTOCOL_VERSION
|
||||||
|
or hello["vm_id"] != vm_id or hello["username"] != username):
|
||||||
|
raise ProtocolError("guest agent identity or protocol version does not match this VM")
|
||||||
|
return cls(connection)
|
||||||
|
except (OSError, EOFError, ProtocolError) as error:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
raise GuestAgentUnavailableError(
|
||||||
|
"guest agent is unavailable or not ready; wait for guest startup, or restart the VM "
|
||||||
|
"to apply automatic vsock guest-agent setup (vsock-capable kernel required)"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
|
||||||
|
validate_exec(command, sudo, timeout_seconds)
|
||||||
|
try:
|
||||||
|
send_frame(self._connection, {
|
||||||
|
"version": PROTOCOL_VERSION, "command": command,
|
||||||
|
"sudo": sudo, "timeout_seconds": timeout_seconds,
|
||||||
|
}, MAX_REQUEST_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||||
|
result = receive_frame(
|
||||||
|
self._connection, MAX_RESPONSE_BYTES,
|
||||||
|
time.monotonic() + timeout_seconds + CLEANUP_SECONDS + CONNECT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
if (set(result) != {"error", "outcome_unknown"} or not isinstance(result["error"], str)
|
||||||
|
or len(result["error"]) > 4096 or type(result["outcome_unknown"]) is not bool):
|
||||||
|
raise ProtocolError("invalid guest failure response")
|
||||||
|
result["error"].encode("utf-8") # Reject invalid Unicode before rendering an HTTP error.
|
||||||
|
raise GuestExecutionError(result["error"], outcome_unknown=result["outcome_unknown"])
|
||||||
|
if set(result) != {"exit_code", "stdout", "stderr", "timed_out", "truncated"}:
|
||||||
|
raise ProtocolError("invalid guest result fields")
|
||||||
|
if (type(result["timed_out"]) is not bool or type(result["truncated"]) is not bool
|
||||||
|
or not isinstance(result["stdout"], str) or not isinstance(result["stderr"], str)
|
||||||
|
or (result["timed_out"] and result["exit_code"] is not None)
|
||||||
|
or (not result["timed_out"] and (
|
||||||
|
type(result["exit_code"]) is not int or not -64 <= result["exit_code"] <= 255))):
|
||||||
|
raise ProtocolError("invalid guest result types")
|
||||||
|
if sum(len(result[name].encode("utf-8")) for name in ("stdout", "stderr")) > 3 * MAX_OUTPUT_BYTES:
|
||||||
|
raise ProtocolError("guest output exceeds its limit")
|
||||||
|
return ExecResult(**result)
|
||||||
|
except (OSError, EOFError, ProtocolError, UnicodeError) as error:
|
||||||
|
raise GuestExecutionError(
|
||||||
|
"guest execution connection was lost or returned an invalid result; "
|
||||||
|
"the command may have run and will not be retried",
|
||||||
|
outcome_unknown=True,
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._connection.close()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Interactive, process-pinned guest terminal transport over the existing vsock agent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
|
||||||
|
from .guest_agent import (
|
||||||
|
CONNECT_TIMEOUT_SECONDS, MAX_TERMINAL_FRAME, PROTOCOL_VERSION, ProtocolError,
|
||||||
|
receive_frame, send_frame, terminal_data, validate_terminal_size,
|
||||||
|
)
|
||||||
|
from .guest_exec import GuestSession
|
||||||
|
|
||||||
|
|
||||||
|
def validate_size(rows: object, cols: object) -> None:
|
||||||
|
try:
|
||||||
|
validate_terminal_size(rows, cols)
|
||||||
|
except ProtocolError as error:
|
||||||
|
raise ValidationError(str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
class GuestTerminal(GuestSession):
|
||||||
|
def start(self, rows: int, cols: int) -> None:
|
||||||
|
validate_size(rows, cols)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
|
||||||
|
send_frame(self._connection, {"version": PROTOCOL_VERSION, "terminal": {"rows": rows, "cols": cols}},
|
||||||
|
MAX_TERMINAL_FRAME, deadline)
|
||||||
|
response = receive_frame(self._connection, MAX_TERMINAL_FRAME, deadline)
|
||||||
|
if response != {"type": "ready"}:
|
||||||
|
raise ProtocolError("terminal support is unavailable")
|
||||||
|
self._connection.setblocking(False)
|
||||||
|
self._write_lock = asyncio.Lock()
|
||||||
|
except (OSError, EOFError, ProtocolError) as error:
|
||||||
|
self.close()
|
||||||
|
raise GuestAgentUnavailableError(
|
||||||
|
"Interactive terminal support is unavailable. Restart this VM to apply automatic guest-agent setup, "
|
||||||
|
"then wait for the agent to start and launch a new session."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
async def _receive_exact(self, size: int) -> bytes:
|
||||||
|
data = bytearray()
|
||||||
|
while len(data) < size:
|
||||||
|
chunk = await asyncio.get_running_loop().sock_recv(self._connection, size - len(data))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError("guest terminal connection closed")
|
||||||
|
data.extend(chunk)
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
async def read(self) -> dict:
|
||||||
|
try:
|
||||||
|
# An idle shell has no output deadline; partial messages are bounded.
|
||||||
|
first = await self._receive_exact(1)
|
||||||
|
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
|
||||||
|
size, = struct.unpack("!I", first + await self._receive_exact(3))
|
||||||
|
if not 0 < size <= MAX_TERMINAL_FRAME:
|
||||||
|
raise ProtocolError("invalid terminal frame length")
|
||||||
|
message = json.loads(await self._receive_exact(size))
|
||||||
|
if isinstance(message, dict):
|
||||||
|
if set(message) == {"type", "data"} and message["type"] == "output":
|
||||||
|
return {"type": "output", "data": terminal_data(message["data"])}
|
||||||
|
if (set(message) == {"type", "exit_code"} and message["type"] == "exit"
|
||||||
|
and type(message["exit_code"]) is int and -64 <= message["exit_code"] <= 255):
|
||||||
|
return message
|
||||||
|
raise ProtocolError("invalid guest terminal response")
|
||||||
|
except (OSError, EOFError, ValueError, UnicodeError, RecursionError) as error:
|
||||||
|
raise GuestExecutionError("The guest terminal connection ended unexpectedly.", outcome_unknown=True) from error
|
||||||
|
|
||||||
|
async def _send(self, message: dict) -> None:
|
||||||
|
payload = json.dumps(message, separators=(",", ":")).encode("ascii")
|
||||||
|
if len(payload) > MAX_TERMINAL_FRAME:
|
||||||
|
raise ValidationError("terminal input exceeds its size limit")
|
||||||
|
async with self._write_lock:
|
||||||
|
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
|
||||||
|
await asyncio.get_running_loop().sock_sendall(self._connection, struct.pack("!I", len(payload)) + payload)
|
||||||
|
|
||||||
|
async def write(self, data: bytes) -> None:
|
||||||
|
await self._send({"type": "input", "data": base64.b64encode(data).decode("ascii")})
|
||||||
|
|
||||||
|
async def resize(self, rows: int, cols: int) -> None:
|
||||||
|
validate_size(rows, cols)
|
||||||
|
await self._send({"type": "resize", "rows": rows, "cols": cols})
|
||||||
|
|
||||||
|
async def end(self) -> None:
|
||||||
|
await self._send({"type": "close"})
|
||||||
+343
-3
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import stat
|
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 . import guest_agent
|
||||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||||
from .errors import UvmError, ValidationError
|
from .errors import UvmError, ValidationError
|
||||||
from .integrity import load_manifest, verify_file
|
from .integrity import load_manifest, verify_file
|
||||||
@@ -35,6 +39,69 @@ _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",
|
||||||
|
)
|
||||||
|
|
||||||
|
GUEST_AGENT_SERVICE = """\
|
||||||
|
[Unit]
|
||||||
|
Description=UVM guest command execution agent
|
||||||
|
After=local-fs.target network.target
|
||||||
|
ConditionPathExists=/etc/uvm-agent.json
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStartPre=/bin/sh /usr/lib/uvm/prepare-agent.sh
|
||||||
|
ExecStart=/usr/bin/python3 -I /usr/lib/uvm/agent.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStartSec=600
|
||||||
|
TimeoutStopSec=5
|
||||||
|
KillMode=control-group
|
||||||
|
Delegate=yes
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Runs only INSIDE the VM. Old Jammy guests can acquire the runtime without SSH
|
||||||
|
# or executing a retained guest's binaries in a privileged host chroot.
|
||||||
|
GUEST_AGENT_PREPARE = """\
|
||||||
|
set -eu
|
||||||
|
check_python() {
|
||||||
|
/usr/bin/python3 -I -c 'import sys, json, math, pwd, selectors, socket, struct, subprocess, threading, uuid; assert sys.version_info >= (3, 10)' 2>/dev/null
|
||||||
|
}
|
||||||
|
if check_python; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
export DEBIAN_FRONTEND=noninteractive LC_ALL=C
|
||||||
|
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -o APT::Update::Error-Mode=any update
|
||||||
|
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -y --no-install-recommends install python3
|
||||||
|
check_python
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# The original v4 template shipped this exact agent without an ownership
|
||||||
|
# manifest. Recognize that payload when creating from a cached v4 template.
|
||||||
|
_V4_TEMPLATE_AGENT_SHA256 = "dfdd4bd6ee3dc06fbdbda6402cb2c54921cff22190d8da889209559800f8b948"
|
||||||
|
|
||||||
|
|
||||||
|
def guest_agent_files() -> dict[str, bytes]:
|
||||||
|
"""The same standalone payload is used by the builder and offline upgrades."""
|
||||||
|
return {
|
||||||
|
"/usr/lib/uvm/agent.py": Path(guest_agent.__file__).read_bytes(),
|
||||||
|
"/usr/lib/uvm/prepare-agent.sh": GUEST_AGENT_PREPARE.encode("utf-8"),
|
||||||
|
"/etc/systemd/system/uvm-agent.service": GUEST_AGENT_SERVICE.encode("utf-8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class GuestAssets:
|
class GuestAssets:
|
||||||
@@ -150,6 +217,239 @@ 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",
|
||||||
|
"uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\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
|
||||||
|
|
||||||
|
def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool:
|
||||||
|
"""Ensure every guest has its vsock agent before booting its private disk.
|
||||||
|
|
||||||
|
The lifecycle lock and confirmed VMM exit are required. A checked sparse
|
||||||
|
copy preserves the original disk on failure and makes retries atomic.
|
||||||
|
Return False only when the managed files and service are already current.
|
||||||
|
An explicit install request always stages and rechecks the installation.
|
||||||
|
"""
|
||||||
|
parse_username(username)
|
||||||
|
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None:
|
||||||
|
raise ValidationError("invalid VM ID for guest agent configuration")
|
||||||
|
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("private VM disk is unsafe for guest agent installation")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name:
|
||||||
|
files = Path(name)
|
||||||
|
if not install and self._guest_agent_current(disk, files, vm_id, username):
|
||||||
|
return False
|
||||||
|
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", 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):
|
||||||
|
raise UvmError("filesystem check failed before guest agent installation")
|
||||||
|
# All ownership/content checks happen AFTER journal replay.
|
||||||
|
self._install_agent_files(temporary, files, vm_id, username)
|
||||||
|
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 provision guest agent: {error}") from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _agent_payloads(vm_id: str, username: str) -> dict[str, bytes]:
|
||||||
|
payloads = guest_agent_files()
|
||||||
|
payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8")
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _guest_agent_current(self, disk: Path, files: Path, vm_id: str, username: str) -> bool:
|
||||||
|
"""Read-only fast path; changed installations are checked again after journal replay."""
|
||||||
|
payloads = self._agent_payloads(vm_id, username)
|
||||||
|
for index, (path, content) in enumerate(payloads.items()):
|
||||||
|
existing = self._agent_file(disk, path, files / f"current-{index}")
|
||||||
|
mode = 0o600 if path.endswith(".json") else 0o644
|
||||||
|
if (existing is None or (existing.mode, existing.gid) != (mode, 0)
|
||||||
|
or existing.path.read_bytes() != content):
|
||||||
|
return False
|
||||||
|
manifest = self._agent_file(disk, "/etc/uvm-agent-files.json", files / "current-manifest")
|
||||||
|
if manifest is None or (manifest.mode, manifest.gid) != (0o600, 0):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
hashes = json.loads(manifest.path.read_bytes())
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return False
|
||||||
|
if hashes != {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}:
|
||||||
|
return False
|
||||||
|
service = self._runner.run((
|
||||||
|
"debugfs", "-R", "stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service", disk,
|
||||||
|
), capture=True)
|
||||||
|
return (re.search(r"Type:\s+symlink\b", service.stdout) is not None
|
||||||
|
and 'Fast link dest: "/etc/systemd/system/uvm-agent.service"' in service.stdout)
|
||||||
|
|
||||||
|
def _install_agent_files(self, disk: Path, files: Path, vm_id: str, username: str) -> None:
|
||||||
|
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
|
||||||
|
result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True)
|
||||||
|
if re.search(r"Type:\s+regular\b", result.stdout) is None:
|
||||||
|
raise UvmError(
|
||||||
|
"automatic vsock guest-agent setup requires a systemd/apt image (Ubuntu 22.04 or newer); "
|
||||||
|
"use the default Ubuntu rootfs or a compatible custom image"
|
||||||
|
)
|
||||||
|
for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd",
|
||||||
|
"/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"):
|
||||||
|
self._agent_directory(disk, directory)
|
||||||
|
payloads = self._agent_payloads(vm_id, username)
|
||||||
|
manifest_path = "/etc/uvm-agent-files.json"
|
||||||
|
previous = self._agent_file(disk, manifest_path, files / "manifest")
|
||||||
|
old_hashes = {}
|
||||||
|
if previous is not None:
|
||||||
|
try:
|
||||||
|
old_hashes = json.loads(previous.path.read_bytes())
|
||||||
|
except (ValueError, UnicodeError) as error:
|
||||||
|
raise UvmError("invalid guest agent ownership manifest") from error
|
||||||
|
if (not isinstance(old_hashes, dict) or set(old_hashes) - set(payloads)
|
||||||
|
or any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None
|
||||||
|
for value in old_hashes.values())):
|
||||||
|
raise UvmError("invalid guest agent ownership manifest")
|
||||||
|
hashes = {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}
|
||||||
|
for index, (path, content) in enumerate(payloads.items()):
|
||||||
|
existing = self._agent_file(disk, path, files / f"existing-{index}")
|
||||||
|
if existing is not None:
|
||||||
|
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
|
||||||
|
allowed = {hashes[path], old_hashes.get(path)}
|
||||||
|
if not old_hashes and path == "/usr/lib/uvm/agent.py":
|
||||||
|
allowed.add(_V4_TEMPLATE_AGENT_SHA256)
|
||||||
|
if digest not in allowed:
|
||||||
|
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
|
||||||
|
source = files / f"payload-{index}"
|
||||||
|
source.write_bytes(content)
|
||||||
|
self._write_guest_file(disk, path, _GuestFile(source, 0o600 if path.endswith(".json") else 0o644, 0, 0))
|
||||||
|
source = files / "new-manifest"
|
||||||
|
source.write_text(json.dumps(hashes, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
self._write_guest_file(disk, manifest_path, _GuestFile(source, 0o600, 0, 0))
|
||||||
|
link = "/etc/systemd/system/multi-user.target.wants/uvm-agent.service"
|
||||||
|
target = "/etc/systemd/system/uvm-agent.service"
|
||||||
|
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
|
||||||
|
if "file not found" in (result.stdout + result.stderr).lower():
|
||||||
|
self._runner.run(("debugfs", "-w", "-R", f"symlink {link} {target}", disk), capture=True)
|
||||||
|
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
|
||||||
|
if (re.search(r"Type:\s+symlink\b", result.stdout) is None
|
||||||
|
or f'Fast link dest: "{target}"' not in result.stdout):
|
||||||
|
raise UvmError("could not enable guest agent without replacing a customized service link")
|
||||||
|
|
||||||
|
def _agent_directory(self, disk: Path, path: str) -> None:
|
||||||
|
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
|
||||||
|
if "file not found" in (result.stdout + result.stderr).lower():
|
||||||
|
self._create_guest_directory(disk, path, 0o755, 0, 0)
|
||||||
|
return
|
||||||
|
mode, owner = _MODE_PATTERN.search(result.stdout), _OWNER_PATTERN.search(result.stdout)
|
||||||
|
if (re.search(r"Type:\s+directory\b", result.stdout) is None or mode is None or owner is None
|
||||||
|
or int(owner.group(1)) != 0 or int(mode.group(1), 8) & 0o022):
|
||||||
|
raise UvmError(f"unsafe guest agent directory: {path}")
|
||||||
|
|
||||||
|
def _agent_file(self, disk: Path, path: str, destination: Path) -> _GuestFile | None:
|
||||||
|
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
|
||||||
|
if "file not found" in (result.stdout + result.stderr).lower():
|
||||||
|
return None
|
||||||
|
if re.search(r"Type:\s+regular\b", result.stdout) is None:
|
||||||
|
raise UvmError(f"unsafe guest agent file: {path}")
|
||||||
|
source = self._read_guest_file(disk, path, destination)
|
||||||
|
if (source.uid != 0 or source.mode & 0o022 or source.links != 1 or source.has_extended_attributes):
|
||||||
|
raise UvmError(f"unsafe guest agent file: {path}")
|
||||||
|
return source
|
||||||
|
|
||||||
@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 +526,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 +546,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 +697,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 +895,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 +916,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+238
-11
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -13,12 +16,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, guest_agent_files
|
||||||
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-v5\n"
|
||||||
|
_GUEST_PACKAGES = (
|
||||||
|
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
|
||||||
|
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", "python3",
|
||||||
|
)
|
||||||
|
# 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 +102,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 +128,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 +148,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 +225,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 +232,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 +252,188 @@ 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()
|
||||||
|
agent_payloads = guest_agent_files()
|
||||||
|
for guest_path, contents in agent_payloads.items():
|
||||||
|
path = root / guest_path.lstrip("/")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.parent.chmod(0o755)
|
||||||
|
path.write_bytes(contents)
|
||||||
|
path.chmod(0o644)
|
||||||
|
manifest = root / "etc/uvm-agent-files.json"
|
||||||
|
manifest.write_text(json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in agent_payloads.items()},
|
||||||
|
sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
manifest.chmod(0o600)
|
||||||
|
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service",
|
||||||
|
"uvm-agent.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",
|
||||||
|
"usr/bin/python3", "usr/lib/uvm/agent.py", "etc/systemd/system/uvm-agent.service",
|
||||||
|
):
|
||||||
|
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", "python3"}.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,
|
||||||
|
|||||||
+73
-1
@@ -17,6 +17,9 @@ from .errors import ConflictError, NotFoundError, UvmError, ValidationError
|
|||||||
from .firecracker.api import FirecrackerClient
|
from .firecracker.api import FirecrackerClient
|
||||||
from .firecracker.config import build_config, write_config
|
from .firecracker.config import build_config, write_config
|
||||||
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
|
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
|
||||||
|
from .guest_agent import DEFAULT_TIMEOUT_SECONDS
|
||||||
|
from .guest_exec import ExecResult, GuestSession, validate_exec
|
||||||
|
from .guest_terminal import GuestTerminal, validate_size
|
||||||
from .images import ImageStore
|
from .images import ImageStore
|
||||||
from .integrity import load_manifest, verify_file
|
from .integrity import load_manifest, verify_file
|
||||||
from .known_hosts import KnownHostsManager
|
from .known_hosts import KnownHostsManager
|
||||||
@@ -58,6 +61,8 @@ class LifecycleService:
|
|||||||
network: NetworkManager, process: FirecrackerProcessManager, *,
|
network: NetworkManager, process: FirecrackerProcessManager, *,
|
||||||
known_hosts: KnownHostsManager,
|
known_hosts: KnownHostsManager,
|
||||||
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
|
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
|
||||||
|
guest_connector: Callable[[Path, str, str, int], GuestSession] = GuestSession.connect,
|
||||||
|
terminal_connector: Callable[[Path, str, str, int], GuestTerminal] = GuestTerminal.connect,
|
||||||
clock: Callable[[], float] = time.time,
|
clock: Callable[[], float] = time.time,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
@@ -67,6 +72,8 @@ class LifecycleService:
|
|||||||
self._process = process
|
self._process = process
|
||||||
self._known_hosts = known_hosts
|
self._known_hosts = known_hosts
|
||||||
self._client_factory = client_factory
|
self._client_factory = client_factory
|
||||||
|
self._guest_connector = guest_connector
|
||||||
|
self._terminal_connector = terminal_connector
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
|
|
||||||
def create(self, spec: VmSpec) -> VmRecord:
|
def create(self, spec: VmSpec) -> VmRecord:
|
||||||
@@ -89,6 +96,8 @@ 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)
|
||||||
|
self._images.provision_guest_agent(disk, vm.id, vm.username)
|
||||||
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 +186,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 +197,8 @@ 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)
|
||||||
|
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username)
|
||||||
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 +290,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}):
|
||||||
@@ -486,6 +503,59 @@ class LifecycleService:
|
|||||||
def find_for_ssh(self, identifier: str) -> VmRecord:
|
def find_for_ssh(self, identifier: str) -> VmRecord:
|
||||||
return self._state_store.find_by_id_or_ip(identifier)
|
return self._state_store.find_by_id_or_ip(identifier)
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self, vm_id: str, command: str, *, sudo: bool = False,
|
||||||
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
) -> ExecResult:
|
||||||
|
"""Pin a running VM's guest connection under the lock, then wait unlocked."""
|
||||||
|
require_root()
|
||||||
|
validate_exec(command, sudo, timeout_seconds)
|
||||||
|
with self._state_store.operation_lock():
|
||||||
|
vm = self._load_for_operation(vm_id, exact_id=True)
|
||||||
|
self._not_deleting(vm)
|
||||||
|
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
|
||||||
|
or self._process.observe(vm) != "alive"):
|
||||||
|
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
|
||||||
|
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
|
||||||
|
try:
|
||||||
|
# This session stays attached to that VMM even if the VM is stopped,
|
||||||
|
# deleted, or restarted concurrently. Never reconnect/retry a command.
|
||||||
|
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
|
||||||
|
"""Connect to the exact running VMM under the lock, then open its guest PTY."""
|
||||||
|
require_root()
|
||||||
|
validate_size(rows, cols)
|
||||||
|
with self._state_store.operation_lock():
|
||||||
|
vm = self._load_for_operation(vm_id, exact_id=True)
|
||||||
|
self._not_deleting(vm)
|
||||||
|
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
|
||||||
|
or self._process.observe(vm) != "alive"):
|
||||||
|
raise ConflictError("terminal sessions require a running VM with no pending lifecycle operation")
|
||||||
|
terminal = self._terminal_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
|
||||||
|
try:
|
||||||
|
terminal.start(rows, cols)
|
||||||
|
return terminal, vm.username
|
||||||
|
except BaseException:
|
||||||
|
terminal.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def install_guest_agent(self, vm_id: str) -> VmRecord:
|
||||||
|
"""Explicitly install/upgrade the agent on a cleanly stopped private disk."""
|
||||||
|
require_root()
|
||||||
|
with self._state_store.operation_lock():
|
||||||
|
vm = self._load_for_operation(vm_id, exact_id=True)
|
||||||
|
self._not_deleting(vm)
|
||||||
|
if (vm.status != "stopped" or vm.operation is not None or vm.pending_resize is not None
|
||||||
|
or self._process.observe(vm) != "gone"):
|
||||||
|
raise ConflictError("stop the VM and finish pending operations before installing the guest agent")
|
||||||
|
if not vm.disk_ready or not vm.disk:
|
||||||
|
raise ConflictError("guest agent installation requires a completely provisioned private disk")
|
||||||
|
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username, install=True)
|
||||||
|
return vm
|
||||||
|
|
||||||
def log_path(self, identifier: str) -> Path:
|
def log_path(self, identifier: str) -> Path:
|
||||||
vm = self._state_store.find_by_id_or_ip(identifier)
|
vm = self._state_store.find_by_id_or_ip(identifier)
|
||||||
self._validate_paths(vm)
|
self._validate_paths(vm)
|
||||||
@@ -558,6 +628,8 @@ class LifecycleService:
|
|||||||
directory = self._settings.vm_dir(vm.id).absolute()
|
directory = self._settings.vm_dir(vm.id).absolute()
|
||||||
if directory.is_symlink():
|
if directory.is_symlink():
|
||||||
raise ConflictError("VM directory must not be a symbolic link")
|
raise ConflictError("VM directory must not be a symbolic link")
|
||||||
|
if self._settings.guest_socket_path(vm.id).is_symlink():
|
||||||
|
raise ConflictError("unsafe guest vsock path")
|
||||||
for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"),
|
for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"),
|
||||||
("log", "firecracker.log"), ("disk", "rootfs.ext4")):
|
("log", "firecracker.log"), ("disk", "rootfs.ext4")):
|
||||||
path = getattr(vm, field)
|
path = getattr(vm, field)
|
||||||
|
|||||||
+29
-102
@@ -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)
|
||||||
|
|||||||
@@ -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 nest --serve or uvm --serve",
|
||||||
|
)
|
||||||
|
background_tasks.add_task(_request_server_exit, server)
|
||||||
|
return ServerExitResponse()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_server_exit(server: Any) -> None:
|
||||||
|
server.should_exit = True
|
||||||
@@ -9,8 +9,11 @@ from ..api_models import (
|
|||||||
PortSyncResponse,
|
PortSyncResponse,
|
||||||
PortUpdateRequest,
|
PortUpdateRequest,
|
||||||
VmActionResultResponse,
|
VmActionResultResponse,
|
||||||
|
VmAgentInstallResponse,
|
||||||
VmBulkActionRequest,
|
VmBulkActionRequest,
|
||||||
VmCreateRequest,
|
VmCreateRequest,
|
||||||
|
VmExecRequest,
|
||||||
|
VmExecResponse,
|
||||||
VmLogResponse,
|
VmLogResponse,
|
||||||
VmResponse,
|
VmResponse,
|
||||||
VmResizeRequest,
|
VmResizeRequest,
|
||||||
@@ -202,6 +205,32 @@ def sync_ports(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{vm_id}/exec", response_model=VmExecResponse)
|
||||||
|
def execute_in_vm(
|
||||||
|
vm_id: str,
|
||||||
|
request: VmExecRequest,
|
||||||
|
application: Application = Depends(get_authorized_application),
|
||||||
|
) -> VmExecResponse:
|
||||||
|
"""Execute a bounded, non-interactive guest command over vsock (exact VM ID)."""
|
||||||
|
result = application.lifecycle.execute(
|
||||||
|
vm_id, request.command, sudo=request.sudo, timeout_seconds=request.timeout_seconds,
|
||||||
|
)
|
||||||
|
return VmExecResponse(
|
||||||
|
exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr,
|
||||||
|
timed_out=result.timed_out, truncated=result.truncated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{vm_id}/agent/install", response_model=VmAgentInstallResponse)
|
||||||
|
def install_guest_agent(
|
||||||
|
vm_id: str,
|
||||||
|
application: Application = Depends(get_authorized_application),
|
||||||
|
) -> VmAgentInstallResponse:
|
||||||
|
"""Install/upgrade the execution agent on a stopped VM's retained private disk."""
|
||||||
|
vm = application.lifecycle.install_guest_agent(vm_id)
|
||||||
|
return VmAgentInstallResponse(id=vm.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{identifier}/logs", response_model=VmLogResponse)
|
@router.get("/{identifier}/logs", response_model=VmLogResponse)
|
||||||
def get_vm_logs(
|
def get_vm_logs(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
|
|||||||
+27
-15
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import ssl
|
import ssl
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .app import Application, build_application
|
from .app import Application, build_application
|
||||||
from .errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
|
from .errors import (
|
||||||
|
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
|
||||||
|
NotFoundError, UvmError, ValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
|
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
|
||||||
@@ -27,18 +31,17 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
except ModuleNotFoundError as error:
|
except ModuleNotFoundError as error:
|
||||||
raise UvmError(
|
raise UvmError(
|
||||||
"FastAPI server support is not installed. Install this project with its dependencies."
|
"FastAPI server support is not installed. Install this project with its dependencies."
|
||||||
) 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
|
||||||
from .routers.dashboard import router as dashboard_router
|
|
||||||
|
|
||||||
api = FastAPI(
|
api = FastAPI(
|
||||||
title="uvm",
|
title="uvm",
|
||||||
@@ -49,28 +52,30 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
|
|
||||||
@api.exception_handler(UvmError)
|
@api.exception_handler(UvmError)
|
||||||
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
|
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
|
||||||
|
detail: dict[str, Any] = {"message": str(error)}
|
||||||
|
if isinstance(error, GuestExecutionError):
|
||||||
|
detail["outcome_unknown"] = error.outcome_unknown
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=_http_status_for(error),
|
status_code=_http_status_for(error),
|
||||||
content={"error": {"message": str(error)}},
|
content={"error": detail},
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
static_dir = Path(__file__).parent / "dashboard" / "static"
|
|
||||||
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
|
|
||||||
api.include_router(dashboard_router, prefix="/dashboard")
|
|
||||||
|
|
||||||
@api.get("/")
|
|
||||||
async def root() -> Any:
|
|
||||||
return RedirectResponse(url="/dashboard")
|
|
||||||
|
|
||||||
return api
|
return api
|
||||||
|
|
||||||
|
|
||||||
def run_server(application: Application, *, host: str, port: int) -> None:
|
def run_server(
|
||||||
|
application: Application,
|
||||||
|
*,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
api_factory: Callable[..., Any] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Run Uvicorn after enforcing the local-management security boundary."""
|
"""Run Uvicorn after enforcing the local-management security boundary."""
|
||||||
|
|
||||||
settings = application.settings
|
settings = application.settings
|
||||||
@@ -91,7 +96,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 = (api_factory or 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:
|
||||||
@@ -128,6 +136,10 @@ def _validate_server_settings(
|
|||||||
|
|
||||||
|
|
||||||
def _http_status_for(error: UvmError) -> int:
|
def _http_status_for(error: UvmError) -> int:
|
||||||
|
if isinstance(error, GuestAgentUnavailableError):
|
||||||
|
return 503
|
||||||
|
if isinstance(error, GuestExecutionError):
|
||||||
|
return 502
|
||||||
if isinstance(error, NotFoundError):
|
if isinstance(error, NotFoundError):
|
||||||
return 404
|
return 404
|
||||||
if isinstance(error, ConflictError):
|
if isinstance(error, ConflictError):
|
||||||
|
|||||||
Reference in New Issue
Block a user