diff --git a/.gitignore b/.gitignore index e0d736d..487c6e4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ debug/ target/ +# Python bytecode from opt-in VM test helpers. +__pycache__/ + # These are backup files generated by rustfmt **/*.rs.bk diff --git a/PLANS.md b/PLANS.md index 5152597..05d185a 100644 --- a/PLANS.md +++ b/PLANS.md @@ -108,17 +108,17 @@ Actual systemd/D-Bus acceptance runs in a disposable VM. Never use production to Each row is a delivery milestone, not a completion claim. Expand its record before execution; retain its original ID and acceptance scope. -| ID | Milestone | Prerequisites | State | -| --- | -------------------------------------------------------- | ----------------------------------------- | -------- | -| 1 | Rust foundation, settings, and domain states | None | accepted | -| 2 | Server configuration editing with byte preservation | 1 | accepted | -| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted | -| 4 | Backups, safe Commit, and full restoration | 3 | accepted | -| 5 | Runtime observation and systemd restart adapter | 4 | accepted | -| 6 | Client configuration delivery and reissue lifecycle | 5 | accepted | -| 7 | Complete terminal management workflow | 6 | accepted | -| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted | -| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | pending | +| ID | Milestone | Prerequisites | State | +| --- | -------------------------------------------------------- | ----------------------------------------- | ----------- | +| 1 | Rust foundation, settings, and domain states | None | accepted | +| 2 | Server configuration editing with byte preservation | 1 | accepted | +| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted | +| 4 | Backups, safe Commit, and full restoration | 3 | accepted | +| 5 | Runtime observation and systemd restart adapter | 4 | accepted | +| 6 | Client configuration delivery and reissue lifecycle | 5 | accepted | +| 7 | Complete terminal management workflow | 6 | accepted | +| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted | +| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | in_progress | ### Milestone 1 — Rust foundation, settings, and domain states @@ -2981,13 +2981,302 @@ Milestone 9 remains pending. ### Milestone 9 — Real systemd acceptance and release readiness -Prepare a repeatable disposable VM procedure and run the actual application/systemd adapter against synthetic WireGuard state. Finish operating/recovery documentation, safe settings examples, native target release build, and review of executable runtime dependencies. +Status: paused (2026-10-26T20:30:15Z; user explicitly stopped Milestone 9 as +over-engineering). No VM acceptance result exists. +Prerequisites: milestones 1–8 accepted; explicitly selected disposable Linux VM +with its own kernel, systemd as PID 1, system D-Bus, WireGuard and synthetic data; +compatible pinned-toolchain artifacts and a bounded management/console channel. +Missing VM access or required capabilities is a blocker, not passing acceptance. -Acceptance: real D-Bus job handling, separate restart verification, address release, and manual backup recovery pass in the VM. All specification acceptance criteria have linked evidence. Required default checks and Alpine tests pass on the integrated revision; unresolved defects or unavailable required VM checks prevent final acceptance. Production is never the test environment. +#### Outcome and scope + +Verify the real production `Application::restart` -> private zbus systemd job +adapter -> real Rustix WireGuard observation -> journaled verification sequence. +Complete operating/recovery instructions, an acceptance evidence map, and a +reproducible release candidate. Acceptance authorizes no production experiment, +installation, publication, deployment, Git commit or tag. + +Read SPEC.md, IMPLEMENTATION.md, CONTEXT.md, ADRs 0001–0003, RUNTIME.md, +PERSISTENCE.md, BACKUP_FORMAT.md, DELIVERY.md, ENVIRONMENT.md, TESTING.md, +DEPENDENCIES.md and EXECUTION_RECORDS.md. Read only the relevant final M8 evidence +and focused diagnostics when needed; do not reconstruct every historical attempt. + +M8 was accepted for user-authorized IPv4-only Alpine coverage. Preserve that +acceptance and do not silently reintroduce excluded IPv6 tests as a prerequisite +for the VM. Production dual-stack requirements remain unchanged, so the final +acceptance matrix/release notes must explicitly retain the unverified real IPv6 +limitation. Do not claim complete dual-stack production validation from IPv4 +VM/container or synthetic tests. Any additional IPv6 validation or requirement +change needs an explicit scope decision; it is not a reason to block independent +systemd/IPv4 work or conceal the remaining release qualification. + +Out of scope: replacing Alpine coverage, new product features, another service +manager, global toolchain/package installation, production access, automatic VPN +rollback, new unapproved direct dependencies, and a general-purpose VM framework. +Choose the smallest reproducible VM procedure using an available approved runtime; +if none exists, record the prerequisite instead of installing one silently. + +#### Current implementation and critical boundaries + +- M7 provides actual startup, root checks, executable-relative settings, TUI and + serialized ApplicationWorker. Help/version remain unprivileged. Use real root + management only inside the selected VM, never on the development/production host. +- M8's `src/container_driver.rs` is cfg(test) and uses an explicitly fake fixture + service apply plus real observation/persistence. It does not exercise zbus. + Do not reuse that callback as M9 service evidence. Reuse safe fixture generation + and redacted protocol patterns only where their semantics actually match. +- Application has preview/confirmation, pre-restart backup, attempt/generation + checks, bounded service/runtime operations and durable state marking. Public + `Application::restart` is the required VM entry point. Test-only VM code may + inspect private results but must not substitute its service/read/install adapters. +- The pinned zbus 5.19.0 adapter subscribes before submission, resolves the loaded + unit's primary ID, follows the returned job and accepts only `done`. Verify this + against actual systemd, including fast completion and an alias resolving to a + primary unit. Do not demand a daemon PID from an active/exited oneshot service. +- A service job completing does not prove peer correspondence; matching peers do + not prove verification was persisted. Check both stages and address release. + Historical markers remain historical after reopening. Retain M4 journal recovery + and M5 timeout/partial-runtime-change reporting; no rollback/retry is automatic. +- A release executable is distinct from a cfg(test) harness. Capture each artifact's + source identity/target and prove the actual executable starts in the VM as well + as exercising library operations through a test driver. + +#### Tasks and ownership + +Execute sequentially with one writer, GPT-6-Sol/medium coordination and +GPT-6-Luna/high implementer, independent reviewer and repairer. Provide bounded +file ownership and acceptance IDs; the coordinator maintains this current snapshot. + +| Task | Owner | Deliverable and boundary | State | +| --- | --- | --- | --- | +| M9.1 | implementer | `docs/VM_ACCEPTANCE.md` and `tests/vm/` contract: explicit disposable target selection, pinned base/provisioning, driver, prerequisites and cleanup; no production endpoints | paused by user: guarded first start ran; Debian installer progress was observed, then the exact disposable VM was powered off on stop request; guest completion/provisioning/reset/cleanup unverified | +| M9.2 | implementer | Minimal VM fixture/opt-in driver and build recipe: real systemd/WireGuard service, private synthetic settings/data, compatible test and release artifacts | pending M9.1 guest/guard | +| M9.3 | implementer | Execute actual successful/failed/timeout/verification VM scenarios below; repair narrowly if real integration exposes defects | pending M9.2 fixture | +| M9.4 | implementer | README/operations/recovery and release documentation, requirements-to-evidence matrix, release-candidate identity/runtime dependency assessment | complete for host/documentation scope: independently reviewed operator/recovery docs, qualified matrix and native GNU artifact assessment; guest startup remains blocked | +| M9.5 | reviewer, repairer | Fresh independent review of VM targeting, actual production adapter use, failures/recovery, evidence and remaining release limitations; repair/re-review | partial document reviews closed; fresh VM implementation review pending after M9.1–M9.3 | +| M9.6 | coordinator | Integrated default/affected Alpine/VM gates, concise final evidence and qualified acceptance decision; no publishing or deployment | prior host gates passed; integrated VM gates and acceptance pending | + +M9.4 review finding: **M9.4-R1 (P2, closed)** — `docs/RUNTIME.md` described +accepted M7 TUI/startup and M8 real IPv4 Rustix validation as future work. +Targeted repair corrected those claims while retaining the systemd/D-Bus and +real IPv6 limits; independent re-review closed it. Guest/systemd validation +remains unavailable. + +M9.1-R1–R8 (P2, closed on independent re-review) covered bounded guest marker +capture, strict inventory, accurate host access status, occupied-path preflight, +the observed NAT baseline, exact SATA controller inventory, target-folder symlink +escape, and actual VBox machine-readable recording/storage syntax. Repairs and +regressions passed the integrated synthetic VM suite (26/26). The create run +registered only the pinned target UUID +`b14c850b-a21e-4a56-acca-785590f0c4ac`; the UUID-pinned resume configured +its VDI, ISO, host marker and manifest. The actual read-only guard then passed +on the powered-off target. Neither create nor resume may be rerun on this occupied +state. Diagnostic attempts are in [milestone-9 diagnostics](docs/diagnostics/milestone-9.md). +M9.1-R9/R10 (P2, closed on independent re-review) corrected unattended command +options and accepted VirtualBox's empty SATA port rows while rejecting extra +media. The integrated synthetic VM suite passed 33/33 before actual preparation. + +#### VM setup and execution contract + +Use a disposable server VM and a controlled synthetic client in a separate VM or +isolated namespace inside the lab. Test traffic must reach tunnel-only addresses, +not succeed via the management network. Do not attach physical production networks, +forward production credentials, mount host D-Bus/system paths, or expose host +service control. Keep management reachable independently of the VPN being restarted. + +Document VM image/release, kernel, architecture, systemd, D-Bus and WireGuard-tools +versions, fixture units/drop-ins and exact provision/build/run/reset commands. +Cache immutable image/build inputs; reset mutable state between scenarios. Require +explicit target selection and lab marker validation before privileged test actions. +A remote target being reachable or running Linux is not sufficient authorization. +Refuse missing identity markers, ambiguous endpoints and accidental host execution. +Use bounded waits/transport, owned-resource cleanup and redacted reports. Secrets +never travel in argv, public logs, committed images/snapshots or evidence files. + +Provision wg-quick/service prerequisites in the VM only, with a loaded/enabled +fixture service, permissions compatible with SavedStore, and stable invariant +paths. Set up fixtures without unsafe SaveConfig rewriting; test preflight refusal +of such a configuration rather than modifying administrator settings automatically. +Existing fixture hooks, if needed, are fixed reviewed lab code, never imported text. +Network/kernel prerequisite changes and snapshots affect the disposable guest only. + +Do not use a naked ignored test that can restart the current host's service when +someone runs --ignored. Put target guards at the earliest privileged boundary. +Use an explicitly selected VM launcher/driver and document exact commands after +its files exist. All normal cargo tests remain unprivileged and bus/network-free. +No production environment switch or fake-systemd mode is added to the binary. + +#### Required scenarios + +1. **Real successful apply.** Establish the existing client's IPv4 tunnel. Through + Application create/Commit/deliver a new client and show saved/runtime divergence. + Preview/cancel preserves draft and sends no service request; confirmed restart + with the real adapter has a completed pre-restart backup, matching terminal job, + actual interface/peer comparison and durable state write. New traffic succeeds, + verification history is recorded, and reopening treats it as history, not a new + live verification. Repeat a fast real job/loaded alias case without losing its + completion signal. Do not use log inference in place of application outcomes. +2. **Deletion and reservations.** Commit deletion of a working client: access and + reservation remain until the real successful restart/verification path. After + it, the peer is absent, fresh tunnel-only traffic fails, durable reservation is + released and old backups remain. Establish positive traffic first and bound + negative checks so unrelated fixture failure cannot pass the scenario. +3. **Failures and deadline.** Use fixed guest-only fixture unit/drop-ins to cause + a terminal job failure and a submitted job exceeding the configured deadline. + Check bounded return, honest submitted/may-still-complete classification, no + automatic retry/rollback, no false Verified state or IP release. Wait/reset the + guest explicitly after a late completion; timeout is not cancellation. Also + cover an unresolved configured unit before submission and backup failure that + prevents any restart. Use focused synthetic tests for exhaustive bus races, + not destructive daemon manipulation merely to duplicate unit coverage. +4. **Successful job, failed verification.** A dedicated fixture service completes + but leaves the interface missing or a mismatched peer set. The real adapter must + report the appropriate verification failure and retain reservations; no client + handshake is required to prove correct idle peers. A separate controlled saved- + state write failure after actual restart must report changed runtime without + falsely recording durable verification. RecoveryRequired must prevent unsafe + subsequent mutations; use established M4 fault seams only for persistence, never + replace the actual service/runtime calls to claim this scenario passed. +5. **Manual restore/recovery.** After a deliberately bad saved/apply state, select + a known-good archive through preview/confirm without supplying a vault password. + Check precautionary backup, restored settings/config/vault and unchanged runtime + until an explicit real restart; then verify the restored peer set and traffic. + Follow the documented manual procedure, not a hidden automatic rollback. Keep + transport access independent and validate file permissions/reopen behavior. +6. **Actual executable smoke.** In the guest's console/PTY, start the release binary + from a dedicated root-only directory outside PATH, with settings alongside it. + Check list/current statistics and one controlled Commit/restart workflow through + the real worker path; exit/cancel restores terminal state. Help/version work as + non-root without settings; non-root management is rejected. Reuse M7's detailed + synthetic terminal tests rather than repeating every keyboard/form edge case. + +#### Release preparation and evidence + +Document installation prerequisites and binary placement, 0600/0700 permissions, +setup/import with untouched originals, initial vault password and unrecoverable +password loss, first/reissue file/QR behavior, Commit versus restart, timeout and +partial-write outcomes, and manual recovery. Explain backup plaintext server-secret +exposure and that backups beside the binary are not protection against disk loss. +Do not add automatic retention, encryption, or remote backup features. + +Produce a native release candidate under the pinned compiler and locked dependency +set. Record source/dirty identity, artifact identity/target and actual dynamic-library +requirements. Test the artifact in the chosen guest; distinguish its supported +baseline from the tested development host. Existing musl artifacts may be reused +only with captured compatible inputs. Do not claim reproducibility from merely +running cargo build twice or claim support for untested architectures/distributions. + +Create a specification-to-evidence matrix (including preservation, secrets, saved/ +applied separation, backups, delivery and terminal behavior) using existing valid +M1–M8 evidence plus M9 results. Reuse M8's current evidence where relevant inputs +are unchanged; rebuild/rerun affected Alpine behavior when M9 changes invalidate +it. Never carry forward a pass from an unidentified or older dirty artifact. +List actual IPv4, synthetic dual-stack, unverified real IPv6, QR/device and systemd +scopes honestly rather than labelling the entire product universally verified. + +#### Acceptance and validation + +- [ ] M9-A1: documented guarded VM procedure/compatible artifacts execute in the + explicitly selected disposable guest; default tests cannot target host services. +- [ ] M9-A2: real zbus submission/job completion and Rustix verification succeed + through Application::restart, including fast completion/primary-unit alias; + actual release-worker startup is exercised separately from the test harness. +- [ ] M9-A3: completed pre-restart backup precedes service mutation; Commit/restore + never apply changes; canceled draft/restart actions have no unintended effects. +- [ ] M9-A4: real successful deletion/apply releases durable reservations only after + full verification; fresh traffic and reopened state support the result. +- [ ] M9-A5: unit/job/deadline/backup/missing-interface/mismatch and post-restart + persistence failures have accurate staged outcomes, retained reservations, + deterministic manual recovery and no automatic restart retry/VPN rollback. +- [ ] M9-A6: previewed password-free restore plus explicit real restart recovers the + selected known-good state/traffic; the VM procedure matches operator instructions. +- [ ] M9-A7: root/non-root executable-relative startup, limited real terminal flow, + artifact compatibility and private output/backup permissions are verified in VM. +- [ ] M9-A8: evidence matrix, release artifact identity, runtime dependencies, + operator/recovery docs and unresolved coverage qualifications are accurate. + M8's IPv6 exclusion is preserved; no unsupported full dual-stack claim is made. +- [ ] M9-A9: current default gates and affected Alpine/VM scenarios pass on the + identified integrated revision; independent review closes actionable findings. + Unavailable required VM checks prevent acceptance, rather than becoming a mock pass. + +#### Validation and execution records + +From repository root, run these existing commands at the integrated gate, not +on every diagnostic retry: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --locked --release +./target/release/wireguard-admin --help +./target/release/wireguard-admin --version +git diff --check +``` + +Before VM execution, document exact newly implemented build/provision/run/cleanup +commands in `docs/VM_ACCEPTANCE.md` and the existing selected-daemon Alpine command +in TESTING.md. No VM target/command is claimed available yet. Record actual results, +counts, selected VM/base identity, source/artifact identity, cleanup and review +mapping in `docs/evidence/milestone-9.md` when runs occur. Keep only latest applicable +evidence/status in this plan. Investigations belong in `docs/diagnostics/milestone-9.md` +when needed; after two failed repairs reassess/minimize before another expensive +full run per EXECUTION_RECORDS.md. Do not create placeholder reports claiming tests. + +#### Review, evidence and handoff + +M9.1 initial non-runnable contract passed read-only review. After the user +authorized local VirtualBox, approved outside-sandbox inventory confirmed +`VBoxManage` 7.2.8 works and `/dev/vboxdrv` is visible there; only Win7/Win11 +are registered and neither is a test target. The official Debian 13.7 amd64 +netinst ISO in the private lab root has SHA256 +`a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355`, +matching the downloaded official SHA256SUMS. The new target UUID +`b14c850b-a21e-4a56-acca-785590f0c4ac` is configured and powered off with +the private manifest/marker, exact VDI and pinned ISO; actual read-only guard +`check` exited 0. The integrated synthetic VM suite passed 26/26 after +independent review/repair; no existing Windows VM was modified. An exact-target +VirtualBox unattended install dry run with private synthetic password files, +Guest Additions ISO, and redacted output exited 0. The reviewed preparation +command then exited 0; read-only `showvminfo` found the expected VDI and a +private generated auxiliary `.viso` at SATA ports 0/1. It did not boot the +guest. M9.4 [operator instructions](docs/OPERATIONS.md), +[qualified evidence matrix](docs/RELEASE_READINESS.md), and updated README, +environment and runtime guidance passed independent review after M9.4-R1 repair. +The native GNU artifact SHA256 is +`d52d43cd02332352ffbd09c97e3b8095a710359128260c1286006e1c76c1b283`; +host `--help` and `--version` exited 0. On the integrated unchanged Rust source, +`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, +`cargo test --locked`, `cargo build --locked --release`, and `git diff --check` +exited 0. M8's source-input fingerprint remains +`b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`; +no Rust/Alpine input changed, so M8 evidence was not rerun. No VM scenario, +production access or key read occurred. Guest compatibility and actual zbus/ +systemd behavior remain unverified. The prior blocker audit was valid before +VirtualBox was made accessible outside the sandbox; its no-VM conclusion is +superseded by this authorized prerequisite path. M9.4 has no open finding. + +M8 is accepted for real IPv4 Alpine application/transport coverage; its test-only +service callback is not systemd evidence. Carry forward that distinction. Initial +M9 evidence is unavailable until actual guest execution; record missing historical +inputs as unavailable rather than reconstructing them. + +Next action: none. User stopped Milestone 9. Do not restart the VM or resume +M9.1–M9.3 without a new explicit request. No M9-A1–A9 check is complete. + +#### Outcome + +Paused at the user's request. The separate disposable Debian VM +`b14c850b-a21e-4a56-acca-785590f0c4ac` was powered off; a read-only +running-VM inventory was empty afterward. M9.1 remains incomplete, M9.2–M9.3 +have not run, and M9-A1–A9 remain unchecked. Prior host gates and M9.4 +documentation passed; no release acceptance or deployment is claimed. +Real IPv6 remains separately unverified by the previously accepted test scope. ## Current progress and resume point -- Milestones 1–8 are accepted; milestone 9 remains pending. +- Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change. +- Milestones 1–8 are accepted; milestone 9 is paused by the user and unaccepted. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. @@ -2999,7 +3288,8 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele - Milestone 7 delivered the native terminal workflow, safe startup/session projection, bounded single-owner worker, setup/import/draft/Commit/restart/delivery/backup/restore forms, synthetic PTY and workflow tests, and documentation. Independent R2 closed its list-status finding; all planned coordinator gates passed. Real WireGuard and systemd remain M8/M9. - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. -- Next action: stop after M8. Milestone 9 remains pending for disposable-VM systemd/D-Bus acceptance; no production access, commit or deployment is authorized by M8 acceptance. +- Milestone 9 is paused and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. +- Next action: none unless the user explicitly requests more Milestone 9 work. Do not restart the VM, access production, commit, or deploy. ## Plan maintenance diff --git a/README.md b/README.md index 0c0f93a..7fdc1bf 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,12 @@ absolute. Settings validation checks syntax and values only; it does not require the referenced files, directories, or service to exist and does not perform DNS lookups. +For installation, backup custody, restart failure handling and manual recovery, +see [`docs/OPERATIONS.md`](docs/OPERATIONS.md). The qualified evidence and the +current host release-candidate assessment are in +[`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md); those records do not +claim a completed systemd VM run or real IPv6 qualification. + ## Terminal workflows The client list supports arrow-key selection, Enter for details, `?` for help, @@ -98,9 +104,10 @@ let bytes_to_review = updated.render(); // no persistence or VPN operation ``` Runtime and restart behavior has synthetic fixture and fake-adapter coverage; -it has not been accepted against a real WireGuard interface or systemd service. -Real WireGuard integration is deferred to the isolated Alpine milestone, and -real systemd/D-Bus acceptance requires the disposable VM milestone. +the accepted Alpine milestone also exercised real IPv4 WireGuard traffic and +the Rustix reader. The Alpine fixture did not use systemd/D-Bus, and the +disposable systemd VM milestone has not been run. Real IPv6 capability and +traffic remain unverified. ## Saved state, Commit, and backups @@ -189,4 +196,6 @@ It checks an 80×24 to 40×8 to 80×24 resize sequence and terminal cleanup afte `q`. The workflow also checks that Esc clears a returned QR disclosure; the focused renderer test checks complete quiet-zone fit. Do not use the development host's settings or VPN as a substitute. +M1–M8 qualification evidence and the remaining M9 limitations are summarized +in [`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md). diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index ad4c51a..be5817f 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -97,22 +97,24 @@ The host cannot run this build directly because its musl target is absent. The digest-pinned official Rust 1.97.1 Alpine builder command in [TESTING.md](TESTING.md) built the library test harness on 2026-10-23. `file` identified a static PIE and `readelf -l` showed no interpreter; the ignored -readiness selector then passed 1/1 in the pinned Alpine runtime using a -read-only bind of the nonsecret binary. The selected-daemon M8.4 Testcontainers -smoke later copied the current binary into a fresh server container and passed -the test-only Create/Commit/first delivery/fixture Apply sequence with real -runtime observation. M8.5 traffic and remaining lifecycle scenarios remain -pending. The readiness wrapper -invokes the harness as +readiness selector passed 1/1 in the pinned Alpine runtime using a read-only +bind of the nonsecret binary. The selected-daemon M8.4 Testcontainers smoke +copied the current binary into a fresh server container and passed the +test-only Create/Commit/first delivery/fixture Apply sequence with real runtime +observation. M8.5 and M8.7 subsequently completed and M8 was accepted for the +documented IPv4 scope. The current integrated host gates and three fresh +selected-daemon lifecycle runs are recorded in +[evidence/milestone-8.md](evidence/milestone-8.md). The runs covered IPv4 +traffic, deletion, restore, import/reissue and mandatory-backup failure. The +fixture Apply callback is not a systemd job. IPv6 capability/traffic and real +systemd/D-Bus remain unverified. The readiness wrapper invokes the harness as `PATH --ignored --exact container_driver::alpine_readiness --test-threads=1`. Cargo printed the executable under `/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-` -for the builder command. Discover the current hash from each build; the driver -must be recopied after code changes. A target name or successful compilation -alone is not Alpine runtime evidence. Shell-tool readiness, Rust harness -readiness and the M8.4 Application smoke passed on the selected daemon. No -client tunnel traffic or systemd job has been tested. Musl readiness is a -milestone 8 prerequisite, not a milestone 1 acceptance gate. +for the builder command. Discover the current hash from each build; rebuild the +driver after relevant code changes. A target name or successful compilation +alone is not Alpine runtime evidence. Musl readiness is an M8 fixture build +prerequisite; it is not a milestone 1 acceptance gate. ## Observed preparation state diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..fdb8836 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,150 @@ +# Operator setup, backup and recovery + +This guide describes the supported single-interface installation and recovery +boundaries. Use it only after qualifying the application for the target host; +the current evidence and gaps are listed in +[`RELEASE_READINESS.md`](RELEASE_READINESS.md). Do not trial the application on +a production service as a substitute for the pending disposable systemd VM +qualification. + +## Installation prerequisites and placement + +The application manages one already-configured Linux WireGuard interface and +its existing systemd service. The host must provide working WireGuard kernel +support, systemd and its system D-Bus, plus the required service setup (such as +`wg-quick` when the selected unit uses it). Version 1 does not provision a +server, install a service, or manage multiple interfaces. Do not mount the +development host's D-Bus into a test environment. + +Build with the pinned Rust 1.97.1 toolchain and locked dependencies. Before +installing an artifact, verify its target and dynamic library requirements +against the chosen Linux distribution. The current x86-64 GNU release candidate +is host-assessed only; it has not been started in a guest. Keep the executable +and `wireguard-admin.toml` together in a dedicated root-owned directory that +ordinary users cannot modify. Keep the executable outside `PATH`; no installer, +service unit, or automatic privilege wrapper is supplied. + +Copy [`../config/wireguard-admin.example.toml`](../config/wireguard-admin.example.toml) +to `wireguard-admin.toml` beside the executable and edit it for the existing +interface, server configuration, client source directory, address pools, +endpoint, DNS, systemd unit, and timeout. The server config path and interface +name are installation invariants. Relative paths resolve beside the executable, +not from the shell's working directory. By default working data is +`Backup/data` and archives are `Backup/archives`, also beside the executable. +Test the resolved paths before running management. Preserve the existing +configuration and imported client files; import is read-only and anomaly +reports require operator decisions. + +Restrict the settings file, server configuration, vault, internal state, and +backup tree to root. Existing server configuration and vault files must already +be mode `0600` or stricter when opened. Keep private directories at `0700` and +secret-bearing files at `0600`; the application creates new owned outputs with +these modes but does not silently chmod an unsafe existing server/vault file. +Do not make the executable or settings writable by unprivileged users. The +application requires root for management; `--help` and `--version` are +unprivileged. + +## Initial vault, import and delivery + +At initial setup, import scans the configured source directory without changing +or deleting its original files. Review every reported match, mismatch, invalid +file, or server-only/client-only anomaly. The server peer set remains +authoritative; do not treat an imported file as permission to rewrite server +settings. + +Choose and protect the vault password during initial vault creation. The +password protects the separate vault secret key; it does not encrypt the +server configuration, original client files, or backup archives. Losing it +prevents future reissue of stored client private keys. A backup restores the +encrypted vault but cannot recover a lost password. Store credentials under +your organization's separate secret-custody process; never place the password +in settings, a command line, logs, or evidence. + +For a new client, Commit first, then complete first file delivery in the same +application session. The initial file is mandatory and can be retried at a +different destination after a publication failure while that creation session +is still active. Optional QR output represents the same configuration and is +available only after file publication. The delivered file is plaintext +WireGuard configuration, mode `0600`; treat terminal QR output as a disclosure. +If publication reports uncertain output or cleanup is required, follow the +exact manual-cleanup instruction before retrying; do not assume an error means +no plaintext file was created. +Until the saved peer set has been explicitly applied and verified, treat the +new client's access as inactive. After that session ends, each reissue requires +the vault password for that client and reads installed saved state. The +application does not use retained original plaintext files as a password-free +fallback. Overwriting a selected output file requires confirmation. + +## Commit, backups, restore and restart + +Keep the three states distinct: + +1. **Draft** is the current session's proposed edit. Cancel preserves it in + that session; exiting does not save it. +2. **Saved** is what Commit installed on disk. Commit creates a mandatory + pre-Commit backup, then saves settings, server configuration, internal state + and encrypted vault. Commit never reloads, syncs, or restarts WireGuard. +3. **Applied** is saved state verified against the running interface after an + explicit restart. A historical verification marker after reopen is not a + live observation. + +Every Commit, restore and restart first creates a complete mandatory backup. +If that backup fails, the protected operation stops before changing saved files +or submitting a restart. Backups are uncompressed archives, not encrypted +archives. They can contain the server's private configuration and encrypted +vault records. Restrict them to root, include them in the site's protected +backup custody, and remember that a backup beside the executable is lost with +that disk. The application has no automatic retention or remote-copy feature. +Deleting a client from the current vault does not erase old backups. + +Restore is a previewed, confirmed full replacement of saved state. It restores +the settings and opaque vault without a vault password, and it does not change +the running interface. A restored archive's old verification marker is +historical; runtime state becomes unknown. To apply the restored state, start a +separate explicit restart and inspect its result. Never describe restore or +Commit as a VPN rollback. + +Before confirming restart, review the configured interface/service and the +saved-versus-applied differences. The application creates a pre-restart backup, +submits the configured systemd job, waits for its terminal result, compares the +complete observed peer and route sets, then journals verification state. A +successful systemd job alone does not establish that the expected peers are +active. A pre-restart backup captures saved files and is not necessarily a +known-good working VPN state. Deleted-client addresses remain reserved until +verification and its saved-state write both succeed. + +If restart reports failure, timeout, a runtime mismatch, or a persistence +failure, read the stage and whether submission occurred or may still complete. +A timeout does not cancel a submitted job. Do not immediately issue repeated +restarts while a submitted job may still be running. No automatic restart, +retry, VPN rollback, or reservation release occurs on these failure paths. +Preserve the pre-restart backup and inspect the service and configured +interface through the host's normal, independently controlled administration +path. Do not infer success from log text alone. When the application remains +usable, select a known-good archive through its preview/confirmation flow to +restore saved files, then decide separately whether to restart and verify them. + +## Saved-file journal recovery + +The persistence journal recovers interrupted file replacement; it is not +runtime rollback. On reopen, an uncommitted prepared journal rolls saved files +back to the recorded prior bytes, while a committed journal is cleaned up. If +recovery or cleanup fails, the application returns `RecoveryRequired` and +rejects mutations. Correct the underlying storage problem (for example, +available space or directory access), preserve the journal, and retry opening +the application so its recovery can run. Do not delete, hand-edit, or rename +journal files to force startup. + +If a journal remains invalid or recovery continues to fail, stop making +changes. Preserve the complete data directory and operation backups for a +careful, separately reviewed recovery. A known-good archive is the documented +saved-state recovery source when the application can safely preview and restore +it; it does not repair the runtime automatically. After saved state is recovered, +inspect the live interface and use a separate explicit restart only when its +target is understood. If the application cannot open to perform a safe +previewed restore, do not improvise by copying partial archive members over +installation paths. + +Never remove old archives or original client files as an automatic response to +an error. Keep secret-bearing files and working backups out of source control, +support tickets, screenshots, logs, and evidence reports. diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md new file mode 100644 index 0000000..839832e --- /dev/null +++ b/docs/RELEASE_READINESS.md @@ -0,0 +1,85 @@ +# Release readiness and qualification matrix + +This is a scope-qualified M9.4 record, not a release approval. M1–M8 are +accepted in [`PLANS.md`](../PLANS.md); M9 real-system acceptance remains +incomplete because no explicitly selected disposable systemd VM is available. +No production host, VPN, service, key, or configuration was accessed. The +operator procedure is in [`OPERATIONS.md`](OPERATIONS.md). + +## Current host release candidate + +The following native artifact was built from the current Rust sources with the +pinned compiler and locked dependency set. Its `--help` and `--version` paths +were executed on the development host. This assessment does not establish +startup in a guest or supported compatibility with another distribution. + +| Property | Observed value | +| --- | --- | +| Source Git HEAD | `f4a6540df9621c9c5e5d1430243f92e749356a59` | +| Working-tree state at build | Pre-existing dirty documentation only: `PLANS.md` modified and `docs/VM_ACCEPTANCE.md` untracked; no source, manifest or lockfile changes | +| Relevant input fingerprint | SHA-256 `02ace329ca2ed17aa0b47112a3fcaa090b7d9a7eaa334f293aea9792e35e668a` over sorted `src/`, `Cargo.toml`, and `Cargo.lock` file hashes | +| Compiler and target | rustc 1.97.1, host `x86_64-unknown-linux-gnu`; Cargo 1.97.1 | +| Build command | `cargo build --locked --release` — exit 0 | +| Artifact | `target/release/wireguard-admin`, x86-64 ELF PIE, dynamically linked | +| Artifact SHA-256 | `d52d43cd02332352ffbd09c97e3b8095a710359128260c1286006e1c76c1b283` | +| Runtime requirements observed | ELF interpreter `/lib64/ld-linux-x86-64.so.2`; `libc.so.6` (GLIBC symbols through `GLIBC_2.39`) and `libgcc_s.so.1`; `ldd` resolved these on the development host | +| Artifact smoke | `./target/release/wireguard-admin --help` and `--version` — both exit 0; no settings, terminal, root management, service or interface exercised | + +Commands used to inspect the artifact were `sha256sum target/release/wireguard-admin`, +`file target/release/wireguard-admin`, `readelf -h`, `readelf -l`, `readelf -d`, +`readelf --version-info`, and `ldd target/release/wireguard-admin`. The linked +GLIBC requirement is an observed lower symbol-version bound, not proof that a +distribution satisfying it is otherwise compatible. No installer, package, +signature, reproducible-build claim, or supported distribution baseline is +provided. This native GNU artifact is not an Alpine/musl artifact. + +The source-input fingerprint was calculated from the repository root with: + +```sh +rg --files src Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum +``` + +## Specification-to-evidence matrix + +“Accepted” refers to the repository's existing milestone result. It does not +mean every production platform or dual-stack behavior has been qualified. For +M1–M7, the concise milestone scope, review outcome and commands are in the +corresponding accepted sections of [`PLANS.md`](../PLANS.md). M8's detailed +source, artifact, run and scope record is +[`evidence/milestone-8.md`](evidence/milestone-8.md). + +| Requirement area | Existing evidence | Qualification and limit | +| --- | --- | --- | +| Settings validation, executable-relative paths, draft/saved/applied model | M1 accepted record in `PLANS.md`; M7 startup/workflow record there | Synthetic/unit and TUI workflow coverage; no guest startup or production configuration. | +| Server configuration authority, client identity by public key, byte preservation and metadata | M2 accepted record in `PLANS.md`; specification in `SPEC.md` | Fixture/behavioral evidence for preserving non-client bytes and comments; no real server file was used. | +| Secret-safe keys, encrypted vault, read-only imports and originals | M3 accepted record in `PLANS.md`; M8 import/reissue scenario in `evidence/milestone-8.md` | Synthetic import plus accepted Alpine lifecycle scenario; source originals were fixture files and retained. Vault password loss remains unrecoverable by design. | +| Backups, mandatory backup boundary, journaled Commit, recovery and previewed restore | M4 accepted record in `PLANS.md`; M8 restore and backup-failure runs in `evidence/milestone-8.md` | Saved-state operations and test-fixture behavior covered. Restore did not apply runtime changes; real-VM manual recovery remains unverified. Archives are not encrypted. | +| Read-only runtime observations, restart orchestration, deadline and failure reporting | M5 accepted record in `PLANS.md`; runtime section of `docs/RUNTIME.md`; M8 Rustix observations | Fake-adapter and real Alpine WireGuard reader/IPv4 observations exist. No actual systemd/D-Bus job or `Application::restart` against real systemd has been run. | +| Delivery, first issue, password-per-action reissue, QR and private output | M6 accepted record and M7 synthetic PTY/workflow records in `PLANS.md`; `docs/DELIVERY.md` | Synthetic behavior covers file publication/reissue and QR rendering/terminal handling. No user device scan or actual client device qualification is recorded. | +| Native TUI, root/startup boundary and exit/terminal cleanup | M7 accepted record in `PLANS.md`, including synthetic PTY and workflow checks | Not a real guest root/non-root startup smoke. The release binary was only exercised for help/version on the build host. | +| Alpine real WireGuard lifecycle and IPv4 traffic | M8.7 in `evidence/milestone-8.md` | Accepted M8 used the pinned Alpine 3.22.7 fixture and selected disposable Docker daemon; repeated fresh integrated IPv4 scenarios passed on the recorded source fingerprint. Fixture Apply is a test-only callback, not systemd evidence. | +| Dual-stack behavior | M1–M7 synthetic client/configuration tests; M8 scope declaration | Production IPv4+IPv6 creation/full-tunnel requirements remain in force. Real IPv6 capability and traffic were excluded from M8 and are unverified; an IPv4 result does not qualify IPv6. | +| Real systemd job, alias/fast completion, failure/timeout, verified apply, restore and release-worker startup | No M9 VM result exists; `docs/VM_ACCEPTANCE.md` is a reviewed safety/scenario contract | **Unverified / M9 incomplete.** No selected disposable guest or VM run. Do not infer this from fake service adapters, Alpine or the host `--help` check. | +| Native artifact identity and dynamic dependencies | This document's host release-candidate record | Host-only assessment. Guest compatibility and startup are unverified; no universal Linux support claim. | + +## Qualification status and next action + +- M1–M8 remain accepted at their recorded scope. Later M9 documentation does + not change or broaden their evidence. +- M9.1 has a reviewed, non-runnable VM safety/scenario contract. M9.2–M9.3 + real-system work and M9.5–M9.6 review/acceptance remain incomplete. +- The release candidate was built and its ELF dependencies inspected on the + host, but has not been copied to or started in a guest. Actual guest + architecture/distribution compatibility, root management startup, + systemd/D-Bus behavior and service recovery are unverified. +- Real IPv6 capability and tunnel traffic are unverified. No M8 IPv4 or + synthetic dual-stack test is a substitute for that qualification. +- No release, installation, production action, Git commit, or deployment is + authorized by this report. + +Next action: select and explicitly identify a disposable VM/guest satisfying +[`VM_ACCEPTANCE.md`](VM_ACCEPTANCE.md), then complete its target guard and +real-system scenarios before independent M9 review. Keep systemd/guest results +separate from the existing Alpine and synthetic evidence. Any final release +statement must retain the real IPv6 qualification gap unless that coverage is +explicitly authorized and completed. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 095d1ed..759e637 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -138,11 +138,14 @@ the runtime or service adapters. Application unit tests use private fake adapters and an injected private monotonic clock. Public integration tests exercise cancellation, preview, restore, and saved-setting selection without confirming a host restart. These -are synthetic fixture results. The Linux transport has not been validated -against a real interface; that check belongs to the isolated Alpine suite at -M8. The real zbus/systemd path and end-to-end restart acceptance require the -disposable systemd VM at M9. The TUI and management startup wiring are not -implemented. +are synthetic fixture results. M7 implemented the TUI and management startup +wiring, with synthetic workflow and PTY coverage. M8 also validated the Linux +Rustix transport against a real WireGuard interface in the isolated Alpine +fixture, including IPv4 peer, route, handshake, and counter observations (see +[M8 evidence](evidence/milestone-8.md)). That fixture does not exercise the +real zbus/systemd path: systemd/D-Bus behavior and end-to-end restart acceptance +still require the disposable VM at M9. Real IPv6 capability and traffic remain +unverified. ## Bounded Linux transport @@ -164,5 +167,7 @@ errors, timeouts, and limit excess remain separate, redacted categories. Receive buffers are zeroized on every return path because device replies may include private and preshared key attributes, although the decoder borrows and does not retain those attributes. Runtime observation does not itself perform -saved-peer comparison or mark state applied. Real-interface behavior remains -subject to the isolated Alpine integration milestone. +saved-peer comparison or mark state applied. M8 exercised this Rustix reader +against a real Alpine WireGuard interface for IPv4 observations; real IPv6 +capability and traffic remain unverified. See [M8 evidence](evidence/milestone-8.md) +for the accepted scope. diff --git a/docs/VM_ACCEPTANCE.md b/docs/VM_ACCEPTANCE.md new file mode 100644 index 0000000..8f39772 --- /dev/null +++ b/docs/VM_ACCEPTANCE.md @@ -0,0 +1,392 @@ +# Disposable systemd VM acceptance contract + +Status: Milestone 9 is paused at the user's request. The exact disposable VM +was powered off after a guarded first start and an installer progress screen; +read-only `list runningvms` was empty afterward. Debian installation completion, +guest provisioning, reset, cleanup, and systemd acceptance are unverified. + +This contract supplements [Testing and Acceptance](TESTING.md) and the M9 +criteria in [PLANS.md](../PLANS.md). Product behavior is governed by +[SPEC.md](SPEC.md), [Runtime observation and verification](RUNTIME.md), and +[ADRs 0001–0003](adr/). + +## Selected target + +The selected target is exactly one new VirtualBox VM: + +| Field | Required value | +| --- | --- | +| Provider | Oracle VirtualBox, local `VBoxManage` 7.2.8_Gentoo | +| VM name | `wga-m9-debian137-20261025` | +| Guest base | Official Debian 13.7.0 (Trixie) amd64 netinst ISO | +| ISO path | `/media/data/wm/Virtualbox/wga-m9-lab/debian-13.7.0-amd64-netinst.iso` | +| ISO SHA-256 | `a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355` | +| VirtualBox OS type | `Debian13_64` | +| Lab root | `/media/data/wm/Virtualbox/wga-m9-lab` (owner `sasa`, mode `0700`) | +| Networking | One NAT NIC only, no forwarding; all other NICs disabled | +| VM UUID | `b14c850b-a21e-4a56-acca-785590f0c4ac`, pinned in the mode-`0600` identity manifest | +| Guest marker | `/etc/wga-m9-lab-marker`, exact content `wga-m9-debian137-20261025` after provisioning | + +The ISO is present, has the stated SHA-256, and was obtained from Debian's +[official amd64 netinst directory](https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/). +VBoxManage command forms were checked against the local 7.2.8 help and Oracle's +[VirtualBox 7.2 VBoxManage manual](https://docs.oracle.com/en/virtualization/virtualbox/7.2/user/vboxmanage.html). +Read-only `VBoxManage unattended detect --iso=... --machine-readable` reported +`OSTypeId=Debian13_64`, `OSVersion=13.7.0 Trixie`, and +`IsInstallSupported=on`, but then exited 1 with `NS_ERROR_NOT_IMPLEMENTED`. +The later bounded `unattended install --dry-run --start-vm=none` succeeded for +this exact VM with private synthetic password files, a fully qualified `.invalid` +hostname, and the local Guest Additions ISO. The dry run made no VM changes; +actual unattended preparation and guest boot remain unverified. VirtualBox's +dry-run output contains plaintext password fields, so the wrapper suppressed +them and displayed only redacted output. Any actual unattended step needs a +reviewed exact-target procedure and post-prepare media inspection before start. + +The approved read-only inventory outside the sandbox found `/dev/vboxdrv` +available and `VBoxManage` 7.2.8 working. It initially listed Windows 7 and +Windows 11 VMs; neither is a test target or was modified. The create-only run +registered only the new Debian target and stopped before configuration. The +default sandbox does not expose the host driver/device, so host inventory and +later VBoxManage operations must use the approved outside-sandbox path. The +guard matches the exact new name/UUID and does not inspect existing VMs' +per-VM files. + +## Guarded host operations + +[`tests/vm/guard.py`](../tests/vm/guard.py) is a narrow standard-library Python +guard, not a general VirtualBox wrapper. Its default `check`, `start`, and +`authorize-guest` actions are outside Cargo's default test targets. The guard: + +- Requires the exact lab root to be a real directory owned by the current user + with mode `0700`, and `target-identity.json` to be a regular current-user file + with mode `0600`. +- Requires the manifest to pin the exact VM name, current owner UID, one valid + UUID, and host marker `:`. +- Uses `VBoxManage list vms` to require that exact name occur once with the pinned + UUID, then `showvminfo --machinereadable` to compare UUID/name/marker. +- Requires the VM configuration and its one VDI to reside below the lab root, + the VDI to be named exactly `wga-m9-debian137-20261025.vdi`, and any mounted + installer ISO to be the pinned Debian ISO in that root. +- Requires a complete inventory of all eight NIC slots, with only NIC 1 in NAT + mode. NAT forwarding and all VirtualBox shared folders are refused. +- Performs every identity, path, and isolation check before `startvm`. A missing, + duplicate, malformed, or mismatched identity fails closed. The only start + target is the pinned UUID, in the GUI frontend for console installation. +- Before guest authorization, repeats all host checks, requires a current-user + password file with mode `0600`, and reads only the guest marker via + `VBoxManage guestcontrol ... run /usr/bin/cat` with `--wait-stdout` and a + 15-second guest process timeout. Host stdout/stderr capture is also capped at + 1 MiB while the process runs. The password is supplied using `--passwordfile`; + it is never placed in argv. Marker output is compared exactly and is not + echoed. This read is unprivileged inside the guest. + +The host marker is a pre-install bootstrap identity: it binds the created +VirtualBox object and protected manifest before the first start. It is not proof +of guest identity. The guest marker is installed only after Debian setup and is +checked separately after boot. Do not perform root provisioning, run application +acceptance, or access the guest system bus until `authorize-guest` succeeds for +the exact UUID. The guest marker check is deliberately read-only; it does not +grant the caller a general guest command runner. + +The exact current focused checks are: + +```sh +python3 -m unittest discover -s tests/vm -p 'test_*.py' -v +python3 -m py_compile tests/vm/guard.py tests/vm/test_guard.py +git diff --check +``` + +They use a temporary synthetic VM inventory and fake VBoxManage responses. They +do not call VBoxManage, start a guest, contact a host service, or read any +registered VM configuration. The behavioral cases cover normal exact identity, +missing/duplicate/wrong UUID, wrong name, manifest/root permissions, storage +escape, NIC mode and incomplete inventory, NAT forwarding, shared folders, +host/guest marker mismatch, no `startvm` on refusal, and exact UUID start scope. + +The guard supports these later commands once the selected VM has been created, +its manifest and marker have been recorded, and the coordinator has reviewed the +mutation step: + +```sh +python3 tests/vm/guard.py check +python3 tests/vm/guard.py start +python3 tests/vm/guard.py authorize-guest --password-file /path/to/private/guest-password +``` + +The placeholder password path above must name a private file owned by the +current user with mode `0600`. Do not put a password value in the command line, +environment, terminal history, or output. The `start` command is an implemented +guarded operation but has not been run. Running it requires the approved +outside-sandbox path because the default sandbox does not expose the host +VirtualBox device. + +## Create-only procedure (ran once; do not rerun) + +[`tests/vm/create.py`](../tests/vm/create.py) exposes only the fixed create +operation; it accepts no target, path, or action override. After confirming the +lab root owner/mode, exact ISO digest, absence of both the identity manifest and +the exact target VM path (using `lstat`, so dangling symlinks also count as +occupied), and a well-formed VirtualBox inventory without the selected name, it +issues bounded VBoxManage calls to: + +1. Register `wga-m9-debian137-20261025` as `Debian13_64` below the lab root. +2. Configure 1024 MiB RAM, one CPU, NIC 1 in NAT mode, NICs 2–8 disabled, audio + disabled, USB disabled, and DVD then disk boot order. +3. Create one dynamically allocated standard 8192 MiB VDI, attach it on SATA + port 0, and attach the pinned Debian ISO on SATA port 1. +4. Write the actual VM UUID and `:` host marker in + `target-identity.json` with mode `0600`, set the same marker as the VirtualBox + description, then run the existing read-only host guard. + +Run this exact command only after coordinator review of the current host state +and the scoped mutations: + +```sh +python3 tests/vm/create.py +``` + +The command must run through the approved outside-sandbox path because the +default sandbox cannot access the VirtualBox host driver. Its effects are VM +registration, storage/network/device/boot configuration, ISO attachment, and +the protected host identity manifest. It does not start the VM, install Debian, +use guest control, create snapshots, or delete/clean up resources. VBoxManage +calls have 30-second timeouts and 1 MiB per-stream output limits. Errors omit +VBoxManage output; after registration they report the exact target name and +known UUID. A failed procedure retains any partial target and its identity for +inspection; it performs no automatic rollback or broad cleanup. The final guard +must pass before the script reports success. The first run registered the UUID +above, then stopped when strict parsing rejected VirtualBox's indented +`rec_screen0` recording marker. The parser was narrowly repaired and +independently re-reviewed. The target path is now occupied, so `create.py` must +not be rerun; use the reviewed resume procedure below. + +## Resume the registered partial target (ran once; do not rerun) + +The earlier create attempt registered the selected target before stopping at +the initial machine-readable parse. The exact retained target is +`wga-m9-debian137-20261025` with UUID +`b14c850b-a21e-4a56-acca-785590f0c4ac`; it is powered off and has only its +`.vbox` configuration file. Its observed NIC inventory was NIC 1 in NAT with +NICs 2–8 disabled. It had no identity manifest or VDI. That prior state was +the sole input accepted by [`tests/vm/resume.py`](../tests/vm/resume.py). + +The resume-only procedure is fixed to that UUID and lab root. Before its first +mutation it checks the private root, exact pinned ISO path and SHA-256, absent +manifest and target VDI, unique registered name/UUID identity, exact `.vbox` +path, powered-off state, empty host description, default memory/CPU settings, +the observed NIC inventory (NIC 1 in NAT and NICs 2–8 disabled), no attached +media or storage controller, no shared folders, and no NAT forwarding. It +refuses an unexpected adapter or preexisting state. +After preflight it configures memory/CPU, NAT-only networking, disabled audio +and USB, boot order, one standard 8192 MiB VDI, and the pinned ISO; then it +writes the protected host manifest, sets the host marker, and runs the existing +read-only guard. It does not create, start, unregister, delete, or clean up a +VM. A failure after mutation retains the exact target and reports its pinned +UUID for inspection; no rollback is attempted. + +The focused command is: + +```sh +python3 -m unittest discover -s tests/vm -p 'test_resume.py' -v +``` + +It uses only a temporary synthetic lab and fake VBoxManage responses. It does +not call VBoxManage or establish host/VM evidence. The implementation command +was `python3 tests/vm/resume.py`; it ran once via the approved outside-sandbox +path. It configured the exact target and wrote the host marker/manifest. Its +final guard call initially refused observed VirtualBox storage metadata; +focused repairs and independent re-review closed those parser and guard +findings. A subsequent read-only `python3 tests/vm/guard.py check` passed +against the actual powered-off target. Do not rerun the resume command on this +occupied state. + +Do not infer `unregistervm --delete`, snapshot restore, guest-control, or broad +cleanup commands from the CLI help. No provisioning command is documented +because Debian installation, Guest Additions/management channel, the unprivileged +`wga` account, and guest marker placement have not been validated. No reset or +deletion command is documented because a baseline snapshot and exact owned +resource IDs do not yet exist. Existing registered VMs must remain untouched. + +## Unattended preparation (ran once; do not rerun) + +[`tests/vm/install_prepare.py`](../tests/vm/install_prepare.py) is pinned to the +VM UUID above and accepts no target, path, credential, or action overrides. It +first checks the current user's `/tmp/wga-m9-private` directory is a real mode +`0700` directory and its nonempty `wga-password` and `root-password` files are +current-user-owned regular mode-`0600` files. These are synthetic install +credentials only; password contents never enter argv or output. It verifies the +pinned Debian ISO and `/usr/share/virtualbox/VBoxGuestAdditions.iso` SHA-256 +digests, then calls the existing exact-target read-only guard and additionally +requires the VM to be powered off, with the expected VDI at `SATA-0-0`, the +pinned installer ISO at `SATA-1-0`, and every other reported SATA attachment +slot set to `none` or `emptydrive`. This accepts VirtualBox 7.2.8's real +`showvminfo` inventory (`SATA-2-0` through `SATA-29-0` are `none`) while +rejecting any additional attached media. It refuses before mutation if any +check fails. + +The script runs a bounded `VBoxManage unattended install ` dry run +first with `--iso=` and +`--admin-password-file=/tmp/wga-m9-private/root-password`, using the synthetic +`wga` account and password file, `.invalid` FQDN, `en_US`, `UTC`, US country, +minimal package adjustment, and the pinned Guest Additions ISO. The root +credential option is `--admin-password-file`; `--root-password-file` is not a +valid option for the local VBoxManage 7.2.8. Only a successful dry run permits +the same bounded command without `--dry-run`. Both commands request +`--start-vm=none`; the script has no start, cleanup, or retry operation. +VBoxManage output is captured privately because its dry-run output contains +plaintext password fields; errors and status output omit captured text. The +command has a 120-second deadline and 1 MiB per-stream capture limit. + +After independent review, the command ran once through the approved +outside-sandbox VBoxManage path: + +```sh +python3 tests/vm/install_prepare.py +``` + +The command exited 0 and left the VM powered off. Read-only `showvminfo` +inspection found the exact VDI at SATA 0 and a VirtualBox-generated auxiliary +`.viso` at SATA 1, both in the private target folder. The generated installer +files are mode `0600`; the target folder is mode `0700`. The normal host guard +refuses the `.viso`, as designed, so the first start requires a separate guard +for this exact installer state and independent review. Do not rerun preparation +on the occupied state. Guest boot, Guest Additions setup, and guest provisioning +are not established by this result or the synthetic tests. + +The focused behavioral tests use only temporary synthetic files and a fake +VBoxManage runner: + +```sh +python3 -m unittest discover -s tests/vm -p 'test_install_prepare.py' -v +``` + +## First start of the prepared installer (ran once; VM stopped) + +[`tests/vm/install_boot.py`](../tests/vm/install_boot.py) adds a separate, +fixed-target `check`/`start` command for the one powered-off installer state. +The ordinary [`guard.py`](../tests/vm/guard.py) deliberately continues to +reject the generated `.viso`; this installer guard does not loosen that normal +guard. It reuses the strict machine-readable parser and checks the exact +current-user-owned mode-`0600` identity manifest, pinned UUID and matching host +description, exact target name/configuration path, and current-user-owned +mode-`0700` target folder. It requires the VDI at SATA 0 and exactly +`Unattended-b14c850b-a21e-4a56-acca-785590f0c4ac-aux-iso.viso` at SATA 1; +SATA ports 2–29 must all be `none`. The `.viso` and any generated +`isolinux`/`preseed`/`postinstall` files are checked as current-user-owned +mode-`0600` regular files with exactly one hard link. The VM configuration and +VDI are also required to be regular current-user-owned files with exactly one +hard link. The guard never opens or prints installer-file contents. +The VM must remain powered off during preflight, have only NIC 1 in NAT mode, +and have no forwarding rules or shared folders. Symlinked paths, extra media, +unknown attachment metadata, and incomplete or mismatched identity refuse +before start. + +The exact commands are: + +```sh +python3 tests/vm/install_boot.py check +python3 tests/vm/install_boot.py start +``` + +`check` only reads the protected manifest, filesystem metadata, and bounded +VirtualBox inventory; it does not change VM state. `start` repeats all checks, +then issues only `VBoxManage startvm b14c850b-a21e-4a56-acca-785590f0c4ac +--type=headless`. It accepts no UUID, root, media, credential, or action +override. Each VBoxManage call uses the existing guard's 15-second deadline and +1 MiB per-stream output cap. Output is not copied into errors. If the start call +fails or times out, the result explicitly says the VM may have started and +requires a separate read-only inspection; there is no retry, cleanup, or +rollback. A successful command means only that VirtualBox accepted the start +request. It does not prove guest boot, Debian installation, or Guest Additions. + +Focused fake-runner coverage is: + +```sh +python3 -m unittest discover -s tests/vm -p 'test_install_boot.py' -v +``` + +These tests create synthetic private files and never call VBoxManage. Both +commands ran once against the actual VM after independent review. `check` +passed; `start` returned success; VirtualBox listed only the pinned target as +running. A private console screenshot showed Debian's base-system installer at +6%. The user then stopped Milestone 9, so the exact target was powered off and +read-only `list runningvms` returned no running VMs. Do not restart it without +a new explicit request. Installation completion and guest boot remain +unverified. + +After installation, provision only synthetic lab packages, +fixed reviewed service units, synthetic WireGuard keys/configuration, and the +guest-local marker. Keep all server/client data, outputs, and working backups +under a guest-only root-owned `0700` directory; secret files are `0600`. The +guest-local marker must be readable by the unprivileged `wga` account for the +read-only marker gate. This work belongs to the next task after target creation +and guard review; it is not claimed as implemented here. + +## Isolation and acceptance boundary + +- Use synthetic server/client keys, configuration, settings, and vault contents + generated inside the guest. Preserve imported originals. Do not copy or read + production keys, configuration, backups, or credentials. +- The VM may have only one NAT management adapter, with no forwarding rules; + do not use bridged, host-only, internal, NAT-network, USB network, or physical + adapters. NAT-only VM egress still traverses the host's ordinary network; do + not claim host-independent network isolation. No host D-Bus, `/etc`, `/run`, + `/var`, WireGuard interface, service control, or general shared folder may be + mounted or forwarded into the guest. +- A guest system D-Bus and PID 1 must be guest-local. Keep console management + independent of the VPN under test. Tunnel probes target only synthetic + addresses reachable through the test tunnel, never the NAT management path. +- Provision only fixed, reviewed lab units/drop-ins and fixture hooks. Imported + configuration text remains data. Preserve administrator settings and prove + preflight refusal using synthetic fixtures rather than silently rewriting + `SaveConfig`. +- Exercise the public `Application::restart` flow with the real zbus service + adapter and Rustix observer. A test seam may inject persistence failures only + where already established; it may not replace service submission or runtime + observation as evidence of systemd behavior. Alpine fixture apply remains + separate evidence. +- Run the actual release executable separately from any `cfg(test)` driver. + Capture source/dirty identity, compiler/locked dependencies, target, artifact + SHA-256, and dynamic-library requirements for both artifacts. Prove each + executable starts in the guest. +- Keep secrets out of argv, environment variables, image metadata, logs, + transcripts, and evidence. Use bounded typed control, private files/channels, + monotonic timeouts, and bounded output. A timeout after service submission + means it may still complete; it is not cancellation. +- Before every scenario, reset to a recorded clean guest state and verify both + the pinned host identity and guest marker. Afterward, stop only resources + created by that run and verify their exact IDs. If cleanup is uncertain, + preserve IDs and report them; never use broad host cleanup. Preserve the + original failure if cleanup also fails. + +## Required scenario evidence + +For every run, record UTC start/end, guest and base identities, kernel/systemd/ +D-Bus/WireGuard versions, both marker results, source/artifact identities, exact +commands, exit status and bounded redacted outcomes, backup/archive identities, +reset/cleanup result, and the acceptance criteria supported. Keep raw console +output private; evidence must contain no secrets or complete client config. +Record each scenario independently so failures cannot be masked by unrelated +fixture behavior. + +| Scenario | Required observable evidence | +| --- | --- | +| Successful apply and fast job | Existing synthetic client has positive tunnel-only IPv4 traffic. Through one `Application`, create and Commit a client; show saved/runtime divergence and that Commit did not submit a service job. Cancel preview with no backup/job/mutation. Confirm restart: pre-restart backup completes first, real zbus job for the resolved primary unit reaches `done`, Rustix observes the expected interface and complete matching peer/routes, and durable verification is written. Verify new tunnel traffic, reopen and show verification is historical. Repeat with fast completion and a configured alias resolving to the primary unit; retain the completion signal. Include application outcomes, not journal inference alone. | +| Deletion and reservations | Establish positive traffic before deleting/Committing. Show the client remains in runtime and its address remains reserved until successful real restart and complete verification. Then show it absent, tunnel-only traffic fails within a bounded probe, durable reservation releases, and old backups remain. | +| Failure and deadline | Using fixed guest-only units/drop-ins, show terminal job failure and submitted-job deadline expiry with bounded results, accurate submitted/may-still-complete status, no automatic retry/rollback, no false Verified state, and retained reservations. Also show unresolved unit fails before submission and backup failure prevents restart. After late completion, explicitly observe and reset the guest; timeout is not cancellation. | +| Job succeeds, verification fails | A dedicated fixed fixture service completes while leaving the interface absent or peers mismatched. Show the real adapter reports verification failure and reservations remain. Separately inject saved-state write failure only through the established persistence seam after real restart/observation; show runtime changed without durable Verified state and `RecoveryRequired` blocks unsafe mutations. | +| Restore and manual recovery | Preview and confirm a known-good archive without vault password. Show precautionary backup, full saved settings/config/vault replacement, and unchanged runtime before explicit restart. Then run real restart/verification and expected tunnel check. Demonstrate documented manual recovery; no hidden rollback. Confirm permissions and reopen behavior. | +| Release executable smoke | In guest console/PTY, prove identified release binary starts from its private directory and settings resolve beside it. Exercise current-client/statistics view and one controlled Commit/restart through the real worker path; confirm exit/cancel restores terminal. Help/version work as non-root without settings; non-root management is rejected. Record output/backup permissions. | + +The final M9 evidence must map these observations to M9-A1–A9 and the +specification-to-evidence matrix. State the tested scope, including IPv4 tunnel +coverage, synthetic dual-stack tests, unverified real IPv6 behavior, and +QR/device interoperability limits. A mocked bus, Alpine fixture apply, missing +prerequisite, skipped test, or guest boot alone does not satisfy real systemd +acceptance. + +## Next action + +None while Milestone 9 is paused by the user. The disposable VM is powered off; +do not resume installation, provision it, or run acceptance without a new +explicit request. No M9-A1–A9 check is complete. diff --git a/docs/diagnostics/milestone-9.md b/docs/diagnostics/milestone-9.md new file mode 100644 index 0000000..3a86369 --- /dev/null +++ b/docs/diagnostics/milestone-9.md @@ -0,0 +1,68 @@ +# Milestone 9 diagnostics + +Current execution state and acceptance are in [PLANS.md](../../PLANS.md). This +file records bounded host preparation failures, not systemd acceptance evidence. +All timestamps are UTC; the VM is disposable and contains synthetic data only. + +## 2026-10-25T18:28:55Z — M9.1-R8, initial create parse + +`python3 tests/vm/create.py` registered only +`wga-m9-debian137-20261025` (UUID +`b14c850b-a21e-4a56-acca-785590f0c4ac`) and stopped before VM configuration. +Cause: actual VirtualBox 7.2.8 machine-readable output contains an indented +`rec_screen0` recording section marker. The strict parser rejected it. Read-only +inventory showed the target powered off with only its `.vbox` file; the existing +Windows VMs were unchanged. A narrow marker parser repair and synthetic test +passed independent re-review. Creation cannot be rerun because the target path +is occupied; a UUID-pinned resume procedure was reviewed instead. + +## 2026-10-25T18:35:00Z — M9.1-R5/R8, resume final guard + +`python3 tests/vm/resume.py` verified the exact partial target, then configured +the VDI, pinned installer ISO, NAT-only NICs, and private host marker/manifest. +Its final read-only guard refused actual quoted SATA attachment keys and +per-port metadata. `python3 tests/vm/guard.py check` first reported an +unrecognized machine-readable key; after quoted-key parsing it reported an +unrecognized attachment-shaped metadata key. Read-only `showvminfo` identified +`SATA-IsEjected-1-0` among the actual metadata. Focused repairs accepted only +the observed key forms, required the exact SATA controller, and rejected +unrecognized/extra media and a symlinked target folder. Independent re-review +closed the findings; the integrated synthetic VM suite passed 26/26 and the +actual read-only `guard.py check` exited 0. No second resume or VM start occurred. + +## 2026-10-25T18:47:20Z — M9.1 unattended prerequisite (recorded) + +The first no-change `VBoxManage unattended install ... --dry-run --start-vm=none` +refused a single-label hostname. Repeating with the fully qualified synthetic +hostname `wga-m9-debian137.invalid` exited 0, including a separate dry run with +the local Guest Additions ISO. Both used current-user mode-`0600` private +synthetic password files. VirtualBox prints password values in dry-run output; +the wrapper redacted them before display. These runs created no files or VM +configuration changes. Actual installer preparation, boot, Guest Additions, +and guest systemd remain unverified. + +## 2026-10-25T18:53:05Z — M9.1 installer preparation (recorded) + +Independent review found that the first prepare-only script omitted the +required `--iso` and used an unsupported root-password option; its media +comparison also rejected VirtualBox's empty SATA ports. M9.1-R9/R10 repairs +passed re-review and 33/33 synthetic VM tests. The corrected +`python3 tests/vm/install_prepare.py` exited 0 outside the sandbox with +password-bearing VirtualBox output suppressed. Read-only `showvminfo` showed the +same pinned VM powered off, NAT-only, with its one VDI at SATA 0 and the private +generated `Unattended--aux-iso.viso` at SATA 1. Generated installer files +were mode `0600` in the mode-`0700` target folder. The normal guard intentionally +refuses `.viso`; first start awaits a separately reviewed installer-state guard. + +## 2026-10-26T20:30:15Z — User stopped M9.1 + +The installer-state guard and start code passed independent review after a +hard-link isolation repair; the full synthetic VM suite passed 44/44. Actual +`python3 tests/vm/install_boot.py check` and `start` exited 0 against only the +pinned target. Read-only `list runningvms` listed only that VM; a private +console screenshot showed Debian base-system installation at 6%. Completion +was not observed. After the user explicitly stopped Milestone 9, the exact VM +was powered off; a subsequent read-only running-VM inventory was empty. Guest +installation, Guest Additions, systemd, WireGuard, and M9 acceptance remain +unverified. No existing Windows VM, production resource, Git commit, or +deployment was touched. diff --git a/docs/evidence/tui-navigation.md b/docs/evidence/tui-navigation.md new file mode 100644 index 0000000..0ae8eac --- /dev/null +++ b/docs/evidence/tui-navigation.md @@ -0,0 +1,42 @@ +# TUI navigation repair + +Validated on 2026-10-26 at 22:32 +02:00. Bounded maintenance; M9 remains +paused and unaccepted. No production, Alpine or VM execution was performed. + +## Behavior and review + +List Up/Down previously called the details-opening selection method. The repair +separates highlighting from opening: arrows update selection, Enter opens the +selected peer, and Esc returns to the list. Existing detail/help/message scrolling +is preserved. Unsupported peers remain inspectable without mutation identity. +Independent reviewer NAV-001 identified an initial-highlight mismatch; the repair +treats the renderer's implicit first row as current. Re-review closed NAV-001 +with no remaining actionable scoped findings. + +Regression coverage exercises initial Down, initial Up wrap, explicit selection, +Enter, Esc, scroll reset and unsupported peer inspection. Focused command +`cargo test --locked tui::terminal::tests::` passed 7/7. + +## Integrated checks and identity + +Rust/Cargo 1.97.1, native x86_64 GNU host. All commands exited 0: + +- `cargo fmt --all -- --check` +- `cargo clippy --locked --all-targets -- -D warnings` +- `cargo test --locked`: 323 passed, 3 explicitly ignored, none failed, + including 2 documentation tests. Ignored real-environment tests were not run. +- `cargo build --locked --release` +- `git diff --check` + +Base commit: `f4a6540df9621c9c5e5d1430243f92e749356a59`. Dirty inputs include +the navigation repair plus pre-existing documentation/VM preparation changes; +the latter were preserved. SHA256 of changed Rust files: + +- `src/tui/state.rs`: `3b9ea1540c26a372c781a8194cedff74bb56320a698ae28e56ce22f38d159c5d` +- `src/tui/terminal.rs`: `368bf0be11c35202127b7ac5c95f62a312f9de9b464de6e6816cb4b7523cdb1e` + +Native artifact `target/release/wireguard-admin` SHA256: +`de5b7efe9f8615fcb13d8779a86451d62e64c3f1da976ba706d6773f8a573d9e`. +This is host build and fixture evidence only; target-server deployment and +interactive SSH confirmation remain unverified. Previous release artifact +identities do not identify this build. diff --git a/src/tui/state.rs b/src/tui/state.rs index 4c43fe7..7ca5e35 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -269,6 +269,15 @@ impl UiState { } pub fn select_peer_index(&mut self, peer_index: usize) -> bool { + if !self.highlight_peer_index(peer_index) { + return false; + } + self.screen = Screen::ClientDetails; + true + } + + /// Move the client-list highlight without changing the active screen. + pub fn highlight_peer_index(&mut self, peer_index: usize) -> bool { let Some(client) = self.view.as_ref().and_then(|view| { view.clients .iter() @@ -278,7 +287,6 @@ impl UiState { }; self.selected_peer_index = Some(peer_index); self.selected_identity = client.identity; - self.screen = Screen::ClientDetails; true } diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs index 38dff85..11e9afe 100644 --- a/src/tui/terminal.rs +++ b/src/tui/terminal.rs @@ -679,6 +679,9 @@ fn handle_key( if state.pending.is_some() { return; } + if handle_navigation_key(code, state) { + return; + } match code { KeyCode::Char('q') => { if state.view.as_ref().is_some_and(|view| view.has_draft) { @@ -781,25 +784,6 @@ fn handle_key( state.screen = Screen::Message; state.scroll_offset = 0; } - KeyCode::Esc => { - state.screen = Screen::ClientList; - state.dialog = None; - state.scroll_offset = 0; - } - KeyCode::Enter if state.screen == Screen::ClientList => { - let peer_index = state.selected_peer_index.or_else(|| { - state - .view - .as_ref() - .and_then(|view| view.clients.first().map(|client| client.peer_index)) - }); - if let Some(peer_index) = peer_index { - let _ = state.select_peer_index(peer_index); - } - } - KeyCode::Up | KeyCode::Down if state.screen == Screen::ClientList => { - move_selection(state, matches!(code, KeyCode::Down)); - } KeyCode::Up | KeyCode::PageUp if state.screen == Screen::ClientDetails || state.screen == Screen::Help @@ -818,24 +802,65 @@ fn handle_key( } } +/// Handle list/detail navigation and return whether the key was consumed. +/// Keeping this path independent of worker actions makes its state transitions +/// directly testable without starting an application worker. +fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool { + match code { + KeyCode::Esc => { + state.screen = Screen::ClientList; + state.dialog = None; + state.scroll_offset = 0; + true + } + KeyCode::Enter if state.screen == Screen::ClientList => { + let peer_index = state.selected_peer_index.or_else(|| { + state + .view + .as_ref() + .and_then(|view| view.clients.first().map(|client| client.peer_index)) + }); + if let Some(peer_index) = peer_index { + let _ = state.select_peer_index(peer_index); + } + state.scroll_offset = 0; + true + } + KeyCode::Up | KeyCode::Down if state.screen == Screen::ClientList => { + move_selection(state, matches!(code, KeyCode::Down)); + true + } + _ => false, + } +} + fn move_selection(state: &mut UiState, down: bool) { let Some(view) = &state.view else { return }; if view.clients.is_empty() { return; } - let current = state.selected_peer_index.and_then(|selected| { - view.clients - .iter() - .position(|row| row.peer_index == selected) - }); - let next = match (current, down) { - (None, true) => 0, - (None, false) => view.clients.len() - 1, - (Some(index), true) => (index + 1) % view.clients.len(), - (Some(index), false) => index.checked_sub(1).unwrap_or(view.clients.len() - 1), + let current = state + .selected_peer_index + .and_then(|selected| { + view.clients + .iter() + .position(|row| row.peer_index == selected) + }) + .or_else(|| { + state.selected_identity.and_then(|identity| { + view.clients + .iter() + .position(|row| row.identity == Some(identity)) + }) + }) + .unwrap_or(0); + let next = if down { + (current + 1) % view.clients.len() + } else { + current.checked_sub(1).unwrap_or(view.clients.len() - 1) }; let peer_index = view.clients[next].peer_index; - let _ = state.select_peer_index(peer_index); + let _ = state.highlight_peer_index(peer_index); } #[cfg(test)] @@ -845,9 +870,11 @@ mod tests { use std::process::Command; use std::sync::Mutex; + use crate::app::ClientDisplay; use crate::runtime::RuntimeObservation; use crate::tui::events::OperationError; use crate::tui::state::{AppliedState, OperationKind, SessionView}; + use crate::wireguard::PeerIssue; struct FakeWorker { shutdown_calls: usize, @@ -886,6 +913,100 @@ mod tests { assert_eq!(check_suitable_for(true, true, Ok((80, 24))), Ok(())); } + fn navigation_view() -> SessionView { + let first = crate::domain::PublicKey::from_bytes([1; 32]); + let unsupported = ClientDisplay { + peer_index: 9, + line: 10, + identity: None, + label: "unsupported peer".to_owned(), + allowed_ips: Vec::new(), + issues: vec![PeerIssue::MissingIpv4HostRoute], + mutable_identity: None, + }; + SessionView { + generation: 0, + clients: vec![ + ClientDisplay { + peer_index: 2, + line: 3, + identity: Some(first), + label: "first".to_owned(), + allowed_ips: vec!["10.0.0.2/32".to_owned()], + issues: Vec::new(), + mutable_identity: Some(first), + }, + unsupported, + ], + client_states: Default::default(), + document_issues: Vec::new(), + applied: AppliedState::Unknown, + reserved_addresses: Default::default(), + statistics: RuntimeObservation::unknown(), + has_draft: false, + vault_initialized: true, + } + } + + #[test] + fn arrows_highlight_only_enter_opens_selected_peer_and_escape_returns_to_list() { + let identity = crate::domain::PublicKey::from_bytes([1; 32]); + let mut state = UiState { + view: Some(navigation_view()), + ..UiState::default() + }; + + assert!(handle_navigation_key(KeyCode::Down, &mut state)); + assert_eq!(state.screen, Screen::ClientList); + assert_eq!(state.selected_peer_index, Some(9)); + assert_eq!(state.selected_identity, None); + + assert!(handle_navigation_key(KeyCode::Up, &mut state)); + assert_eq!(state.screen, Screen::ClientList); + assert_eq!(state.selected_peer_index, Some(2)); + assert_eq!(state.selected_identity, Some(identity)); + + state.selected_peer_index = None; + state.selected_identity = None; + assert!(handle_navigation_key(KeyCode::Up, &mut state)); + assert_eq!(state.screen, Screen::ClientList); + assert_eq!(state.selected_peer_index, Some(9)); + assert_eq!(state.selected_identity, None); + + assert!(handle_navigation_key(KeyCode::Down, &mut state)); + assert_eq!(state.selected_peer_index, Some(2)); + + state.scroll_offset = 3; + assert!(handle_navigation_key(KeyCode::Enter, &mut state)); + assert_eq!(state.screen, Screen::ClientDetails); + assert_eq!(state.selected_peer_index, Some(2)); + assert_eq!(state.scroll_offset, 0); + + state.scroll_offset = 5; + assert!(handle_navigation_key(KeyCode::Esc, &mut state)); + assert_eq!(state.screen, Screen::ClientList); + assert_eq!(state.selected_peer_index, Some(2)); + assert_eq!(state.scroll_offset, 0); + } + + #[test] + fn enter_opens_an_unsupported_peer_for_inspection_without_mutation_identity() { + let mut state = UiState { + view: Some(navigation_view()), + ..UiState::default() + }; + assert!(handle_navigation_key(KeyCode::Down, &mut state)); + assert_eq!(state.screen, Screen::ClientList); + assert_eq!(state.selected_peer_index, Some(9)); + assert!(!state.selected_client_mutable()); + + assert!(handle_navigation_key(KeyCode::Enter, &mut state)); + assert_eq!(state.screen, Screen::ClientDetails); + assert_eq!(state.selected_peer_index, Some(9)); + assert_eq!(state.selected_identity, None); + assert!(!state.selected_client_mutable()); + } + #[test] fn restart_results_render_cancel_success_and_uncertain_failure_honestly() { use crate::app::{RestartFailureKind, RestartReport, RestartStage}; diff --git a/tests/vm/create.py b/tests/vm/create.py new file mode 100644 index 0000000..8d0fe74 --- /dev/null +++ b/tests/vm/create.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Create and validate the one approved M9 VirtualBox VM; never start it.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import selectors +import stat +import subprocess +import sys +import time +from typing import Callable, Sequence +import uuid + +import guard + + +LAB_ROOT = guard.LAB_ROOT +ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO +VM_NAME = guard.TARGET_NAME +OS_TYPE = "Debian13_64" +DISK_SIZE_MIB = 8192 +MEMORY_MIB = 1024 +CPUS = 1 +MAX_CAPTURE = 1024 * 1024 +TIMEOUT_SECONDS = 30 + + +class CreateError(Exception): + """Creation could not safely complete; partial target is retained.""" + + +Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]] + + +def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]: + process = subprocess.Popen( + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"}, + ) + assert process.stdout is not None and process.stderr is not None + output = {process.stdout: bytearray(), process.stderr: bytearray()} + selector = selectors.DefaultSelector() + for stream in output: + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ) + deadline = time.monotonic() + TIMEOUT_SECONDS + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(argv, TIMEOUT_SECONDS) + for key, _ in selector.select(remaining): + stream = key.fileobj + chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream]))) + if not chunk: + selector.unregister(stream) + continue + output[stream].extend(chunk) + if len(output[stream]) > MAX_CAPTURE: + raise CreateError("VBoxManage output exceeded the creation limit") + returncode = process.wait(timeout=max(0.0, deadline - time.monotonic())) + except (OSError, subprocess.TimeoutExpired, CreateError): + process.kill() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + raise + finally: + selector.close() + process.stdout.close() + process.stderr.close() + return subprocess.CompletedProcess( + argv, returncode, + bytes(output[process.stdout]).decode("utf-8", errors="replace"), + bytes(output[process.stderr]).decode("utf-8", errors="replace"), + ) + + +def _call(runner: Runner, argv: Sequence[str], *, created_uuid: str | None = None) -> str: + try: + result = runner(tuple(argv)) + except (OSError, subprocess.TimeoutExpired, CreateError) as exc: + suffix = f"; retained partial VM {VM_NAME} ({created_uuid})" if created_uuid else "" + raise CreateError(f"bounded VBoxManage call failed: {argv[1]}{suffix}") from exc + if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE: + raise CreateError("VBoxManage output exceeded the creation limit") + if result.returncode != 0: + suffix = f"; retained partial VM {VM_NAME} ({created_uuid})" if created_uuid else "" + raise CreateError(f"VBoxManage {argv[1]} failed with status {result.returncode}{suffix}") + return result.stdout + + +def _check_lab(lab_root: Path, iso_path: Path, owner_uid: int) -> None: + try: + root_info = lab_root.lstat() + except OSError as exc: + raise CreateError("private lab root is unavailable") from exc + if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode) + or root_info.st_uid != owner_uid or stat.S_IMODE(root_info.st_mode) != 0o700): + raise CreateError("lab root must be a real current-user directory with mode 0700") + if iso_path.parent.resolve(strict=True) != lab_root.resolve(strict=True): + raise CreateError("installer ISO must be directly inside the exact lab root") + try: + iso_info = iso_path.lstat() + except OSError as exc: + raise CreateError("pinned installer ISO is unavailable") from exc + if not stat.S_ISREG(iso_info.st_mode) or stat.S_ISLNK(iso_info.st_mode): + raise CreateError("installer ISO must be a regular non-symlink file") + digest = hashlib.sha256() + try: + with iso_path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise CreateError("pinned installer ISO cannot be read") from exc + if digest.hexdigest() != guard.EXPECTED_ISO_SHA256: + raise CreateError("installer ISO SHA-256 does not match the pinned digest") + for path, description in ( + (lab_root / guard.IDENTITY_FILE, "target identity manifest"), + (lab_root / VM_NAME, "exact target VM path"), + ): + try: + path.lstat() + except FileNotFoundError: + continue + except OSError as exc: + raise CreateError(f"could not inspect {description}; refusing creation") from exc + if description == "target identity manifest": + raise CreateError(f"{description} already exists; refusing overwrite") + raise CreateError(f"{description} already exists; refusing creation") + + +def _check_absent(runner: Runner) -> None: + output = _call(runner, ("VBoxManage", "list", "vms")) + names: set[str] = set() + uuids: set[str] = set() + for line in output.splitlines(): + if not line.strip(): + continue + match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line) + if not match: + raise CreateError("malformed VBoxManage inventory; refusing creation") + name, raw_uuid = match.groups() + try: + vm_uuid = str(uuid.UUID(raw_uuid)) + except ValueError as exc: + raise CreateError("malformed VBoxManage inventory UUID; refusing creation") from exc + if name in names or vm_uuid in uuids: + raise CreateError("duplicate VBoxManage inventory identity; refusing creation") + names.add(name) + uuids.add(vm_uuid) + if VM_NAME in names: + raise CreateError("exact target name is already registered; refusing creation") + + +def _write_manifest(lab_root: Path, target_uuid: str, owner_uid: int) -> None: + marker = f"{VM_NAME}:{target_uuid}" + data = { + "schema": 1, + "name": VM_NAME, + "owner_uid": owner_uid, + "uuid": target_uuid, + "host_marker": marker, + } + path = lab_root / guard.IDENTITY_FILE + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(path, flags, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as destination: + os.fchmod(destination.fileno(), 0o600) + json.dump(data, destination, sort_keys=True) + destination.write("\n") + destination.flush() + os.fsync(destination.fileno()) + except OSError as exc: + raise CreateError(f"could not write identity manifest; retained partial VM {VM_NAME} ({target_uuid})") from exc + + +def create_target( + *, lab_root: Path = LAB_ROOT, + iso_path: Path = ISO_PATH, + owner_uid: int | None = None, + runner: Runner = run_command, +) -> str: + """Preflight, create/configure the exact VM, and run the read-only guard.""" + owner_uid = os.getuid() if owner_uid is None else owner_uid + try: + _check_lab(lab_root, iso_path, owner_uid) + _check_absent(runner) + except OSError as exc: + raise CreateError("creation preflight could not inspect the lab root or ISO") from exc + + create_output = _call(runner, ( + "VBoxManage", "createvm", "--name", VM_NAME, "--ostype", OS_TYPE, + "--basefolder", str(lab_root), "--register", + )) + created_match = re.search(r"(?m)^UUID:\s*([0-9A-Fa-f-]{36})\s*$", create_output) + target_uuid: str | None = None + if created_match: + try: + target_uuid = str(uuid.UUID(created_match.group(1))) + except ValueError: + target_uuid = None + try: + info_text = _call(runner, ( + "VBoxManage", "showvminfo", target_uuid or VM_NAME, "--machinereadable", + ), created_uuid=target_uuid) + info = guard._machine_values(info_text) + if info.get("name") != VM_NAME or "UUID" not in info: + raise CreateError(f"created VM identity is unreadable; retained partial VM {VM_NAME} ({target_uuid or 'UUID unavailable'})") + reported_uuid = str(uuid.UUID(info["UUID"])) + if target_uuid is not None and target_uuid != reported_uuid: + raise CreateError(f"created VM UUID changed unexpectedly; retained partial VM {VM_NAME} ({target_uuid})") + target_uuid = reported_uuid + expected_cfg = lab_root / VM_NAME / f"{VM_NAME}.vbox" + if Path(info.get("CfgFile", "")) != expected_cfg: + raise CreateError(f"created VM path is unexpected; retained partial VM {VM_NAME} ({target_uuid})") + + _call(runner, ( + "VBoxManage", "modifyvm", target_uuid, + "--memory", str(MEMORY_MIB), "--cpus", str(CPUS), + "--nic1", "nat", "--nic2", "none", "--nic3", "none", "--nic4", "none", + "--nic5", "none", "--nic6", "none", "--nic7", "none", "--nic8", "none", + "--audio", "none", "--usb", "off", + "--boot1", "dvd", "--boot2", "disk", "--boot3", "none", "--boot4", "none", + ), created_uuid=target_uuid) + disk = lab_root / VM_NAME / f"{VM_NAME}.vdi" + _call(runner, ( + "VBoxManage", "createmedium", "disk", "--filename", str(disk), + "--size", str(DISK_SIZE_MIB), "--format", "VDI", "--variant", "Standard", + ), created_uuid=target_uuid) + _call(runner, ( + "VBoxManage", "storagectl", target_uuid, "--name", "SATA", + "--add", "sata", "--controller", "IntelAhci", + ), created_uuid=target_uuid) + _call(runner, ( + "VBoxManage", "storageattach", target_uuid, "--storagectl", "SATA", + "--port", "0", "--device", "0", "--type", "hdd", "--medium", str(disk), + ), created_uuid=target_uuid) + _call(runner, ( + "VBoxManage", "storageattach", target_uuid, "--storagectl", "SATA", + "--port", "1", "--device", "0", "--type", "dvddrive", "--medium", str(iso_path), + ), created_uuid=target_uuid) + _write_manifest(lab_root, target_uuid, owner_uid) + _call(runner, ( + "VBoxManage", "modifyvm", target_uuid, + "--description", f"{VM_NAME}:{target_uuid}", + ), created_uuid=target_uuid) + try: + guard.inspect_target(lab_root=lab_root, owner_uid=owner_uid, runner=runner) + except (guard.GuardError, OSError) as exc: + raise CreateError(f"final read-only guard failed; retained partial VM {VM_NAME} ({target_uuid})") from exc + return target_uuid + except CreateError as exc: + if "retained partial" in str(exc): + raise + raise CreateError(f"{exc}; retained partial VM {VM_NAME} ({locals().get('target_uuid', 'UUID unavailable')})") from exc + except (ValueError, KeyError, OSError) as exc: + raise CreateError(f"creation identity/configuration failed; retained partial VM {VM_NAME} ({locals().get('target_uuid', 'UUID unavailable')})") from exc + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.parse_args(argv) + try: + target_uuid = create_target() + except CreateError as exc: + print(f"VM creation refused/stopped: {exc}", file=sys.stderr) + return 2 + print(f"Created and guarded {VM_NAME} ({target_uuid}); VM remains powered off") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vm/guard.py b/tests/vm/guard.py new file mode 100644 index 0000000..ae315a5 --- /dev/null +++ b/tests/vm/guard.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Fail-closed host guard for the single M9 VirtualBox target. + +This is deliberately not a general VirtualBox wrapper. Its only possible +state-changing action is starting the one named lab VM after the full host +identity check. Guest privileged actions remain an explicit later step and +must be preceded by ``authorize-guest`` after the guest marker is installed. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import selectors +import shlex +import stat +import subprocess +import sys +import time +from typing import Callable, Sequence +import uuid + + +LAB_ROOT = Path("/media/data/wm/Virtualbox/wga-m9-lab") +TARGET_NAME = "wga-m9-debian137-20261025" +IDENTITY_FILE = "target-identity.json" +EXPECTED_ISO = "debian-13.7.0-amd64-netinst.iso" +EXPECTED_ISO_SHA256 = "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355" +EXPECTED_GUEST_MARKER = "wga-m9-debian137-20261025" +GUEST_MARKER_PATH = "/etc/wga-m9-lab-marker" +MAX_CAPTURE = 1024 * 1024 + + +class GuardError(Exception): + """A target failed one of the explicit identity/isolation checks.""" + + +Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]] + + +def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]: + process = subprocess.Popen( + list(argv), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"}, + ) + assert process.stdout is not None and process.stderr is not None + output = {process.stdout: bytearray(), process.stderr: bytearray()} + selector = selectors.DefaultSelector() + for stream in output: + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ) + + deadline = time.monotonic() + 15 + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(argv, 15) + for key, _ in selector.select(remaining): + stream = key.fileobj + chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream]))) + if not chunk: + selector.unregister(stream) + continue + output[stream].extend(chunk) + if len(output[stream]) > MAX_CAPTURE: + raise GuardError("VBoxManage output exceeded the guard limit") + returncode = process.wait(timeout=max(0.0, deadline - time.monotonic())) + except (OSError, subprocess.TimeoutExpired, GuardError): + process.kill() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + raise + finally: + selector.close() + process.stdout.close() + process.stderr.close() + + return subprocess.CompletedProcess( + argv, + returncode, + bytes(output[process.stdout]).decode("utf-8", errors="replace"), + bytes(output[process.stderr]).decode("utf-8", errors="replace"), + ) + + +def _machine_values(output: str) -> dict[str, str]: + """Parse VBoxManage key=value output without evaluating its values.""" + values: dict[str, str] = {} + for line in output.splitlines(): + if not line.strip(): + continue + # VBoxManage groups recording settings under section markers in + # machine-readable output. These are the only non-key/value rows we + # accept; keep rejecting all other unexpected text. + if re.fullmatch(r" rec_screen[0-9]+", line): + continue + if "=" not in line: + raise GuardError("malformed VBoxManage machine-readable line") + raw_key, raw = line.split("=", 1) + raw_key = raw_key.strip() + if raw_key.startswith('"') and raw_key.endswith('"'): + key = raw_key[1:-1] + elif '"' in raw_key: + raise GuardError("unrecognized VBoxManage machine-readable key") + else: + key = raw_key + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_()\-]*", key): + raise GuardError("unrecognized VBoxManage machine-readable key") + if key in values: + raise GuardError(f"duplicate VBoxManage machine-readable key: {key}") + try: + value = shlex.split(raw, posix=True) + except ValueError as exc: + raise GuardError(f"malformed VBoxManage value for {key}") from exc + if len(value) != 1: + raise GuardError(f"ambiguous VBoxManage value for {key}") + values[key] = value[0] + return values + + +def _owned_private_directory(path: Path, owner_uid: int) -> None: + try: + info = path.lstat() + except OSError as exc: + raise GuardError(f"lab directory unavailable: {path}") from exc + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise GuardError("lab root must be a real directory") + if info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700: + raise GuardError("lab root must be owned by the current user and mode 0700") + + +def _read_manifest(path: Path, owner_uid: int) -> dict[str, object]: + try: + info = path.lstat() + except OSError as exc: + raise GuardError("target identity manifest is missing") from exc + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise GuardError("target identity manifest must be a regular file") + if info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600: + raise GuardError("target identity manifest must be current-user-owned mode 0600") + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise GuardError("target identity manifest is unreadable or invalid") from exc + if not isinstance(raw, dict) or raw.get("schema") != 1: + raise GuardError("unsupported target identity manifest") + if raw.get("name") != TARGET_NAME or raw.get("owner_uid") != owner_uid: + raise GuardError("manifest owner or exact VM name does not match") + try: + target_uuid = str(uuid.UUID(str(raw.get("uuid")))) + except (ValueError, AttributeError) as exc: + raise GuardError("manifest UUID is missing or invalid") from exc + expected_marker = f"{TARGET_NAME}:{target_uuid}" + if raw.get("host_marker") != expected_marker: + raise GuardError("manifest host marker does not match its UUID") + return {"uuid": target_uuid, "host_marker": expected_marker} + + +def _invoke(runner: Runner, argv: Sequence[str]) -> str: + try: + result = runner(argv) + except (OSError, subprocess.TimeoutExpired) as exc: + raise GuardError(f"bounded VBoxManage call failed: {argv[1]}") from exc + if result.returncode != 0: + raise GuardError(f"VBoxManage {argv[1]} failed with status {result.returncode}") + if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE: + raise GuardError("VBoxManage output exceeded the guard limit") + return result.stdout + + +def _check_attachment_path(raw_path: str, lab_root: Path, suffix: str) -> Path: + if raw_path in {"", "emptydrive", "none"}: + return Path() + path = Path(raw_path) + if not path.is_absolute() or path.is_symlink(): + raise GuardError("attached storage path must be absolute and not a symlink") + try: + resolved_root = lab_root.resolve(strict=True) + resolved = path.resolve(strict=True) + except OSError as exc: + raise GuardError("attached storage path is missing or unreadable") from exc + try: + resolved.relative_to(resolved_root) + except ValueError as exc: + raise GuardError("attached storage is outside the exact lab root") from exc + if resolved.suffix.lower() != suffix: + raise GuardError("attached storage is outside the exact lab root or has wrong type") + if resolved.stat().st_uid != os.getuid() or not resolved.is_file(): + raise GuardError("attached storage must be a current-user-owned regular file") + return resolved + + +def inspect_target( + *, + lab_root: Path = LAB_ROOT, + owner_uid: int | None = None, + runner: Runner = run_command, +) -> tuple[str, dict[str, str]]: + owner_uid = os.getuid() if owner_uid is None else owner_uid + if lab_root != LAB_ROOT and owner_uid == os.getuid(): + # The alternate root is only for unit tests; the CLI never accepts it. + pass + _owned_private_directory(lab_root, owner_uid) + manifest = _read_manifest(lab_root / IDENTITY_FILE, owner_uid) + target_uuid = str(manifest["uuid"]) + + listed = _invoke(runner, ("VBoxManage", "list", "vms")) + rows: list[tuple[str, str]] = [] + for line in listed.splitlines(): + if not line.strip(): + continue + match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line) + if match is None: + raise GuardError("malformed VBoxManage VM inventory row") + name, raw_uuid = match.groups() + try: + listed_uuid = str(uuid.UUID(raw_uuid)) + except ValueError as exc: + raise GuardError("malformed VBoxManage VM inventory UUID") from exc + rows.append((name, listed_uuid)) + if len({name for name, _ in rows}) != len(rows) or len({uid for _, uid in rows}) != len(rows): + raise GuardError("duplicate VBoxManage VM inventory name or UUID") + matching = [(name, listed_uuid) for name, listed_uuid in rows if name == TARGET_NAME] + uuid_matches = [(name, listed_uuid) for name, listed_uuid in rows if listed_uuid.lower() == target_uuid] + if matching != [(TARGET_NAME, target_uuid)] or uuid_matches != [(TARGET_NAME, target_uuid)]: + raise GuardError("exact VM name is missing, duplicated, or has a different UUID") + + output = _invoke(runner, ("VBoxManage", "showvminfo", target_uuid, "--machinereadable")) + info = _machine_values(output) + if info.get("name") != TARGET_NAME or info.get("UUID", "").lower() != target_uuid: + raise GuardError("showvminfo identity does not match the selected target") + marker = str(manifest["host_marker"]) + if info.get("description") != marker: + raise GuardError("VirtualBox host marker is missing or mismatched") + + cfg_file = Path(info.get("CfgFile", "")) + if cfg_file.is_symlink() or cfg_file.name != f"{TARGET_NAME}.vbox": + raise GuardError("VM configuration file is missing or unexpected") + target_folder = lab_root / TARGET_NAME + try: + resolved_lab_root = lab_root.resolve(strict=True) + target_folder_info = target_folder.lstat() + if not stat.S_ISDIR(target_folder_info.st_mode) or stat.S_ISLNK(target_folder_info.st_mode): + raise GuardError("exact target folder must be a real directory") + resolved_target_folder = target_folder.resolve(strict=True) + vm_folder = cfg_file.parent.resolve(strict=True) + config_file = cfg_file.resolve(strict=True) + except OSError as exc: + raise GuardError("VM configuration file or folder is missing") from exc + try: + resolved_target_folder.relative_to(resolved_lab_root) + config_file.relative_to(resolved_lab_root) + except ValueError as exc: + raise GuardError("VM configuration is outside the exact target folder") from exc + if (resolved_target_folder != resolved_lab_root / TARGET_NAME + or vm_folder != resolved_target_folder + or config_file.parent != resolved_target_folder): + raise GuardError("VM configuration is outside the exact target folder") + config_info = config_file.stat() + if not stat.S_ISREG(config_info.st_mode) or config_info.st_uid != owner_uid: + raise GuardError("VM configuration must be a current-user-owned regular file") + + controllers = { + int(match.group(1)): value + for key, value in info.items() + if (match := re.fullmatch(r"storagecontrollername([0-9]+)", key)) + } + if controllers != {0: "SATA"}: + raise GuardError("unexpected storage controller inventory") + + disks: list[Path] = [] + isos: list[Path] = [] + # VirtualBox emits attachment rows as Controller-port-device. Its other + # per-port rows (for example SATA-ImageUUID-0-0 and + # SATA-hot-pluggable-0-0) look similar but are metadata, not media. + metadata_fields = { + "ImageUUID", + "IsEjected", + "hot-pluggable", + "nonrotational", + "discard", + "tempeject", + } + attachment_keys: set[str] = set() + for key, value in info.items(): + if key.startswith("SATA-"): + suffix = key[len("SATA-"):] + if re.fullmatch(r"[0-9]+-[0-9]+", suffix): + attachment_keys.add(key) + continue + metadata = re.fullmatch(r"([A-Za-z][A-Za-z0-9_-]*)-[0-9]+-[0-9]+", suffix) + if metadata and metadata.group(1) in metadata_fields: + continue + # Do not let a disk on an undeclared controller (including a name + # containing hyphens) disappear from the attachment inventory. + if re.fullmatch(r".+-[0-9]+-[0-9]+", key): + raise GuardError("unrecognized attached storage key or controller") + + for key in attachment_keys: + value = info[key] + if value.lower().endswith(".vdi"): + disks.append(_check_attachment_path(value, lab_root, ".vdi")) + elif value.lower().endswith(".iso"): + isos.append(_check_attachment_path(value, lab_root, ".iso")) + elif value not in {"emptydrive", "none"}: + raise GuardError("unexpected attached storage type") + expected_disk = (lab_root / TARGET_NAME / f"{TARGET_NAME}.vdi").resolve(strict=True) + if len(disks) != 1 or disks[0] != expected_disk: + raise GuardError("expected exactly one target VDI in the lab root") + expected_iso = (lab_root / EXPECTED_ISO).resolve(strict=True) + if len(isos) > 1 or (isos and isos[0] != expected_iso): + raise GuardError("unexpected attached ISO") + + nics = {int(match.group(1)): value for key, value in info.items() + if (match := re.fullmatch(r"nic([1-8])", key))} + if nics.get(1) != "nat" or any(mode not in {"none", "null"} for slot, mode in nics.items() if slot != 1): + raise GuardError("target must have only NIC 1 in NAT mode") + if len(nics) != 8: + raise GuardError("NIC inventory is incomplete; refusing ambiguous network state") + if any((key.lower().startswith("natpf") or key.lower().startswith("forwarding(")) and value + for key, value in info.items()): + raise GuardError("NAT port forwarding is prohibited") + if any(key.startswith("SharedFolder") and value for key, value in info.items()): + raise GuardError("shared folders are prohibited") + return target_uuid, info + + +def start_target(*, lab_root: Path = LAB_ROOT, runner: Runner = run_command) -> str: + target_uuid, _ = inspect_target(lab_root=lab_root, runner=runner) + _invoke(runner, ("VBoxManage", "startvm", target_uuid, "--type=gui")) + return target_uuid + + +def authorize_guest(*, password_file: Path, lab_root: Path = LAB_ROOT, runner: Runner = run_command) -> str: + target_uuid, _ = inspect_target(lab_root=lab_root, runner=runner) + try: + info = password_file.lstat() + except OSError as exc: + raise GuardError("guest password file is unavailable") from exc + if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600): + raise GuardError("guest password file must be current-user-owned mode 0600") + output = _invoke(runner, ( + "VBoxManage", "guestcontrol", target_uuid, "run", "--username", "wga", + "--timeout", "15000", "--wait-stdout", + "--passwordfile", str(password_file), "--exe", "/usr/bin/cat", "--", + GUEST_MARKER_PATH, + )) + if output.strip() != EXPECTED_GUEST_MARKER: + raise GuardError("guest-local marker is missing or mismatched") + return target_uuid + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="action", required=True) + sub.add_parser("check", help="inspect the selected VM without changing it") + sub.add_parser("start", help="start only the selected VM after host identity checks") + guest = sub.add_parser("authorize-guest", help="check the guest marker using a password file") + guest.add_argument("--password-file", required=True, type=Path) + args = parser.parse_args(argv) + try: + if args.action == "check": + target_uuid, _ = inspect_target() + elif args.action == "start": + target_uuid = start_target() + else: + target_uuid = authorize_guest(password_file=args.password_file) + except GuardError as exc: + print(f"VM guard refused: {exc}", file=sys.stderr) + return 2 + print(f"{args.action} authorized for {TARGET_NAME} ({target_uuid})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vm/install_boot.py b/tests/vm/install_boot.py new file mode 100644 index 0000000..d8c1eb0 --- /dev/null +++ b/tests/vm/install_boot.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Check and make the bounded first headless start of the pinned M9 installer VM.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +from typing import Sequence +import uuid + +import guard + + +TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac" +TARGET_NAME = guard.TARGET_NAME +LAB_ROOT = guard.LAB_ROOT +TARGET_FOLDER = LAB_ROOT / TARGET_NAME +EXPECTED_VDI = TARGET_FOLDER / f"{TARGET_NAME}.vdi" +EXPECTED_VISO = TARGET_FOLDER / ( + f"Unattended-{TARGET_UUID}-aux-iso.viso" +) +MAX_CAPTURE = guard.MAX_CAPTURE + + +class InstallBootError(Exception): + """Pinned installer state failed validation or first start was uncertain.""" + + +def _regular_owned_file( + path: Path, *, owner_uid: int, mode: int | None, label: str, +) -> Path: + try: + info = path.lstat() + except OSError as exc: + raise InstallBootError(f"{label} is unavailable") from exc + if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) + or info.st_uid != owner_uid + or (mode is not None and stat.S_IMODE(info.st_mode) != mode)): + permission = "a regular current-user-owned file" if mode is None else ( + f"current-user-owned mode {mode:04o}" + ) + raise InstallBootError(f"{label} must be {permission}") + if info.st_nlink != 1: + raise InstallBootError(f"{label} must have exactly one hard link") + try: + return path.resolve(strict=True) + except OSError as exc: + raise InstallBootError(f"{label} is unavailable") from exc + + +def _check_installer_private_files(target_folder: Path, owner_uid: int) -> None: + """Check installer material permissions without opening or displaying contents.""" + def walk_error(error: OSError) -> None: + raise InstallBootError("private target folder cannot be inspected") from error + + try: + walk = tuple(os.walk(target_folder, followlinks=False, onerror=walk_error)) + except OSError as exc: + raise InstallBootError("private target folder cannot be inspected") from exc + for directory, subdirectories, filenames in walk: + directory_path = Path(directory) + if any((directory_path / name).is_symlink() for name in subdirectories): + raise InstallBootError("symlink in private target folder is prohibited") + for name in subdirectories: + child = directory_path / name + child_info = child.lstat() + if (not stat.S_ISDIR(child_info.st_mode) or child_info.st_uid != owner_uid + or stat.S_IMODE(child_info.st_mode) != 0o700): + raise InstallBootError("nested target directories must be current-user-owned mode 0700") + for filename in filenames: + entry = directory_path / filename + if entry.is_symlink(): + raise InstallBootError("symlink in private target folder is prohibited") + # VirtualBox's unattended installer generates the sensitive auxiliary + # media and installer scripts here. Match names only; never read content. + if re.search(r"(?:viso|isolinux|preseed|postinstall)", entry.name, re.IGNORECASE): + _regular_owned_file( + entry, owner_uid=owner_uid, mode=0o600, + label="generated unattended installer file", + ) + + +def _machine_info(*, lab_root: Path, runner: guard.Runner, owner_uid: int) -> tuple[str, dict[str, str]]: + """Apply strict host identity checks while allowing only the known installer .viso.""" + try: + guard._owned_private_directory(lab_root, owner_uid) + manifest = guard._read_manifest(lab_root / guard.IDENTITY_FILE, owner_uid) + selected_uuid = str(manifest["uuid"]) + if selected_uuid != TARGET_UUID: + raise InstallBootError("identity manifest UUID is not the pinned installer target") + + listed = guard._invoke(runner, ("VBoxManage", "list", "vms")) + rows: list[tuple[str, str]] = [] + for line in listed.splitlines(): + if not line.strip(): + continue + match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line) + if match is None: + raise InstallBootError("malformed VirtualBox VM inventory") + name, raw_uuid = match.groups() + try: + vm_uuid = str(uuid.UUID(raw_uuid)) + except ValueError as exc: + raise InstallBootError("malformed VirtualBox VM UUID") from exc + rows.append((name, vm_uuid)) + if (len({name for name, _ in rows}) != len(rows) + or len({vm_uuid for _, vm_uuid in rows}) != len(rows) + or [(name, vm_uuid) for name, vm_uuid in rows + if name == TARGET_NAME or vm_uuid == TARGET_UUID] + != [(TARGET_NAME, TARGET_UUID)]): + raise InstallBootError("pinned VM name and UUID are missing, duplicated, or mismatched") + + info = guard._machine_values(guard._invoke( + runner, ("VBoxManage", "showvminfo", TARGET_UUID, "--machinereadable"), + )) + except guard.GuardError as exc: + raise InstallBootError(f"strict host identity check failed: {exc}") from None + + if info.get("name") != TARGET_NAME or info.get("UUID", "").lower() != TARGET_UUID: + raise InstallBootError("showvminfo identity does not match the pinned target") + marker = f"{TARGET_NAME}:{TARGET_UUID}" + if manifest.get("host_marker") != marker or info.get("description") != marker: + raise InstallBootError("manifest and VirtualBox host marker do not match") + if info.get("VMState") != "poweroff": + raise InstallBootError("pinned target must be powered off before first start") + + folder = lab_root / TARGET_NAME + try: + folder_info = folder.lstat() + except OSError as exc: + raise InstallBootError("exact target folder is unavailable") from exc + if (not stat.S_ISDIR(folder_info.st_mode) or stat.S_ISLNK(folder_info.st_mode) + or folder_info.st_uid != owner_uid or stat.S_IMODE(folder_info.st_mode) != 0o700): + raise InstallBootError("exact target folder must be current-user-owned mode 0700") + resolved_root = lab_root.resolve(strict=True) + resolved_folder = folder.resolve(strict=True) + if resolved_folder != resolved_root / TARGET_NAME: + raise InstallBootError("exact target folder resolves outside the lab root") + + cfg_file = Path(info.get("CfgFile", "")) + if cfg_file.is_symlink() or cfg_file.name != f"{TARGET_NAME}.vbox": + raise InstallBootError("VM configuration path is unexpected") + config = _regular_owned_file( + cfg_file, owner_uid=owner_uid, mode=None, + label="VM configuration file", + ) + if config.parent != resolved_folder: + raise InstallBootError("VM configuration is outside the exact target folder") + + expected_disk = _regular_owned_file( + EXPECTED_VDI, owner_uid=owner_uid, mode=None, + label="pinned target VDI", + ) + expected_viso = _regular_owned_file( + EXPECTED_VISO, owner_uid=owner_uid, mode=0o600, label="generated auxiliary .viso", + ) + if expected_disk.parent != resolved_folder or expected_viso.parent != resolved_folder: + raise InstallBootError("expected VM media resolves outside the exact target folder") + if info.get("storagecontrollername0") != "SATA" or any( + key.startswith("storagecontrollername") and key != "storagecontrollername0" + for key in info + ): + raise InstallBootError("storage controller inventory is not the pinned SATA controller") + attachments = { + key: value for key, value in info.items() + if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key) + } + if (attachments.get("SATA-0-0") != str(expected_disk) + or attachments.get("SATA-1-0") != str(expected_viso) + or any(attachments.get(f"SATA-{port}-0") != "none" for port in range(2, 30)) + or set(attachments) != {f"SATA-{port}-0" for port in range(30)}): + raise InstallBootError("SATA media do not exactly match the pinned installer state") + for key, value in info.items(): + if (key.startswith("SATA-") and not re.fullmatch( + r"SATA-(?:[0-9]+-[0-9]+|(?:ImageUUID|IsEjected|hot-pluggable|nonrotational|discard|tempeject)-[0-9]+-[0-9]+)", key)): + raise InstallBootError("unrecognized SATA attachment metadata") + if (not key.startswith("SATA-") + and re.fullmatch(r".+-[0-9]+-[0-9]+", key)): + raise InstallBootError("unexpected attached storage controller or media") + if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key) and value not in {"none", str(expected_disk), str(expected_viso)}: + raise InstallBootError("unexpected attached media") + + if {key for key in info if re.fullmatch(r"nic[0-9]+", key)} != { + f"nic{slot}" for slot in range(1, 9) + }: + raise InstallBootError("network adapter inventory is incomplete or has extra devices") + nics = {f"nic{slot}": info.get(f"nic{slot}") for slot in range(1, 9)} + if nics != {"nic1": "nat", **{f"nic{slot}": "none" for slot in range(2, 9)}}: + raise InstallBootError("network inventory is not the pinned NAT-only configuration") + if any(key.lower().startswith(("natpf", "forwarding(")) and value + for key, value in info.items()): + raise InstallBootError("NAT port forwarding is prohibited") + if any(key.startswith("SharedFolder") and value for key, value in info.items()): + raise InstallBootError("VirtualBox shared folders are prohibited") + + _check_installer_private_files(resolved_folder, owner_uid) + return TARGET_UUID, info + + +def inspect_installer_state( + *, lab_root: Path = LAB_ROOT, + owner_uid: int | None = None, + runner: guard.Runner = guard.run_command, +) -> tuple[str, dict[str, str]]: + owner_uid = os.getuid() if owner_uid is None else owner_uid + return _machine_info(lab_root=lab_root, runner=runner, owner_uid=owner_uid) + + +def start_installer( + *, lab_root: Path = LAB_ROOT, + owner_uid: int | None = None, + runner: guard.Runner = guard.run_command, +) -> str: + target_uuid, _ = inspect_installer_state( + lab_root=lab_root, owner_uid=owner_uid, runner=runner, + ) + try: + guard._invoke(runner, ("VBoxManage", "startvm", target_uuid, "--type=headless")) + except guard.GuardError: + raise InstallBootError( + "bounded first-start request failed or timed out; the VM may have started; " + "run read-only inspection before any further action" + ) from None + return target_uuid + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("check", "start")) + args = parser.parse_args(argv) + try: + if args.action == "check": + target_uuid, _ = inspect_installer_state() + else: + target_uuid = start_installer() + except (InstallBootError, guard.GuardError) as exc: + print(f"Installer-state guard refused: {exc}", file=sys.stderr) + return 2 + if args.action == "check": + print(f"installer state checked for {TARGET_NAME} ({target_uuid}); VM remains powered off") + else: + print( + f"bounded first-start request completed for {TARGET_NAME} ({target_uuid}); " + "guest installation and boot are not verified" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vm/install_prepare.py b/tests/vm/install_prepare.py new file mode 100644 index 0000000..e66ef96 --- /dev/null +++ b/tests/vm/install_prepare.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Prepare the one pinned M9 VM for unattended installation; never start it.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import re +import selectors +import stat +import subprocess +import sys +import time +from typing import Callable, Sequence + +import guard + + +TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac" +LAB_ROOT = guard.LAB_ROOT +ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO +ISO_SHA256 = guard.EXPECTED_ISO_SHA256 +ADDITIONS_ISO = Path("/usr/share/virtualbox/VBoxGuestAdditions.iso") +ADDITIONS_SHA256 = "4f51a073296de31cce53924860549149be5dc339f65dcd1dbf34fd7accefe8fb" +PASSWORD_DIR = Path("/tmp/wga-m9-private") +USER_PASSWORD_FILE = PASSWORD_DIR / "wga-password" +ROOT_PASSWORD_FILE = PASSWORD_DIR / "root-password" +MAX_CAPTURE = 1024 * 1024 +TIMEOUT_SECONDS = 120 + + +class PrepareError(Exception): + """Preflight refused or unattended preparation stopped; output is redacted.""" + + +Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]] + + +def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]: + """Run bounded VBoxManage while retaining its potentially secret output privately.""" + process = subprocess.Popen( + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"}, + ) + assert process.stdout is not None and process.stderr is not None + output = {process.stdout: bytearray(), process.stderr: bytearray()} + selector = selectors.DefaultSelector() + for stream in output: + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ) + deadline = time.monotonic() + TIMEOUT_SECONDS + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(argv, TIMEOUT_SECONDS) + for key, _ in selector.select(remaining): + stream = key.fileobj + chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream]))) + if not chunk: + selector.unregister(stream) + continue + output[stream].extend(chunk) + if len(output[stream]) > MAX_CAPTURE: + raise PrepareError("VBoxManage output exceeded the preparation limit") + returncode = process.wait(timeout=max(0.0, deadline - time.monotonic())) + except (OSError, subprocess.TimeoutExpired, PrepareError): + process.kill() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + raise + finally: + selector.close() + process.stdout.close() + process.stderr.close() + return subprocess.CompletedProcess( + argv, returncode, + bytes(output[process.stdout]).decode("utf-8", errors="replace"), + bytes(output[process.stderr]).decode("utf-8", errors="replace"), + ) + + +def _check_private_file(path: Path, owner_uid: int) -> None: + try: + info = path.lstat() + except OSError as exc: + raise PrepareError("private synthetic password file is unavailable") from exc + if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) + or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_size == 0): + raise PrepareError("synthetic password files must be nonempty current-user files with mode 0600") + + +def _check_private_credentials(owner_uid: int) -> None: + try: + info = PASSWORD_DIR.lstat() + except OSError as exc: + raise PrepareError("private synthetic password directory is unavailable") from exc + if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode) + or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700): + raise PrepareError("synthetic password directory must be current-user-owned mode 0700") + _check_private_file(USER_PASSWORD_FILE, owner_uid) + _check_private_file(ROOT_PASSWORD_FILE, owner_uid) + + +def _check_digest(path: Path, expected: str, label: str) -> None: + try: + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise PrepareError(f"{label} must be a regular non-symlink file") + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + except OSError as exc: + raise PrepareError(f"{label} is unavailable") from exc + if digest.hexdigest() != expected: + raise PrepareError(f"{label} SHA-256 does not match the pinned value") + + +def _validate_preinstall_state(info: dict[str, str]) -> None: + if info.get("VMState") != "poweroff": + raise PrepareError("exact target must be powered off before preparation") + expected_disk = str((LAB_ROOT / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vdi").resolve(strict=True)) + expected_iso = str(ISO_PATH.resolve(strict=True)) + attachments = {key: value for key, value in info.items() + if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key)} + if (attachments.get("SATA-0-0") != expected_disk + or attachments.get("SATA-1-0") != expected_iso): + raise PrepareError("target media do not match the expected VDI and pinned installer ISO") + extra_attachments = set(attachments) - {"SATA-0-0", "SATA-1-0"} + if any(attachments[key] not in {"none", "emptydrive"} for key in extra_attachments): + raise PrepareError("target media do not match the expected VDI and pinned installer ISO") + + +def _arguments(*, dry_run: bool) -> tuple[str, ...]: + args = [ + "VBoxManage", "unattended", "install", TARGET_UUID, + f"--iso={ISO_PATH}", + "--user=wga", "--full-user-name=WireGuard Admin Lab", + f"--user-password-file={USER_PASSWORD_FILE}", + f"--admin-password-file={ROOT_PASSWORD_FILE}", + "--hostname=wga-m9-debian137.invalid", "--locale=en_US", "--time-zone=UTC", + "--country=US", "--package-selection-adjustment=minimal", + "--install-additions", f"--additions-iso={ADDITIONS_ISO}", + "--start-vm=none", + ] + if dry_run: + args.append("--dry-run") + return tuple(args) + + +def _invoke(runner: Runner, argv: Sequence[str], stage: str) -> None: + try: + result = runner(argv) + except PrepareError as exc: + if str(exc) == "VBoxManage output exceeded the preparation limit": + raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed") from None + raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None + except Exception: + raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None + if result.returncode != 0: + raise PrepareError(f"{stage} failed with status {result.returncode}; VBoxManage output was suppressed") + if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE: + raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed") + + +def prepare_target(*, runner: Runner = run_command, owner_uid: int | None = None) -> str: + owner_uid = os.getuid() if owner_uid is None else owner_uid + _check_private_credentials(owner_uid) + if guard.EXPECTED_ISO_SHA256 != ISO_SHA256: + raise PrepareError("configured installer digest differs from the pinned digest") + _check_digest(ISO_PATH, ISO_SHA256, "installer ISO") + _check_digest(ADDITIONS_ISO, ADDITIONS_SHA256, "Guest Additions ISO") + try: + target_uuid, info = guard.inspect_target(runner=runner) + except (guard.GuardError, OSError, subprocess.TimeoutExpired): + raise PrepareError("read-only exact-target guard failed; refusing preparation") from None + if target_uuid != TARGET_UUID: + raise PrepareError("read-only guard returned an unexpected target UUID") + _validate_preinstall_state(info) + _invoke(runner, _arguments(dry_run=True), "unattended dry run") + try: + _invoke(runner, _arguments(dry_run=False), "unattended preparation") + except PrepareError: + raise PrepareError( + f"unattended preparation stopped or partially completed for {TARGET_UUID}; " + "do not rerun; perform separate read-only inspection before any separately reviewed start" + ) from None + return TARGET_UUID + + +def main() -> int: + try: + target_uuid = prepare_target() + except PrepareError as exc: + print(f"Unattended preparation refused/stopped: {exc}", file=sys.stderr) + return 2 + print( + f"Unattended preparation completed for the pinned target ({target_uuid}); " + "VM remains unstarted. Perform separate read-only media inspection before any separately reviewed start." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vm/resume.py b/tests/vm/resume.py new file mode 100644 index 0000000..7dff7d5 --- /dev/null +++ b/tests/vm/resume.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Resume configuration of the one reviewed, powered-off partial M9 VM.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +from typing import Sequence +import uuid + +import guard +import create + + +LAB_ROOT = guard.LAB_ROOT +ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO +VM_NAME = "wga-m9-debian137-20261025" +TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac" +PINNED_ISO_SHA256 = "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355" + + +class ResumeError(Exception): + """The partial VM could not safely be resumed or configured.""" + + +Runner = create.Runner + + +def _require_absent(path: Path, description: str) -> None: + try: + path.lstat() + except FileNotFoundError: + return + except OSError as exc: + raise ResumeError(f"could not inspect {description}; refusing resume") from exc + raise ResumeError(f"{description} already exists; refusing resume") + + +def _check_iso(iso_path: Path) -> None: + if guard.EXPECTED_ISO_SHA256 != PINNED_ISO_SHA256: + raise ResumeError("configured ISO digest differs from the pinned digest") + if iso_path != ISO_PATH: + raise ResumeError("installer ISO path is not the pinned lab path") + try: + if iso_path.parent.resolve(strict=True) != LAB_ROOT.resolve(strict=True): + raise ResumeError("installer ISO must be directly inside the exact lab root") + info = iso_path.lstat() + except OSError as exc: + raise ResumeError("pinned installer ISO is unavailable") from exc + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise ResumeError("installer ISO must be a regular non-symlink file") + digest = hashlib.sha256() + try: + with iso_path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise ResumeError("pinned installer ISO cannot be read") from exc + if digest.hexdigest() != PINNED_ISO_SHA256: + raise ResumeError("installer ISO SHA-256 does not match the pinned digest") + + +def _inventory(runner: Runner) -> None: + output = guard._invoke(runner, ("VBoxManage", "list", "vms")) + names: set[str] = set() + uuids: set[str] = set() + matches: list[tuple[str, str]] = [] + for line in output.splitlines(): + if not line.strip(): + continue + match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line) + if not match: + raise ResumeError("malformed VBoxManage inventory; refusing resume") + name, raw_uuid = match.groups() + try: + vm_uuid = str(uuid.UUID(raw_uuid)) + except ValueError as exc: + raise ResumeError("malformed VBoxManage inventory UUID; refusing resume") from exc + if name in names or vm_uuid in uuids: + raise ResumeError("duplicate VBoxManage inventory identity; refusing resume") + names.add(name) + uuids.add(vm_uuid) + if name == VM_NAME or vm_uuid == TARGET_UUID: + matches.append((name, vm_uuid)) + if matches != [(VM_NAME, TARGET_UUID)]: + raise ResumeError("registered VM inventory does not match the pinned partial target") + + +def _preflight_info(info: dict[str, str], lab_root: Path, owner_uid: int) -> None: + if info.get("name") != VM_NAME or info.get("UUID", "").lower() != TARGET_UUID: + raise ResumeError("showvminfo identity does not match the pinned partial target") + if info.get("VMState") != "poweroff": + raise ResumeError("partial target must be powered off") + if info.get("description", ""): + raise ResumeError("partial target already has a host marker; refusing resume") + if info.get("memory") != "1024" or info.get("cpus") != "1": + raise ResumeError("partial target default memory or CPU state is unexpected") + + cfg_file = Path(info.get("CfgFile", "")) + if cfg_file != lab_root / VM_NAME / f"{VM_NAME}.vbox" or cfg_file.is_symlink(): + raise ResumeError("partial target configuration path is not the exact lab path") + try: + folder_info = (lab_root / VM_NAME).lstat() + config_info = cfg_file.lstat() + except OSError as exc: + raise ResumeError("partial target configuration file is unavailable") from exc + if (not stat.S_ISDIR(folder_info.st_mode) or stat.S_ISLNK(folder_info.st_mode) + or folder_info.st_uid != owner_uid + or (lab_root / VM_NAME).resolve(strict=True) != lab_root / VM_NAME): + raise ResumeError("partial target folder must be a real directory at the exact lab path") + if (not stat.S_ISREG(config_info.st_mode) or stat.S_ISLNK(config_info.st_mode) + or config_info.st_uid != owner_uid): + raise ResumeError("partial target configuration must be a current-user-owned regular file") + + nics = {int(match.group(1)): value for key, value in info.items() + if (match := re.fullmatch(r"nic([1-8])", key))} + if (len(nics) != 8 or nics.get(1) != "nat" + or any(nics.get(slot) not in {"none", "null"} for slot in range(2, 9))): + raise ResumeError("partial target NIC state must be NIC 1 in NAT with NICs 2-8 disabled") + if any((key.lower().startswith("natpf") or key.lower().startswith("forwarding(")) and value + for key, value in info.items()): + raise ResumeError("partial target has NAT forwarding; refusing resume") + if any(key.startswith("SharedFolder") and value for key, value in info.items()): + raise ResumeError("partial target has shared folders; refusing resume") + storage_rows = [ + (key, value) for key, value in info.items() + if re.fullmatch(r"[A-Za-z0-9_-]+-[0-9]+-[0-9]+", key) + ] + if any(value not in {"", "emptydrive", "none"} for _, value in storage_rows): + raise ResumeError("partial target has attached storage; refusing resume") + if any(key.startswith("storagecontroller") for key in info): + raise ResumeError("partial target has a storage controller; refusing resume") + + +def _preflight( + *, lab_root: Path, iso_path: Path, owner_uid: int, runner: Runner, +) -> None: + if lab_root != LAB_ROOT: + raise ResumeError("resume is restricted to the pinned lab root") + try: + root_info = lab_root.lstat() + except OSError as exc: + raise ResumeError("private lab root is unavailable") from exc + if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode) + or root_info.st_uid != owner_uid or stat.S_IMODE(root_info.st_mode) != 0o700): + raise ResumeError("lab root must be a real current-user directory with mode 0700") + _check_iso(iso_path) + _require_absent(lab_root / guard.IDENTITY_FILE, "target identity manifest") + _require_absent(lab_root / VM_NAME / f"{VM_NAME}.vdi", "target VDI") + _inventory(runner) + info_text = guard._invoke(runner, ( + "VBoxManage", "showvminfo", TARGET_UUID, "--machinereadable", + )) + try: + info = guard._machine_values(info_text) + except guard.GuardError as exc: + raise ResumeError("partial target machine-readable state is invalid") from exc + _preflight_info(info, lab_root, owner_uid) + + +def _write_manifest(lab_root: Path, owner_uid: int) -> None: + marker = f"{VM_NAME}:{TARGET_UUID}" + data = { + "schema": 1, + "name": VM_NAME, + "owner_uid": owner_uid, + "uuid": TARGET_UUID, + "host_marker": marker, + } + path = lab_root / guard.IDENTITY_FILE + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(path, flags, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as destination: + os.fchmod(destination.fileno(), 0o600) + json.dump(data, destination, sort_keys=True) + destination.write("\n") + destination.flush() + os.fsync(destination.fileno()) + except OSError as exc: + raise ResumeError(f"could not write identity manifest; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc + + +def _call(runner: Runner, argv: Sequence[str]) -> str: + try: + result = runner(tuple(argv)) + except (OSError, subprocess.TimeoutExpired, create.CreateError, guard.GuardError) as exc: + raise ResumeError(f"bounded VBoxManage call failed: {argv[1]}") from exc + if len(result.stdout) > create.MAX_CAPTURE or len(result.stderr) > create.MAX_CAPTURE: + raise ResumeError("VBoxManage output exceeded the resume limit") + if result.returncode != 0: + raise ResumeError(f"VBoxManage {argv[1]} failed with status {result.returncode}") + return result.stdout + + +def resume_target( + *, lab_root: Path = LAB_ROOT, + iso_path: Path = ISO_PATH, + owner_uid: int | None = None, + runner: Runner = create.run_command, +) -> str: + """Preflight the exact partial VM, finish setup, and run the read-only guard.""" + owner_uid = os.getuid() if owner_uid is None else owner_uid + try: + _preflight(lab_root=lab_root, iso_path=iso_path, owner_uid=owner_uid, runner=runner) + except (OSError, guard.GuardError) as exc: + raise ResumeError("resume preflight failed closed") from exc + + disk_path = lab_root / VM_NAME / f"{VM_NAME}.vdi" + try: + _call(runner, ( + "VBoxManage", "modifyvm", TARGET_UUID, + "--memory", str(create.MEMORY_MIB), "--cpus", str(create.CPUS), + "--nic1", "nat", "--nic2", "none", "--nic3", "none", "--nic4", "none", + "--nic5", "none", "--nic6", "none", "--nic7", "none", "--nic8", "none", + "--audio", "none", "--usb", "off", + "--boot1", "dvd", "--boot2", "disk", "--boot3", "none", "--boot4", "none", + )) + _call(runner, ( + "VBoxManage", "createmedium", "disk", "--filename", str(disk_path), + "--size", str(create.DISK_SIZE_MIB), "--format", "VDI", "--variant", "Standard", + )) + _call(runner, ( + "VBoxManage", "storagectl", TARGET_UUID, "--name", "SATA", + "--add", "sata", "--controller", "IntelAhci", + )) + _call(runner, ( + "VBoxManage", "storageattach", TARGET_UUID, "--storagectl", "SATA", + "--port", "0", "--device", "0", "--type", "hdd", "--medium", str(disk_path), + )) + _call(runner, ( + "VBoxManage", "storageattach", TARGET_UUID, "--storagectl", "SATA", + "--port", "1", "--device", "0", "--type", "dvddrive", "--medium", str(iso_path), + )) + _write_manifest(lab_root, owner_uid) + _call(runner, ( + "VBoxManage", "modifyvm", TARGET_UUID, + "--description", f"{VM_NAME}:{TARGET_UUID}", + )) + try: + selected_uuid, _ = guard.inspect_target( + lab_root=lab_root, owner_uid=owner_uid, runner=runner, + ) + except (guard.GuardError, OSError) as exc: + raise ResumeError("final read-only guard failed") from exc + if selected_uuid != TARGET_UUID: + raise ResumeError("final guard returned an unexpected UUID") + return TARGET_UUID + except ResumeError as exc: + if "retained partial VM" in str(exc): + raise + raise ResumeError(f"{exc}; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc + except (OSError, ValueError, KeyError, create.CreateError, guard.GuardError) as exc: + raise ResumeError(f"resume configuration failed; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.parse_args(argv) + try: + target_uuid = resume_target() + except ResumeError as exc: + print(f"VM resume refused/stopped: {exc}", file=sys.stderr) + return 2 + print(f"Resumed and guarded {VM_NAME} ({target_uuid}); VM remains powered off") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vm/test_create.py b/tests/vm/test_create.py new file mode 100644 index 0000000..0997d89 --- /dev/null +++ b/tests/vm/test_create.py @@ -0,0 +1,209 @@ +"""Behavioral tests for the M9 create-only VirtualBox procedure.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +import uuid + + +HERE = Path(__file__).parent +sys.path.insert(0, str(HERE)) +SPEC = importlib.util.spec_from_file_location("vm_create", HERE / "create.py") +assert SPEC and SPEC.loader +create = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(create) + + +class FakeVBox: + def __init__(self, root: Path, iso: Path, *, inventory: str = ""): + self.root = root + self.iso = iso + self.inventory = inventory + self.target_uuid = "1fd553db-3b0e-43e8-b407-244ea5f8c5c8" + self.description = "" + self.storage_controllers: dict[str, str] = {} + self.calls: list[tuple[str, ...]] = [] + self.mutations: list[tuple[str, ...]] = [] + + def __call__(self, argv): + args = tuple(argv) + self.calls.append(args) + command = args[1] + if command not in {"list", "showvminfo"}: + self.mutations.append(args) + if args[1:3] == ("list", "vms"): + return subprocess.CompletedProcess(args, 0, self.inventory, "") + if command == "createvm": + folder = self.root / create.VM_NAME + folder.mkdir() + (folder / f"{create.VM_NAME}.vbox").write_text("synthetic", encoding="ascii") + self.inventory = f'"{create.VM_NAME}" {{{self.target_uuid}}}\n' + return subprocess.CompletedProcess(args, 0, f"VM created.\nUUID: {self.target_uuid}\n", "") + if command == "createmedium": + Path(args[args.index("--filename") + 1]).write_bytes(b"synthetic disk") + return subprocess.CompletedProcess(args, 0, "", "") + if command == "storagectl": + self.storage_controllers["storagecontrollername0"] = args[args.index("--name") + 1] + return subprocess.CompletedProcess(args, 0, "", "") + if command == "modifyvm" and "--description" in args: + self.description = args[args.index("--description") + 1] + return subprocess.CompletedProcess(args, 0, "", "") + if command == "showvminfo": + info = { + "name": create.VM_NAME, + "UUID": self.target_uuid, + "description": self.description, + "CfgFile": str(self.root / create.VM_NAME / f"{create.VM_NAME}.vbox"), + "nic1": "nat", + **{f"nic{slot}": "none" for slot in range(2, 9)}, + "natpf1": "", + "SATA-0-0": str(self.root / create.VM_NAME / f"{create.VM_NAME}.vdi"), + "SATA-1-0": str(self.iso), + **self.storage_controllers, + } + output = "\n".join(f"{key}={json.dumps(value)}" for key, value in info.items()) + "\n" + return subprocess.CompletedProcess(args, 0, output, "") + return subprocess.CompletedProcess(args, 0, "", "") + + +class CreateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "lab" + self.root.mkdir(mode=0o700) + os.chmod(self.root, 0o700) + self.iso = self.root / create.guard.EXPECTED_ISO + self.iso.write_bytes(b"synthetic ISO") + self.digest = __import__("hashlib").sha256(self.iso.read_bytes()).hexdigest() + self.vbox = FakeVBox(self.root, self.iso) + + def tearDown(self): + self.temp.cleanup() + + def run_create(self, runner=None): + with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest): + return create.create_target( + lab_root=self.root, iso_path=self.iso, runner=runner or self.vbox, + ) + + def test_create_is_scoped_and_guarded_with_mode_0600_manifest(self): + target_uuid = self.run_create() + self.assertEqual(target_uuid, self.vbox.target_uuid) + self.assertEqual(self.vbox.calls[2][1:4], ("showvminfo", target_uuid, "--machinereadable")) + manifest_path = self.root / create.guard.IDENTITY_FILE + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(manifest["name"], create.VM_NAME) + self.assertEqual(manifest["uuid"], target_uuid) + self.assertEqual(manifest["host_marker"], f"{create.VM_NAME}:{target_uuid}") + self.assertEqual(manifest_path.stat().st_mode & 0o777, 0o600) + + createvm = self.vbox.mutations[0] + self.assertEqual(createvm[:2], ("VBoxManage", "createvm")) + self.assertEqual(createvm[createvm.index("--name") + 1], create.VM_NAME) + self.assertEqual(createvm[createvm.index("--ostype") + 1], "Debian13_64") + self.assertEqual(createvm[createvm.index("--basefolder") + 1], str(self.root)) + modify = next(call for call in self.vbox.mutations if call[1] == "modifyvm" and "--memory" in call) + self.assertEqual(modify[modify.index("--memory") + 1], "1024") + self.assertEqual(modify[modify.index("--cpus") + 1], "1") + self.assertEqual(modify[modify.index("--nic1") + 1], "nat") + for slot in range(2, 9): + self.assertEqual(modify[modify.index(f"--nic{slot}") + 1], "none") + self.assertEqual(modify[modify.index("--audio") + 1], "none") + self.assertEqual(modify[modify.index("--usb") + 1], "off") + self.assertEqual(modify[modify.index("--boot1") + 1], "dvd") + self.assertEqual(modify[modify.index("--boot2") + 1], "disk") + disk_call = next(call for call in self.vbox.mutations if call[1] == "createmedium") + self.assertEqual(disk_call[disk_call.index("--size") + 1], "8192") + self.assertFalse(any(call[1] in {"startvm", "unregistervm", "snapshot"} for call in self.vbox.calls)) + self.assertEqual(self.vbox.calls[-2][1:], ("list", "vms")) + self.assertEqual(self.vbox.calls[-1][1], "showvminfo") + + def test_preflight_failures_make_no_mutating_calls(self): + cases = ( + ("bad-digest", "ISO SHA-256"), + ("registered", "already registered"), + ("malformed", "malformed VBoxManage inventory"), + ("manifest", "manifest already exists"), + ) + for case, message in cases: + with self.subTest(case=case): + fake = FakeVBox(self.root, self.iso) + iso_path = self.iso + if case == "bad-digest": + with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", "0" * 64): + with self.assertRaisesRegex(create.CreateError, message): + create.create_target(lab_root=self.root, iso_path=iso_path, runner=fake) + self.assertEqual(fake.calls, []) + continue + if case == "registered": + fake.inventory = f'"{create.VM_NAME}" {{{fake.target_uuid}}}\n' + elif case == "malformed": + fake.inventory = "not a machine-readable row\n" + else: + (self.root / create.guard.IDENTITY_FILE).write_text("exists", encoding="ascii") + with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest): + with self.assertRaisesRegex(create.CreateError, message): + create.create_target(lab_root=self.root, iso_path=iso_path, runner=fake) + self.assertEqual(fake.mutations, []) + + def test_occupied_manifest_and_target_paths_make_no_vbox_calls(self): + cases = ( + ("manifest-file", "manifest already exists", "manifest", False), + ("manifest-dangling-symlink", "manifest already exists", "manifest", True), + ("target-directory", "exact target VM path already exists", "target", False), + ("target-dangling-symlink", "exact target VM path already exists", "target", True), + ) + for case, message, occupied_kind, dangling in cases: + with self.subTest(case=case): + fake = FakeVBox(self.root, self.iso) + occupied_path = ( + self.root / create.guard.IDENTITY_FILE + if occupied_kind == "manifest" + else self.root / create.VM_NAME + ) + if dangling: + occupied_path.symlink_to(self.root / "missing-target") + elif occupied_kind == "manifest": + occupied_path.write_text("occupied", encoding="ascii") + else: + occupied_path.mkdir() + + with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest): + with self.assertRaisesRegex(create.CreateError, message): + create.create_target(lab_root=self.root, iso_path=self.iso, runner=fake) + self.assertEqual(fake.calls, []) + + if dangling or occupied_kind == "manifest": + occupied_path.unlink() + else: + occupied_path.rmdir() + + def test_failed_configuration_retains_partial_identity_and_does_not_cleanup(self): + class FailingVBox(FakeVBox): + def __call__(inner, argv): + if len(argv) > 1 and argv[1] == "storagectl": + args = tuple(argv) + inner.calls.append(args) + inner.mutations.append(args) + return subprocess.CompletedProcess(args, 1, "", "sensitive detail omitted") + return super(FailingVBox, inner).__call__(argv) + + fake = FailingVBox(self.root, self.iso) + with mock.patch.object(create.guard, "EXPECTED_ISO_SHA256", self.digest): + with self.assertRaisesRegex(create.CreateError, self.vbox.target_uuid): + create.create_target(lab_root=self.root, iso_path=self.iso, runner=fake) + self.assertTrue((self.root / create.VM_NAME / f"{create.VM_NAME}.vbox").exists()) + self.assertFalse(any(call[1] in {"unregistervm", "closevm", "deletevm"} for call in fake.calls)) + self.assertNotIn("sensitive detail omitted", str(fake.calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vm/test_guard.py b/tests/vm/test_guard.py new file mode 100644 index 0000000..a052e21 --- /dev/null +++ b/tests/vm/test_guard.py @@ -0,0 +1,365 @@ +"""Behavioral tests for the fail-closed VirtualBox target guard. + +These tests use a temporary synthetic VM inventory and a recording fake CLI. +They never invoke VBoxManage, start a VM, or contact a host service. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import uuid + + +SPEC = importlib.util.spec_from_file_location("vm_guard", Path(__file__).with_name("guard.py")) +assert SPEC and SPEC.loader +guard = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(guard) + + +class FakeVBox: + def __init__(self, root: Path, *, mutate: dict[str, str] | None = None, + listed_uuid: str | None = None, duplicate: bool = False, + extra_list_rows: tuple[str, ...] = (), + guest_marker: str = guard.EXPECTED_GUEST_MARKER): + self.root = root + self.mutate = mutate or {} + self.listed_uuid = listed_uuid + self.duplicate = duplicate + self.extra_list_rows = extra_list_rows + self.guest_marker = guest_marker + self.calls: list[tuple[str, ...]] = [] + self.target_uuid = str(uuid.UUID("1fd553db-3b0e-43e8-b407-244ea5f8c5c8")) + + def __call__(self, argv): + args = tuple(argv) + self.calls.append(args) + if args[1:3] == ("list", "vms"): + uid = self.listed_uuid or self.target_uuid + rows = [f'"{guard.TARGET_NAME}" {{{uid}}}'] + if self.duplicate: + rows.append(f'"{guard.TARGET_NAME}" {{{self.target_uuid}}}') + rows.extend(self.extra_list_rows) + return subprocess.CompletedProcess(args, 0, "\n".join(rows) + "\n", "") + if args[1] == "showvminfo": + lines = { + "name": guard.TARGET_NAME, + "UUID": self.target_uuid, + "description": f"{guard.TARGET_NAME}:{self.target_uuid}", + "CfgFile": str(self.root / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vbox"), + "nic1": "nat", + **{f"nic{n}": "none" for n in range(2, 9)}, + "natpf1": "", + "storagecontrollername0": "SATA", + "SATA-0-0": str(self.root / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vdi"), + "SATA-1-0": str(self.root / guard.EXPECTED_ISO), + "SATA-2-0": "none", + "SATA-ImageUUID-0-0": "{synthetic-disk-uuid}", + "SATA-hot-pluggable-0-0": "off", + "SATA-nonrotational-0-0": "off", + "SATA-discard-0-0": "off", + "SATA-IsEjected-1-0": "off", + "SATA-tempeject-1-0": "off", + } + lines.update(self.mutate) + out = "\n".join( + f'{json.dumps(key) if "-" in key else key}={json.dumps(value)}' + for key, value in lines.items() + ) + "\n" + return subprocess.CompletedProcess(args, 0, out, "") + if args[1:3] == ("guestcontrol", self.target_uuid): + return subprocess.CompletedProcess(args, 0, self.guest_marker + "\n", "") + return subprocess.CompletedProcess(args, 0, "", "") + + +class GuardTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "lab" + self.root.mkdir(mode=0o700) + os.chmod(self.root, 0o700) + self.vm_dir = self.root / guard.TARGET_NAME + self.vm_dir.mkdir() + (self.vm_dir / f"{guard.TARGET_NAME}.vbox").write_text("synthetic", encoding="ascii") + (self.vm_dir / f"{guard.TARGET_NAME}.vdi").write_bytes(b"synthetic disk") + (self.root / guard.EXPECTED_ISO).write_bytes(b"synthetic ISO marker") + self.vbox = FakeVBox(self.root) + marker = f"{guard.TARGET_NAME}:{self.vbox.target_uuid}" + manifest = { + "schema": 1, + "name": guard.TARGET_NAME, + "owner_uid": os.getuid(), + "uuid": self.vbox.target_uuid, + "host_marker": marker, + } + manifest_path = self.root / guard.IDENTITY_FILE + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + os.chmod(manifest_path, 0o600) + + def tearDown(self): + self.temp.cleanup() + + def inspect(self, runner=None): + return guard.inspect_target(lab_root=self.root, runner=runner or self.vbox) + + def test_normal_selected_identity_is_allowed(self): + target_uuid, info = self.inspect() + self.assertEqual(target_uuid, self.vbox.target_uuid) + self.assertEqual(info["nic1"], "nat") + self.assertEqual(self.vbox.calls, [ + ("VBoxManage", "list", "vms"), + ("VBoxManage", "showvminfo", self.vbox.target_uuid, "--machinereadable"), + ]) + + def test_missing_manifest_fails_closed_before_inventory_or_start(self): + (self.root / guard.IDENTITY_FILE).unlink() + with self.assertRaisesRegex(guard.GuardError, "manifest is missing"): + guard.start_target(lab_root=self.root, runner=self.vbox) + self.assertEqual(self.vbox.calls, []) + + def test_duplicate_or_mismatched_name_uuid_fails_before_start(self): + for fake in (FakeVBox(self.root, duplicate=True), + FakeVBox(self.root, listed_uuid=str(uuid.uuid4()))): + with self.subTest(duplicate=fake.duplicate, listed_uuid=fake.listed_uuid): + with self.assertRaisesRegex(guard.GuardError, "duplicate|missing, duplicated"): + guard.start_target(lab_root=self.root, runner=fake) + self.assertEqual([call[1] for call in fake.calls], ["list"]) + + def test_malformed_or_ambiguous_vm_inventory_fails_before_start(self): + cases = ( + (FakeVBox(self.root, extra_list_rows=("not a VM row",)), "malformed"), + (FakeVBox(self.root, extra_list_rows=(f'"other" {{{uuid.uuid4()}}}', + f'"other" {{{uuid.uuid4()}}}')), "duplicate"), + (FakeVBox(self.root, extra_list_rows=(f'"other" {{{self.vbox.target_uuid}}}',)), "duplicate"), + ) + for fake, expected in cases: + with self.subTest(rows=fake.extra_list_rows): + with self.assertRaisesRegex(guard.GuardError, expected): + guard.start_target(lab_root=self.root, runner=fake) + self.assertEqual([call[1] for call in fake.calls], ["list"]) + + def test_malformed_or_duplicate_machine_readable_values_refuse_before_start(self): + for bad_line, expected in (("not-a-key-value", "malformed"), + ('name="duplicate"', "duplicate")): + class MalformedMachine(FakeVBox): + def __call__(inner_self, argv): + result = super(MalformedMachine, inner_self).__call__(argv) + if argv[1] == "showvminfo": + result.stdout += bad_line + "\n" + return result + + fake = MalformedMachine(self.root) + with self.subTest(line=bad_line): + with self.assertRaisesRegex(guard.GuardError, expected): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_quoted_storage_keys_and_port_metadata_match_observed_vbox_output(self): + fake = FakeVBox(self.root) + _, info = self.inspect(fake) + self.assertEqual(info['SATA-0-0'], str(self.vm_dir / f"{guard.TARGET_NAME}.vdi")) + self.assertEqual(info['SATA-2-0'], "none") + self.assertEqual( + {key for key in info if key.startswith("SATA-") and key not in { + "SATA-0-0", "SATA-1-0", "SATA-2-0" + }}, + { + "SATA-ImageUUID-0-0", + "SATA-hot-pluggable-0-0", + "SATA-nonrotational-0-0", + "SATA-discard-0-0", + "SATA-IsEjected-1-0", + "SATA-tempeject-1-0", + }, + ) + self.assertEqual(info["SATA-ImageUUID-0-0"], "{synthetic-disk-uuid}") + + def test_unknown_attachment_shaped_key_and_extra_media_are_rejected(self): + cases = ( + ({"SATA-unknown-property-1-0": "off"}, "unrecognized attached storage"), + ({"SATA-3-0": str(self.vm_dir / f"{guard.TARGET_NAME}.vdi")}, + "expected exactly one target VDI"), + ) + for mutations, expected in cases: + with self.subTest(mutations=mutations): + fake = FakeVBox(self.root, mutate=mutations) + with self.assertRaisesRegex(guard.GuardError, expected): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_extra_or_undeclared_hyphenated_controller_storage_fails_closed(self): + cases = ( + ({"storagecontrollername1": "IDE-Extra", "IDE-Extra-0-0": "/tmp/other.iso"}, + "storage controller inventory"), + ({"IDE-Extra-0-0": "/tmp/other.iso"}, "unrecognized attached storage"), + ) + for mutations, expected in cases: + with self.subTest(mutations=mutations): + fake = FakeVBox(self.root, mutate=mutations) + with self.assertRaisesRegex(guard.GuardError, expected): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_missing_storage_controller_inventory_fails_closed(self): + class MissingController(FakeVBox): + def __call__(inner_self, argv): + result = super(MissingController, inner_self).__call__(argv) + if argv[1] == "showvminfo": + result.stdout = "\n".join( + line for line in result.stdout.splitlines() + if not line.startswith("storagecontrollername") + ) + "\n" + return result + + fake = MissingController(self.root) + with self.assertRaisesRegex(guard.GuardError, "storage controller inventory"): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_target_folder_symlink_outside_lab_fails_configuration_guard(self): + outside_folder = Path(self.temp.name) / "outside-target" + self.vm_dir.rename(outside_folder) + self.vm_dir.symlink_to(outside_folder, target_is_directory=True) + + fake = FakeVBox(self.root) + with self.assertRaisesRegex(guard.GuardError, "exact target folder must be a real directory"): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_quoted_and_unquoted_duplicate_keys_are_rejected(self): + class DuplicateQuotedKey(FakeVBox): + def __call__(inner_self, argv): + result = super(DuplicateQuotedKey, inner_self).__call__(argv) + if argv[1] == "showvminfo": + result.stdout += 'SATA-0-0="duplicate.vdi"\n' + return result + + fake = DuplicateQuotedKey(self.root) + with self.assertRaisesRegex(guard.GuardError, "duplicate.*key"): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_malformed_quoted_key_is_rejected(self): + class MalformedQuotedKey(FakeVBox): + def __call__(inner_self, argv): + result = super(MalformedQuotedKey, inner_self).__call__(argv) + if argv[1] == "showvminfo": + result.stdout += '"SATA-0-1=value\n' + return result + + fake = MalformedQuotedKey(self.root) + with self.assertRaisesRegex(guard.GuardError, "unrecognized.*key"): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_virtualbox_recording_section_marker_is_ignored(self): + class RecordingSection(FakeVBox): + def __call__(inner_self, argv): + result = super(RecordingSection, inner_self).__call__(argv) + if argv[1] == "showvminfo": + result.stdout = result.stdout.replace( + 'name="' + guard.TARGET_NAME + '"\n', + 'name="' + guard.TARGET_NAME + '"\n rec_screen0\n', + ) + return result + + fake = RecordingSection(self.root) + target_uuid, info = self.inspect(fake) + self.assertEqual(target_uuid, fake.target_uuid) + self.assertEqual(info["name"], guard.TARGET_NAME) + + class UnknownSection(FakeVBox): + def __call__(inner_self, argv): + result = super(UnknownSection, inner_self).__call__(argv) + if argv[1] == "showvminfo": + result.stdout = result.stdout.replace( + 'name="' + guard.TARGET_NAME + '"\n', + 'name="' + guard.TARGET_NAME + '"\n rec_unknown0\n', + ) + return result + + unknown = UnknownSection(self.root) + with self.assertRaisesRegex(guard.GuardError, "malformed VBoxManage"): + self.inspect(unknown) + + def test_mismatched_identity_path_network_and_marker_all_refuse_before_start(self): + cases = [ + ({"name": "wga-other"}, "showvminfo identity"), + ({"description": "wrong-marker"}, "host marker"), + ({"CfgFile": "/tmp/not-the-lab/other.vbox"}, "configuration"), + ({"nic1": "bridged"}, "only NIC 1 in NAT"), + ({"nic2": "hostonly"}, "only NIC 1 in NAT"), + ({"natpf1": "ssh,tcp,,2222,,22"}, "port forwarding"), + ({"Forwarding(0)": "ssh,tcp,,2222,,22"}, "port forwarding"), + ({"SharedFolderPathMachineMapping1": "/"}, "shared folders"), + ({"SATA-0-0": "/tmp/other.vdi"}, "attached storage path"), + ] + for mutations, expected in cases: + with self.subTest(mutations=mutations): + fake = FakeVBox(self.root, mutate=mutations) + with self.assertRaisesRegex(guard.GuardError, expected): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_incomplete_nic_inventory_refuses_ambiguous_state(self): + class MissingNic(FakeVBox): + def __call__(self, argv): + result = super().__call__(argv) + if argv[1] == "showvminfo": + result.stdout = "\n".join( + line for line in result.stdout.splitlines() if not line.startswith('nic8=') + ) + "\n" + return result + + fake = MissingNic(self.root) + with self.assertRaisesRegex(guard.GuardError, "inventory is incomplete"): + guard.start_target(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_valid_start_is_scoped_to_exact_uuid_after_checks(self): + result = guard.start_target(lab_root=self.root, runner=self.vbox) + self.assertEqual(result, self.vbox.target_uuid) + self.assertEqual(self.vbox.calls[-1], ( + "VBoxManage", "startvm", self.vbox.target_uuid, "--type=gui")) + + def test_guest_marker_is_read_only_and_must_match_before_authorization(self): + password = self.root / "guest-password" + password.write_text("synthetic\n", encoding="ascii") + os.chmod(password, 0o600) + self.assertEqual(guard.authorize_guest( + password_file=password, lab_root=self.root, runner=self.vbox), self.vbox.target_uuid) + self.assertIn("--passwordfile", self.vbox.calls[-1]) + self.assertIn("--wait-stdout", self.vbox.calls[-1]) + self.assertEqual(self.vbox.calls[-1][self.vbox.calls[-1].index("--timeout") + 1], "15000") + self.assertNotIn("synthetic", " ".join(self.vbox.calls[-1])) + + fake = FakeVBox(self.root, guest_marker="wrong") + with self.assertRaisesRegex(guard.GuardError, "guest-local marker"): + guard.authorize_guest(password_file=password, lab_root=self.root, runner=fake) + self.assertEqual(fake.calls[-1][1], "guestcontrol") + self.assertEqual(len(fake.calls), 3) + + def test_subprocess_capture_stops_at_limit(self): + for stream in ("stdout", "stderr"): + script = f"import sys; sys.{stream}.write('x' * {guard.MAX_CAPTURE + 1})" + with self.subTest(stream=stream), self.assertRaisesRegex(guard.GuardError, "output exceeded"): + guard.run_command((sys.executable, "-c", script)) + + def test_unsafe_lab_or_manifest_permissions_refuse(self): + os.chmod(self.root, 0o755) + with self.assertRaisesRegex(guard.GuardError, "mode 0700"): + self.inspect() + os.chmod(self.root, 0o700) + os.chmod(self.root / guard.IDENTITY_FILE, 0o644) + with self.assertRaisesRegex(guard.GuardError, "mode 0600"): + self.inspect() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vm/test_install_boot.py b/tests/vm/test_install_boot.py new file mode 100644 index 0000000..cdfaadf --- /dev/null +++ b/tests/vm/test_install_boot.py @@ -0,0 +1,209 @@ +"""Synthetic behavioral checks for the pinned installer-state guard.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import contextlib +import io +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +HERE = Path(__file__).parent +sys.path.insert(0, str(HERE)) +SPEC = importlib.util.spec_from_file_location("vm_install_boot", HERE / "install_boot.py") +assert SPEC and SPEC.loader +boot = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(boot) + + +class FakeVBox: + def __init__(self, root: Path, *, mutations: dict[str, str] | None = None, + extra_vms: tuple[str, ...] = (), start_result: int = 0): + self.root = root + self.mutations = mutations or {} + self.extra_vms = extra_vms + self.start_result = start_result + self.calls: list[tuple[str, ...]] = [] + + def __call__(self, argv): + args = tuple(argv) + self.calls.append(args) + if args[1:3] == ("list", "vms"): + rows = [f'"{boot.TARGET_NAME}" {{{boot.TARGET_UUID}}}', *self.extra_vms] + return subprocess.CompletedProcess(args, 0, "\n".join(rows) + "\n", "") + if args[1] == "showvminfo": + values = { + "name": boot.TARGET_NAME, + "UUID": boot.TARGET_UUID, + "description": f"{boot.TARGET_NAME}:{boot.TARGET_UUID}", + "CfgFile": str(self.root / boot.TARGET_NAME / f"{boot.TARGET_NAME}.vbox"), + "VMState": "poweroff", + "storagecontrollername0": "SATA", + "SATA-0-0": str(self.root / boot.TARGET_NAME / f"{boot.TARGET_NAME}.vdi"), + "SATA-1-0": str(self.root / boot.TARGET_NAME / f"Unattended-{boot.TARGET_UUID}-aux-iso.viso"), + **{f"SATA-{port}-0": "none" for port in range(2, 30)}, + **{f"nic{slot}": "nat" if slot == 1 else "none" for slot in range(1, 9)}, + "natpf1": "", + "SharedFolderNameMachineMapping1": "", + "SATA-ImageUUID-0-0": "{synthetic-disk-uuid}", + "SATA-IsEjected-1-0": "off", + } + values.update(self.mutations) + out = "\n".join( + f'{json.dumps(key) if "-" in key else key}={json.dumps(value)}' + for key, value in values.items() + ) + "\n" + return subprocess.CompletedProcess(args, 0, out, "") + if args[1] == "startvm": + return subprocess.CompletedProcess(args, self.start_result, "", "") + return subprocess.CompletedProcess(args, 0, "", "") + + +class InstallBootTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "lab" + self.root.mkdir(mode=0o700) + os.chmod(self.root, 0o700) + self.folder = self.root / boot.TARGET_NAME + self.folder.mkdir(mode=0o700) + os.chmod(self.folder, 0o700) + self.cfg = self.folder / f"{boot.TARGET_NAME}.vbox" + self.disk = self.folder / f"{boot.TARGET_NAME}.vdi" + self.viso = self.folder / f"Unattended-{boot.TARGET_UUID}-aux-iso.viso" + self.cfg.write_text("synthetic vbox", encoding="ascii") + self.disk.write_bytes(b"synthetic disk") + self.viso.write_bytes(b"synthetic viso; contents intentionally never read") + os.chmod(self.viso, 0o600) + manifest = { + "schema": 1, + "name": boot.TARGET_NAME, + "owner_uid": os.getuid(), + "uuid": boot.TARGET_UUID, + "host_marker": f"{boot.TARGET_NAME}:{boot.TARGET_UUID}", + } + (self.root / "target-identity.json").write_text(json.dumps(manifest), encoding="utf-8") + os.chmod(self.root / "target-identity.json", 0o600) + self.constants = mock.patch.multiple( + boot, LAB_ROOT=self.root, TARGET_FOLDER=self.folder, + EXPECTED_VDI=self.disk, EXPECTED_VISO=self.viso, + ) + self.constants.start() + self.addCleanup(self.constants.stop) + self.fake = FakeVBox(self.root) + + def tearDown(self): + self.temp.cleanup() + + def test_exact_installer_state_checks_read_only(self): + uid, info = boot.inspect_installer_state(lab_root=self.root, runner=self.fake) + self.assertEqual(uid, boot.TARGET_UUID) + self.assertEqual(info["SATA-1-0"], str(self.viso)) + self.assertEqual([call[1] for call in self.fake.calls], ["list", "showvminfo"]) + + def test_start_uses_only_pinned_headless_target_after_inspection(self): + uid = boot.start_installer(lab_root=self.root, runner=self.fake) + self.assertEqual(uid, boot.TARGET_UUID) + self.assertEqual(self.fake.calls[-1], ( + "VBoxManage", "startvm", boot.TARGET_UUID, "--type=headless", + )) + + def test_identity_media_power_network_and_forwarding_mismatches_refuse_before_start(self): + cases = ( + ({"description": "wrong marker"}, "host marker"), + ({"SATA-2-0": "/tmp/extra.iso"}, "SATA media"), + ({"SATA-1-0": "/tmp/elsewhere.viso"}, "SATA media"), + ({"VMState": "running"}, "powered off"), + ({"nic2": "nat"}, "NAT-only"), + ({"nic9": "nat"}, "extra devices"), + ({"natpf1": "guest,tcp,,2222,,22"}, "forwarding"), + ({"SharedFolderNameMachineMapping1": "hostshare"}, "shared folders"), + ({"IDE-0-0": "/tmp/extra.iso"}, "storage controller or media"), + ) + for mutations, message in cases: + with self.subTest(mutations=mutations): + fake = FakeVBox(self.root, mutations=mutations) + with self.assertRaisesRegex(boot.InstallBootError, message): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_extra_registered_vm_row_is_rejected_without_start(self): + fake = FakeVBox(self.root, extra_vms=( + '"wga-m9-debian137-20261025" {12345678-1234-1234-1234-123456789012}', + )) + with self.assertRaisesRegex(boot.InstallBootError, "missing, duplicated, or mismatched"): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertEqual([call[1] for call in fake.calls], ["list"]) + + def test_target_folder_and_attached_viso_symlinks_refuse(self): + outside = Path(self.temp.name) / "outside" + self.folder.rename(outside) + self.folder.symlink_to(outside, target_is_directory=True) + fake = FakeVBox(self.root) + with self.assertRaisesRegex(boot.InstallBootError, "target folder"): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_viso_mode_and_missing_media_are_checked_without_reading_contents(self): + os.chmod(self.viso, 0o644) + fake = FakeVBox(self.root) + with self.assertRaisesRegex(boot.InstallBootError, "mode 0600"): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_target_vdi_hard_link_refuses_before_start(self): + other_vm_disk = Path(self.temp.name) / "other-vm.vdi" + os.link(self.disk, other_vm_disk) + fake = FakeVBox(self.root) + with self.assertRaisesRegex(boot.InstallBootError, "one hard link"): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_generated_private_file_symlink_is_rejected(self): + secret = self.folder / "preseed.cfg" + secret.symlink_to(self.viso) + fake = FakeVBox(self.root) + with self.assertRaisesRegex(boot.InstallBootError, "symlink"): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertNotIn("startvm", [call[1] for call in fake.calls]) + + def test_failed_start_reports_may_have_started_and_never_retries(self): + fake = FakeVBox(self.root, start_result=1) + with self.assertRaisesRegex(boot.InstallBootError, "may have started"): + boot.start_installer(lab_root=self.root, runner=fake) + self.assertEqual([call[1] for call in fake.calls].count("startvm"), 1) + + def test_start_timeout_is_bounded_and_reported_as_ambiguous(self): + fake = FakeVBox(self.root) + start_attempts = [] + + def timed_out(argv): + if tuple(argv)[1] == "startvm": + start_attempts.append(tuple(argv)) + raise subprocess.TimeoutExpired(argv, 15) + return fake(argv) + + with self.assertRaisesRegex(boot.InstallBootError, "may have started"): + boot.start_installer(lab_root=self.root, runner=timed_out) + self.assertEqual(start_attempts, [( + "VBoxManage", "startvm", boot.TARGET_UUID, "--type=headless", + )]) + + def test_cli_has_no_target_or_path_override(self): + with mock.patch.object(boot, "inspect_installer_state") as inspect: + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as raised: + boot.main(["check", "--lab-root", "/tmp/other"]) + self.assertEqual(raised.exception.code, 2) + inspect.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vm/test_install_prepare.py b/tests/vm/test_install_prepare.py new file mode 100644 index 0000000..018b67c --- /dev/null +++ b/tests/vm/test_install_prepare.py @@ -0,0 +1,175 @@ +"""Behavioral tests for the credential-redacting M9 unattended preparation.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +HERE = Path(__file__).parent +sys.path.insert(0, str(HERE)) +SPEC = importlib.util.spec_from_file_location("vm_install_prepare", HERE / "install_prepare.py") +assert SPEC and SPEC.loader +prepare = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(prepare) + + +class PrepareTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.lab = self.root / "lab" + self.vm_dir = self.lab / prepare.guard.TARGET_NAME + self.vm_dir.mkdir(parents=True) + os.chmod(self.lab, 0o700) + self.disk = self.vm_dir / f"{prepare.guard.TARGET_NAME}.vdi" + self.disk.write_bytes(b"synthetic VDI") + self.iso = self.lab / prepare.guard.EXPECTED_ISO + self.iso.write_bytes(b"synthetic installer") + self.additions = self.root / "VBoxGuestAdditions.iso" + self.additions.write_bytes(b"synthetic additions") + self.password_dir = self.root / "private" + self.password_dir.mkdir(mode=0o700) + os.chmod(self.password_dir, 0o700) + self.user_password = self.password_dir / "wga-password" + self.root_password = self.password_dir / "root-password" + self.user_password.write_text("synthetic-user-secret", encoding="ascii") + self.root_password.write_text("synthetic-root-secret", encoding="ascii") + os.chmod(self.user_password, 0o600) + os.chmod(self.root_password, 0o600) + self.iso_digest = hashlib.sha256(self.iso.read_bytes()).hexdigest() + self.additions_digest = hashlib.sha256(self.additions.read_bytes()).hexdigest() + self.info = { + "VMState": "poweroff", + "SATA-0-0": str(self.disk), + "SATA-1-0": str(self.iso), + } + self.info.update({f"SATA-{port}-0": "none" for port in range(2, 30)}) + self.calls: list[tuple[str, ...]] = [] + + self.constants = mock.patch.multiple( + prepare, LAB_ROOT=self.lab, ISO_PATH=self.iso, ISO_SHA256=self.iso_digest, + ADDITIONS_ISO=self.additions, ADDITIONS_SHA256=self.additions_digest, + PASSWORD_DIR=self.password_dir, USER_PASSWORD_FILE=self.user_password, + ROOT_PASSWORD_FILE=self.root_password, + ) + self.constants.start() + self.addCleanup(self.constants.stop) + self.guard_digest = mock.patch.object(prepare.guard, "EXPECTED_ISO_SHA256", self.iso_digest) + self.guard_digest.start() + self.addCleanup(self.guard_digest.stop) + self.guard_inspect = mock.patch.object( + prepare.guard, "inspect_target", + return_value=(prepare.TARGET_UUID, self.info), + ) + self.guard_inspect.start() + self.addCleanup(self.guard_inspect.stop) + + def tearDown(self): + self.temp.cleanup() + + def runner(self, argv): + args = tuple(argv) + self.calls.append(args) + return subprocess.CompletedProcess(args, 0, "synthetic output", "") + + def test_runs_dry_run_then_exact_target_prepare_without_start(self): + selected = prepare.prepare_target(runner=self.runner) + self.assertEqual(selected, prepare.TARGET_UUID) + self.assertEqual(len(self.calls), 2) + dry, actual = self.calls + self.assertEqual(dry[:4], ("VBoxManage", "unattended", "install", prepare.TARGET_UUID)) + self.assertIn("--dry-run", dry) + self.assertNotIn("--dry-run", actual) + for args in (dry, actual): + self.assertIn(f"--iso={self.iso}", args) + self.assertIn("--user=wga", args) + self.assertIn("--hostname=wga-m9-debian137.invalid", args) + self.assertIn("--locale=en_US", args) + self.assertIn("--time-zone=UTC", args) + self.assertIn("--country=US", args) + self.assertIn("--package-selection-adjustment=minimal", args) + self.assertIn("--install-additions", args) + self.assertIn(f"--additions-iso={self.additions}", args) + self.assertIn("--start-vm=none", args) + self.assertIn(f"--user-password-file={self.user_password}", args) + self.assertIn(f"--admin-password-file={self.root_password}", args) + self.assertNotIn(f"--root-password-file={self.root_password}", args) + self.assertFalse(any("startvm" in value for value in args)) + self.assertNotIn("synthetic-user-secret", args) + self.assertNotIn("synthetic-root-secret", args) + + def test_bad_private_credentials_refuse_before_guard_or_vbox(self): + os.chmod(self.user_password, 0o644) + with self.assertRaisesRegex(prepare.PrepareError, "mode 0600"): + prepare.prepare_target(runner=self.runner) + self.assertEqual(self.calls, []) + + def test_guard_rejects_extra_nonempty_sata_media_before_vbox(self): + self.info["SATA-2-0"] = str(self.additions) + with self.assertRaisesRegex(prepare.PrepareError, "target media"): + prepare.prepare_target(runner=self.runner) + self.assertEqual(self.calls, []) + + def test_guard_accepts_emptydrive_placeholders_for_extra_sata_slots(self): + self.info["SATA-2-0"] = "emptydrive" + self.info["SATA-3-0"] = "none" + prepare.prepare_target(runner=self.runner) + self.assertEqual(len(self.calls), 2) + + def test_guard_power_media_or_digest_refusal_makes_no_mutating_calls(self): + failures = ( + ("running", {"VMState": "running"}, "powered off"), + ("media", {"SATA-1-0": str(self.additions)}, "target media"), + ) + for name, update, expected in failures: + with self.subTest(name=name): + self.info.update(update) + with self.assertRaisesRegex(prepare.PrepareError, expected): + prepare.prepare_target(runner=self.runner) + self.assertEqual(self.calls, []) + self.info.update({"VMState": "poweroff", "SATA-1-0": str(self.iso)}) + + with mock.patch.object(prepare, "ADDITIONS_SHA256", "0" * 64): + with self.assertRaisesRegex(prepare.PrepareError, "Guest Additions ISO SHA-256"): + prepare.prepare_target(runner=self.runner) + self.assertEqual(self.calls, []) + + def test_dry_run_failure_redacts_output_and_does_not_prepare(self): + secret = "SENSITIVE-PLAINTEXT-DRY-RUN" + + def failing(argv): + self.calls.append(tuple(argv)) + return subprocess.CompletedProcess(argv, 1, secret, secret) + + with self.assertRaises(prepare.PrepareError) as raised: + prepare.prepare_target(runner=failing) + self.assertNotIn(secret, str(raised.exception)) + self.assertEqual(len(self.calls), 1) + self.assertIn("unattended dry run", str(raised.exception)) + + def test_actual_failure_reports_partial_target_and_suppresses_output(self): + secret = "SENSITIVE-PLAINTEXT-ACTUAL" + + def failing(argv): + self.calls.append(tuple(argv)) + code = 0 if "--dry-run" in argv else 9 + return subprocess.CompletedProcess(argv, code, secret, secret) + + with self.assertRaises(prepare.PrepareError) as raised: + prepare.prepare_target(runner=failing) + self.assertNotIn(secret, str(raised.exception)) + self.assertIn("partially completed", str(raised.exception)) + self.assertIn(prepare.TARGET_UUID, str(raised.exception)) + self.assertEqual(len(self.calls), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vm/test_resume.py b/tests/vm/test_resume.py new file mode 100644 index 0000000..44d29bb --- /dev/null +++ b/tests/vm/test_resume.py @@ -0,0 +1,211 @@ +"""Behavioral tests for the UUID-pinned M9 resume-only procedure.""" + +from __future__ import annotations + +import importlib.util +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +HERE = Path(__file__).parent +sys.path.insert(0, str(HERE)) +SPEC = importlib.util.spec_from_file_location("vm_resume", HERE / "resume.py") +assert SPEC and SPEC.loader +resume = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resume) + + +class FakeVBox: + def __init__(self, root: Path, iso: Path, *, mutate: dict[str, str] | None = None): + self.root = root + self.iso = iso + self.target_uuid = resume.TARGET_UUID + self.description = "" + self.machine_mutations = dict(mutate or {}) + self.calls: list[tuple[str, ...]] = [] + self.mutations: list[tuple[str, ...]] = [] + self.inventory = f'"{resume.VM_NAME}" {{{self.target_uuid}}}\n' + self.fail_command: str | None = None + + def __call__(self, argv): + args = tuple(argv) + self.calls.append(args) + command = args[1] + if command not in {"list", "showvminfo"}: + self.mutations.append(args) + if command == self.fail_command: + return subprocess.CompletedProcess(args, 1, "", "synthetic failure detail") + if args[1:3] == ("list", "vms"): + return subprocess.CompletedProcess(args, 0, self.inventory, "") + if command == "modifyvm" and "--description" in args: + self.description = args[args.index("--description") + 1] + if command == "modifyvm" and "--memory" in args: + self.machine_mutations.update({ + "memory": "1024", "cpus": "1", "nic1": "nat", + **{f"nic{slot}": "none" for slot in range(2, 9)}, + }) + if command == "createmedium": + Path(args[args.index("--filename") + 1]).write_bytes(b"synthetic VDI") + if command == "storagectl": + self.machine_mutations["storagecontrollername0"] = args[args.index("--name") + 1] + if command == "storageattach": + port = args[args.index("--port") + 1] + medium = args[args.index("--medium") + 1] + self.machine_mutations[f"SATA-{port}-0"] = medium + if command == "showvminfo": + info = { + "name": resume.VM_NAME, + "UUID": self.target_uuid, + "description": self.description, + "CfgFile": str(self.root / resume.VM_NAME / f"{resume.VM_NAME}.vbox"), + "VMState": "poweroff", + "memory": "1024", + "cpus": "1", + "nic1": "nat", + **{f"nic{slot}": "none" for slot in range(2, 9)}, + **self.machine_mutations, + } + output = "\n".join(f"{key}={json.dumps(value)}" for key, value in info.items()) + "\n" + return subprocess.CompletedProcess(args, 0, output, "") + return subprocess.CompletedProcess(args, 0, "", "") + + +class ResumeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "lab" + self.root.mkdir(mode=0o700) + os.chmod(self.root, 0o700) + self.vm_dir = self.root / resume.VM_NAME + self.vm_dir.mkdir() + (self.vm_dir / f"{resume.VM_NAME}.vbox").write_text("synthetic", encoding="ascii") + self.iso = self.root / "debian-13.7.0-amd64-netinst.iso" + self.iso.write_bytes(b"synthetic ISO") + self.digest = hashlib.sha256(self.iso.read_bytes()).hexdigest() + self.vbox = FakeVBox(self.root, self.iso) + self.constants = mock.patch.multiple( + resume, + LAB_ROOT=self.root, + ISO_PATH=self.iso, + PINNED_ISO_SHA256=self.digest, + ) + self.constants.start() + self.guard_digest = mock.patch.object(resume.guard, "EXPECTED_ISO_SHA256", self.digest) + self.guard_digest.start() + self.addCleanup(self.constants.stop) + self.addCleanup(self.guard_digest.stop) + + def tearDown(self): + self.temp.cleanup() + + def run_resume(self, runner=None): + return resume.resume_target( + lab_root=self.root, iso_path=self.iso, runner=runner or self.vbox, + ) + + def test_resume_configures_only_pinned_partial_vm_and_finishes_guard(self): + selected = self.run_resume() + self.assertEqual(selected, resume.TARGET_UUID) + self.assertEqual(self.vbox.calls[0][1:3], ("list", "vms")) + self.assertEqual(self.vbox.calls[1][1:4], ( + "showvminfo", resume.TARGET_UUID, "--machinereadable")) + modify = next(call for call in self.vbox.mutations + if call[1] == "modifyvm" and "--memory" in call) + self.assertEqual(modify[modify.index("--nic1") + 1], "nat") + for slot in range(2, 9): + self.assertEqual(modify[modify.index(f"--nic{slot}") + 1], "none") + disk_call = next(call for call in self.vbox.mutations if call[1] == "createmedium") + self.assertEqual(disk_call[disk_call.index("--size") + 1], "8192") + self.assertTrue((self.vm_dir / f"{resume.VM_NAME}.vdi").is_file()) + manifest_path = self.root / resume.guard.IDENTITY_FILE + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(manifest["uuid"], resume.TARGET_UUID) + self.assertEqual(manifest["host_marker"], f"{resume.VM_NAME}:{resume.TARGET_UUID}") + self.assertEqual(manifest_path.stat().st_mode & 0o777, 0o600) + self.assertFalse(any(call[1] in { + "createvm", "startvm", "unregistervm", "deletevm", "closevm", + } for call in self.vbox.calls)) + self.assertEqual(self.vbox.calls[-2][1:], ("list", "vms")) + self.assertEqual(self.vbox.calls[-1][1], "showvminfo") + + def test_preflight_failures_make_no_mutating_calls(self): + cases = ( + ("digest", "ISO SHA-256"), + ("inventory", "inventory does not match"), + ("manifest", "manifest already exists"), + ("disk", "VDI already exists"), + ("running", "powered off"), + ("network", "NIC state"), + ("primary_nic", "NIC state"), + ("forwarding", "NAT forwarding"), + ("forwarding_record", "NAT forwarding"), + ("shared", "shared folders"), + ("storage", "attached storage"), + ("controller", "storage controller"), + ("description", "host marker"), + ("memory", "default memory or CPU"), + ) + for case, message in cases: + with self.subTest(case=case): + for stale in ( + self.root / resume.guard.IDENTITY_FILE, + self.vm_dir / f"{resume.VM_NAME}.vdi", + ): + if stale.exists(): + stale.unlink() + fake = FakeVBox(self.root, self.iso) + if case == "inventory": + fake.inventory = '"other" {00000000-0000-0000-0000-000000000001}\n' + elif case == "manifest": + (self.root / resume.guard.IDENTITY_FILE).write_text("occupied", encoding="ascii") + elif case == "disk": + (self.vm_dir / f"{resume.VM_NAME}.vdi").write_bytes(b"occupied") + elif case == "running": + fake.machine_mutations["VMState"] = "running" + elif case == "network": + fake.machine_mutations["nic2"] = "hostonly" + elif case == "primary_nic": + fake.machine_mutations["nic1"] = "none" + elif case == "forwarding": + fake.machine_mutations["natpf1"] = "ssh,tcp,,2222,,22" + elif case == "forwarding_record": + fake.machine_mutations["Forwarding(0)"] = "ssh,tcp,,2222,,22" + elif case == "shared": + fake.machine_mutations["SharedFolderPathMachineMapping1"] = "/" + elif case == "storage": + fake.machine_mutations["SATA-0-0"] = str(self.root / "foreign.vdi") + elif case == "controller": + fake.machine_mutations["storagecontrollername1"] = "SATA" + elif case == "description": + fake.description = "unreviewed marker" + elif case == "memory": + fake.machine_mutations["memory"] = "2048" + with self.subTest(case=case), self.assertRaisesRegex(resume.ResumeError, message): + if case == "digest": + with (mock.patch.object(resume, "PINNED_ISO_SHA256", "0" * 64), + mock.patch.object(resume.guard, "EXPECTED_ISO_SHA256", "0" * 64)): + self.run_resume(fake) + else: + self.run_resume(fake) + self.assertEqual(fake.mutations, []) + + def test_mutation_failure_reports_exact_partial_uuid_and_does_not_clean_up(self): + fake = FakeVBox(self.root, self.iso) + fake.fail_command = "storagectl" + with self.assertRaisesRegex(resume.ResumeError, resume.TARGET_UUID): + self.run_resume(fake) + self.assertFalse((self.root / resume.guard.IDENTITY_FILE).exists()) + self.assertFalse(any(call[1] in {"unregistervm", "deletevm", "closevm"} + for call in fake.calls)) + self.assertNotIn("synthetic failure detail", str(fake.calls)) + + +if __name__ == "__main__": + unittest.main()