Compare commits

...

27 Commits

Author SHA1 Message Date
sasa c4299118f7 Fix endpoint value in README.md 2026-10-31 00:23:52 +02:00
sasa e648bc4dc6 Update README.md 2026-10-31 00:20:13 +02:00
sasa 4c86472740 Refactor acceptance and architecture handoff 2026-10-30 23:30:16 +02:00
sasa 7123197ec4 Add application read models and thin TUI command adapter 2026-10-30 23:02:35 +02:00
sasa 9a143e975e Implement explicit restart verification scenario 2026-10-30 22:27:28 +02:00
sasa 7cef400b9a Implement session-owned first delivery and password reissue scenarios 2026-10-30 21:25:49 +02:00
sasa ed602f0596 Implement readable saved-state Commit and restore scenarios 2026-10-29 23:36:28 +02:00
sasa c1d4f935c8 Implement transactional draft editing and client creation vertical slice 2026-10-29 23:15:55 +02:00
sasa 2766c6254a Add domain ownership of reservations and apply knowledge 2026-10-29 23:03:13 +02:00
sasa 8413b382b1 Add explicit vocabulary and named operation contexts 2026-10-29 22:41:38 +02:00
sasa ede45d224a Add refactoring contract, terminology and behavior map 2026-10-29 22:26:25 +02:00
sasa 23d6180f24 Improve application help 2026-10-29 00:01:35 +02:00
sasa 4960c65f65 Implement adaptive client list 2026-10-28 23:12:22 +02:00
sasa d49721ff8e Improve bottom panel TUI design 2026-10-27 23:04:52 +02:00
sasa 4b53aad20c Fix systemd restart support 2026-10-27 22:44:06 +02:00
sasa fe7e3d61c2 Fix systemd unloaded unit 2026-10-27 22:21:44 +02:00
sasa 86c28a35b5 Fix systemd service restart 2026-10-27 21:55:59 +02:00
sasa 4bb975f122 Fix QR-code generation 2026-10-27 21:21:14 +02:00
sasa af2d061880 Fix timestamp formats conflict 2026-10-27 20:42:12 +02:00
sasa 6d8d6d572d Add aligned users table 2026-10-26 23:41:43 +02:00
sasa ecc5c83d78 Fix subnets validation 2026-10-26 23:25:13 +02:00
sasa e141c03c2c Add visible styled keyboard quick reference in bottom panel 2026-10-26 22:59:11 +02:00
sasa 7d0f006601 Add integration tests based on VM 2026-10-26 22:34:00 +02:00
sasa f4a6540df9 Add integration tests based on testcontainers 2026-10-25 19:47:25 +02:00
sasa 2ccd67d64c Complete terminal management workflow 2026-10-21 23:25:31 +03:00
sasa f718f739b4 Implement client configuration delivery and reissue lifecycle 2026-10-21 21:52:24 +03:00
sasa 6ec593c783 Add runtime observation and systemd restart adapter 2026-10-20 23:01:43 +03:00
104 changed files with 42259 additions and 661 deletions
+3
View File
@@ -4,6 +4,9 @@
debug/
target/
# Python bytecode from opt-in VM test helpers.
__pycache__/
# These are backup files generated by rustfmt
**/*.rs.bk
+3 -3
View File
@@ -4,10 +4,10 @@
These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation.
The project contains a Rust package with startup, settings validation, a pure client domain model, a byte-preserving WireGuard configuration editor, encrypted vault and read-only import APIs, saved-file persistence with recovery, backups, Commit, and previewed restore. The management interface and VPN runtime operations are not implemented yet. Do not report planned modules, tests, or commands as already working.
The project contains a Rust package with startup, settings validation, a pure client domain model, a byte-preserving WireGuard configuration editor, encrypted vault and read-only import APIs, saved-file persistence with recovery, backups, Commit, previewed restore, a runtime observation/restart adapter, and a terminal management interface under Milestone 7 review. Real WireGuard and systemd integration remain for Milestones 8–9. Do not report planned modules, tests, or commands as already working.
- Read [SPEC.md](docs/SPEC.md) and [CONTEXT.md](CONTEXT.md) before implementing behavior. The approved specification is the product contract; do not change it to make tests pass.
- For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence.
- For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md) and [execution record rules](docs/EXECUTION_RECORDS.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence.
- Read the relevant ADRs: [explicit restart](docs/adr/0001-explicit-restart.md), [client secrets](docs/adr/0002-client-secret-storage.md), [server configuration authority](docs/adr/0003-server-config-authority.md).
- Read task-specific supporting documents below before editing that area. Their requirements remain applicable; moving them out of this file does not make them optional.
- Preserve unrelated user changes. Resolve specific requirement conflicts before implementing affected behavior; continue independent work. Do not reopen settled decisions or seek approval for routine implementation choices.
@@ -47,7 +47,7 @@ Use the pinned Rust toolchain and environment checks in [ENVIRONMENT.md](docs/EN
Coordinator: **GPT-6-Sol / medium**. Implementation, independent review, repairs, and optional exploration: **GPT-6-Luna / high**, using the configured `implementer`, `reviewer`, `repairer`, and `explorer` roles. Configure actual models through the harness; do not impersonate or silently substitute them.
The coordinator owns task boundaries, PLANS.md updates, and acceptance. Default to one writer; parallel writes require disjoint ownership. Use a separate reviewer from the implementer, repair actionable findings, and verify integrated results before advancing. Do not create Git commits, publish, or deploy merely because a milestone is accepted.
The coordinator owns task boundaries, PLANS.md updates, and acceptance. Keep PLANS.md as a current snapshot; put diagnostic attempts in `docs/diagnostics/`, detailed evidence in `docs/evidence/`, and superseded records in `docs/history/`, following [EXECUTION_RECORDS.md](docs/EXECUTION_RECORDS.md). Update statuses in place and keep the register, milestone outcome, and resume point consistent. Default to one writer; parallel writes require disjoint ownership. Use a separate reviewer from the implementer, repair actionable findings, and verify integrated results before advancing. Do not create Git commits, publish, or deploy merely because a milestone is accepted.
Build/test without root by default. Never read production private keys, modify production configuration, restart host services, or use the live VPN for testing. Alpine Testcontainers suites are opt-in and isolated; actual systemd/D-Bus acceptance requires a disposable VM. Unavailable prerequisites mean incomplete validation, not success.
+42
View File
@@ -7,6 +7,45 @@ Terminology for managing device access to a WireGuard VPN.
**Draft**:
Changes prepared by the administrator but not yet saved through Commit.
**Saved state**:
The installed settings, server configuration, internal metadata, and encrypted
vault after Commit or restoration. Saved state may differ from the running VPN.
**Applied state**:
Current knowledge that the running interface has been verified against the
saved peer configuration. A saved file, a successful historical restart, or a
handshake alone does not establish current applied state. After reopening, the
current state is unknown until checked again.
**Server peer record**:
A `[Peer]` section in the authoritative server configuration, including its
routes, comments, and any detected issues. A malformed record may have no usable
public key and must remain available for inspection.
**Manageable client**:
A server peer that can be represented by the supported client fields and
identified unambiguously by its public key for an allowed management action.
Some valid peer records, such as peers with subnet routes, remain verifiable
without being manageable.
**Runtime observation**:
A read-only measurement of the running interface and its peers, such as routes,
last handshake, or current traffic counters. Missing observations are unknown or
unavailable; they do not imply zero traffic or successful application.
**Reserved address**:
An address retained so that it cannot be assigned to another client while a
deleted peer may still exist on the running interface. It is released only
after a confirmed restart has verified the complete saved peer set and the
verification state has been durably saved.
**First delivery**:
The one-time, password-free delivery of a newly created client's connection
file after Commit in the same creation session. A failed file write can be
retried in that session; later delivery requires the vault password. Until the
saved configuration is verified as applied, delivery does not establish VPN
access.
**Commit**:
Saving prepared changes without applying them to the running VPN.
@@ -23,5 +62,8 @@ _Avoid_: User, account
**Client alias**:
An administrator-friendly name for a client. A client is identified by its public key rather than its alias.
An existing client's creation date remains unknown unless it is present in its
managed metadata; an unknown date is not filled in with an estimated value.
**Anomalous client record**:
A record with only a server configuration entry or only a client connection file, without its corresponding counterpart. Records are matched by public key and are not automatically repaired when discovered.
Generated
+2724 -36
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -16,14 +16,21 @@ path = "src/main.rs"
[dependencies]
age = { version = "=0.12.1", default-features = false }
base64 = "=0.22.1"
crossterm = { version = "=0.29.0", default-features = false, features = ["events"] }
getrandom = "=0.3.4"
ipnet = "=2.11.0"
rustix = { version = "=1.1.5", default-features = false, features = ["fs", "process", "std"] }
qrcode = { version = "=0.14.1", default-features = false }
ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm_0_29"] }
rustix = { version = "=1.1.5", default-features = false, features = ["event", "fs", "net", "process", "std"] }
serde = { version = "=1.0.229", features = ["derive"] }
thiserror = "=2.0.20"
tar = { version = "=0.4.44", default-features = false }
tempfile = "=3.23.0"
time = { version = "=0.3.44", default-features = false, features = ["formatting", "parsing", "std"] }
time = { version = "=0.3.47", default-features = false, features = ["formatting", "parsing", "std"] }
toml = "=1.1.8"
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
zeroize = "=1.9.0"
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "p2p"] }
[dev-dependencies]
testcontainers = { version = "=0.28.0", default-features = false, features = ["blocking"] }
+2793 -27
View File
File diff suppressed because it is too large Load Diff
+88 -73
View File
@@ -1,61 +1,85 @@
# wireguard-admin
This repository contains the Rust package, startup boundary, validated settings
loader, client domain model, a byte-preserving in-memory WireGuard configuration
editor, and library APIs for encrypted client vaults and read-only client import.
The library also provides saved-file persistence with recovery, private backups,
prepared Commit, and previewed full restore. The terminal management interface
and WireGuard/systemd runtime operations have not been implemented yet.
This repository contains a root-only native terminal interface for one existing WireGuard interface and configurable systemd service. It connects the validated settings loader, byte-preserving server configuration editor, encrypted client vault, recoverable saved-file persistence, backups, Commit, previewed restore, explicit restart, read-only runtime statistics, and client file/QR delivery. The interface keeps draft, saved, and applied states distinct. Commit saves files; restore replaces saved state; only an explicit, verified restart applies saved changes to the running interface.
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
the repository root, run:
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From the repository root, run:
```sh
cargo run -- --help
cargo run -- --version
```
`--help` and `--version` work without root. Starting with no arguments requires
root; until the management interface is implemented, startup exits with an
error and does not read server configuration or vault files.
`--help` and `--version` work without root, settings, or a terminal. Starting with no arguments requires root and a suitable interactive terminal. Settings are read from `wireguard-admin.toml` beside the executable. If terminal checks or saved-state recovery fail, startup exits before entering raw mode or performing setup mutations.
## Settings
Settings use TOML in the fixed file `wireguard-admin.toml` beside the
executable. Start from [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml).
The required fields are:
Copy this example to `wireguard-admin.toml` beside the executable. Adapt every address, range, path, interface name, and service name to the existing server; `192.0.2.1` is a documentation address, not a usable public endpoint. This configuration manages an existing WireGuard interface and systemd service; it does not create either one.
- `interface_name`: existing WireGuard interface name (1–15 ASCII characters).
- `server_config_path`: path to the existing server configuration.
- `client_source_directory`: directory containing existing client files.
- `server_endpoint`: hostname or IPv4 address followed by `:port`, or IPv6 in
brackets followed by `:port` (for example, `[2001:db8::2]:51820`).
- `ipv4_allocation_range` and `ipv6_allocation_range`: CIDR ranges of the
matching address family.
- `dns_addresses`: non-empty list of IPv4 and/or IPv6 literal addresses.
- `systemd_unit`: service unit name ending in `.service`.
- `restart_timeout_seconds`: positive integer.
```toml
# Copy this file to wireguard-admin.toml beside the wireguard-admin executable.
# Relative paths are resolved from that executable directory, never from the
# process working directory. This example uses documentation-only addresses.
`data_root` is optional. It defaults to `Backup` beside the executable;
current internal data and the encrypted vault are under `data`, and archives
are under `archives`. Relative configured paths, including an explicit
`data_root`, resolve from the executable directory. Absolute paths remain
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.
interface_name = "wg0"
server_config_path = "/etc/wireguard/wg0.conf"
client_source_directory = "clients"
server_endpoint = "37.26.136.232:51820"
ipv4_allocation_range = "10.9.0.0/24"
ipv6_allocation_range = "2001:db8:1::/64"
dns_addresses = ["10.9.0.1", "192.168.0.200"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
# Optional. Defaults to Backup beside the executable. The application stores
# current data in <data_root>/data and archives in <data_root>/archives.
# data_root = "Backup"
```
The parser requires every uncommented field above and rejects unknown fields.
The example values are **not defaults**. Only `data_root` has a default.
| Parameter | Required? | Purpose and validated constraints |
| ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interface_name` | Yes | Name of the existing WireGuard interface. Must be 1–15 ASCII letters, digits, `.`, `_`, or `-`; `.` and `..` are rejected. |
| `server_config_path` | Yes | Path to the existing, authoritative server configuration. Must be non-empty. The settings parser does not check that the file exists; startup validates the saved installation. |
| `client_source_directory` | Yes | Directory of original client configuration files used for initial vault import and explicit selected-file import. Must be non-empty. Scans consider immediate regular `.conf` files without following symlinks; originals are neither modified nor deleted. |
| `server_endpoint` | Yes | Address and port written to generated client configurations: a hostname or IPv4 address followed by `:port`, or an IPv6 address in brackets followed by `:port`. Port must be 1–65535. Hostnames are syntax-checked but not resolved during settings validation. |
| `ipv4_allocation_range` | Yes | IPv4 CIDR range from which new-client IPv4 addresses are allocated. Must parse as an IPv4 CIDR network. |
| `ipv6_allocation_range` | Yes | IPv6 CIDR range from which new-client IPv6 addresses are allocated. Must parse as an IPv6 CIDR network. |
| `dns_addresses` | Yes | Non-empty list of IPv4 and/or IPv6 literal DNS server addresses included in generated client configurations. This does not configure the host's DNS settings. Hostnames are rejected. |
| `systemd_unit` | Yes | Existing systemd service targeted by an explicitly requested restart. Must end in `.service`, be at most 255 ASCII bytes, and use only letters, digits, `.`, `_`, `-`, `@`, or `:`; paths and whitespace are rejected. |
| `restart_timeout_seconds` | Yes | Positive integer number of seconds for the shared restart deadline. After the mandatory backup, it covers systemd connection, subscription, unit resolution, request and job completion, then the runtime interface read and verification deadline check. It does not time the precautionary backup or durable verification-state write. |
| `data_root` | No | Non-empty root directory for application data. Defaults to `Backup` beside the executable. Current internal data and the encrypted vault are stored in `<data_root>/data`; archives are stored in `<data_root>/archives`. |
The settings file name and location are fixed: `wireguard-admin.toml` must sit beside the executable, regardless of the process working directory. Relative values for `server_config_path`, `client_source_directory`, and `data_root` resolve from that executable directory; absolute paths stay absolute. Settings validation checks syntax and values without checking referenced paths or the service for existence. Commit and Restore change saved state only. Restart is separate and explicit; only successful service completion followed by matching interface/client verification and durable bookkeeping marks the saved state applied. The separate [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml) is another example, with different illustrative values.
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 bordered **Keys** panel at the bottom of the screen shows shortcuts for the current screen and form. On narrow list and details screens it prioritizes navigation and Help, and indicates when additional actions are omitted; resize the terminal to see more. Forms, QR disclosure and progress show only their applicable controls. Press `?` from the list or details screen to open Help, which contains the complete shortcut reference. The client list supports arrow-key selection, Enter for details, and `q` to exit. Exit warns when a draft would be discarded; it never commits implicitly. Uppercase shortcuts are distinct from lowercase shortcuts (`R` previews restart, while `r` renames; `B` creates a backup, while `b` lists backups):
| Key | Action |
| --- | --- |
| `n` | Create a client with automatic or manual dual-stack addresses |
| `r` / `d` | Rename or delete the selected supported peer by full public key |
| `p` / `x` | Review or discard the draft |
| `c` | Commit saved files after the mandatory backup |
| `R` | Preview explicit restart; confirm only after reviewing the choice |
| `o` / `O` | First file delivery / password-per-action reissue |
| `b` / `B` | List and preview backups / create a manual backup |
| `s` / `i` | Initial vault setup / import one selected client file |
| `!` | Inspect document and peer anomalies |
The list header labels **Draft**, **Saved**, and **Applied** separately. Draft reports pending changes even when only vault or managed metadata changed. Saved counts peer sections in the saved server file, including peers this manager cannot edit, and separately counts managed clients. Applied reports verification of the saved configuration; a draft is excluded. “Not verified” does not claim that a restart is pending when that divergence is unknown. A saved peer row does not prove active access, and handshake or traffic counters are observations, not proof that the saved configuration was applied. Details distinguish document or peer problems from valid configuration shapes that are currently outside the management model.
In Help, Up/Down scroll by one displayed row and PageUp/PageDown move by the visible page. The Help panel shows the current row range and clamps navigation when the terminal is resized. Press `?` to return to the beginning or Esc to return to the client list without changing the selected peer or application state.
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to cancel. Backup restore previews identify the selected archive and require an explicit confirmation. Restore does not prompt for the vault password and does not restart WireGuard. Client configuration output is a disclosure: first delivery requires a successful Commit in the same session; QR appears only after file publication. Esc closes and clears a QR disclosure. Help in the interface contains the full current shortcut list.
## Library configuration API
The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8
configuration bytes supplied by the caller. It retains the source and renders
unmodified input byte-for-byte, including line endings and a missing final
newline. Peer inspection exposes public identities, parsed `AllowedIPs`, spans,
and redacted issues; arbitrary directive values are not included in the view.
The edit methods `add_client`, `delete_client`, `rename_client`, and
`insert_legacy_metadata` return new in-memory documents. They do not read or
write files, save changes, or alter the running VPN. See
[`docs/CONFIG_FORMAT.md`](docs/CONFIG_FORMAT.md) for the managed metadata block
format.
The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8 configuration bytes supplied by the caller. It retains the source and renders unmodified input byte-for-byte, including line endings and a missing final newline. Peer inspection exposes public identities, parsed `AllowedIPs`, spans, and redacted issues; arbitrary directive values are not included in the view. The edit methods `add_client`, `delete_client`, `rename_client`, and `insert_legacy_metadata` return new in-memory documents. They do not read or write files, save changes, or alter the running VPN. See [`docs/CONFIG_FORMAT.md`](docs/CONFIG_FORMAT.md) for the managed metadata block format.
Example:
@@ -67,44 +91,35 @@ let updated = document.rename_client(&public_key, "office laptop")?;
let bytes_to_review = updated.render(); // no persistence or VPN operation
```
Management startup still exits with an explicit “not implemented” error after
its root check. The application library APIs are usable by callers, but are not
yet connected to a management session or runtime VPN adapter.
Runtime and restart behavior has synthetic fixture and fake-adapter coverage; 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 retired systemd VM milestone did not establish real-system acceptance. Real IPv6 capability and traffic remain unverified.
## Saved state, Commit, and backups
wireguard_admin::app::Application opens saved files through
wireguard_admin::persistence::SavedStore, stages client and vault changes,
creates manual and mandatory operation backups, and previews/confirms a full
restore. Commit writes saved files only. Restore also changes saved files only;
neither operation reloads or restarts WireGuard. The server file is kept at its
trusted configured path, while internal state and the encrypted vault are kept
under Backup/data by default. Archives are stored under Backup/archives and
contain fixed logical members, not source client files or prior archives.
wireguard_admin::app::Application opens saved files through wireguard_admin::persistence::SavedStore, stages client and vault changes, creates manual and mandatory operation backups, and previews/confirms a full restore. Commit writes saved files only. Restore also changes saved files only; neither operation reloads or restarts WireGuard. The server file is kept at its trusted configured path, while internal state and the encrypted vault are kept under Backup/data by default. Archives are stored under Backup/archives and contain fixed logical members, not source client files or prior archives.
The persistence journal recovers interrupted file replacement on reopen and
returns a recovery error while earlier work remains unresolved. Restoring a
backup does not restore runtime state: the running interface is unknown until a
future explicit runtime verification. Pending deleted-client addresses remain
reserved through Commit, reopen, and restore. See
[docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and
[docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details.
The persistence journal recovers interrupted file replacement on reopen and returns a recovery error while earlier work remains unresolved. A confirmed restart marks the saved generation applied only after a successful service job, a matching complete runtime observation, and journaled verification-state write. Restoring a backup does not restore runtime state: the running interface is unknown until a later explicit restart and verification. Pending deleted-client addresses remain reserved through Commit, reopen, and restore. See [docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and [docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details, and [docs/RUNTIME.md](docs/RUNTIME.md) for observation and restart boundaries.
## Vault and existing-client import library
`wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned
vault bundle, caller-buffer client configuration parsing, and reconciliation
against a supplied server document. The source adapter can prepare an initial
encrypted bundle or explicitly import candidates into an existing prepared
bundle. These operations return in-memory data only: they do not install or save
a vault, edit source files or server configuration, Commit, or touch the VPN.
`wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned vault bundle, caller-buffer client configuration parsing, and reconciliation against a supplied server document. The source adapter can prepare an initial encrypted bundle or explicitly import candidates into an existing prepared bundle. These operations return in-memory data only: they do not install or save a vault, edit source files or server configuration, Commit, or touch the VPN.
Directory scans use the resolved `client_source_directory` only. A candidate is
an immediate regular file whose name ends in `.conf` (case-insensitive); the
adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A
scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total
input. Per-file failures are redacted and do not prevent other files from being
examined. Enumeration, read, or limit failures mark the scan incomplete. The
original files remain untouched. Adapter examples and behavior are covered with
synthetic temporary directories in `tests/vault_import.rs`.
Directory scans use the resolved `client_source_directory` only. A candidate is an immediate regular file whose name ends in `.conf` (case-insensitive); the adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total input. Per-file failures are redacted and do not prevent other files from being examined. Enumeration, read, or limit failures mark the scan incomplete. The original files remain untouched. Adapter examples and behavior are covered with synthetic temporary directories in `tests/vault_import.rs`.
## Client delivery and reissue
`Application::prepare_new_client` stages a dual-stack peer and encrypted key. After a successful `commit`, `deliver_first_issue_file` can publish its first configuration without a vault password during that same session; a failed file publication can be retried to a newly selected destination. The first file is mandatory. `deliver_first_issue_file_and_qr` optionally returns a QR matrix from the same authorized bytes after file publication. Both success results carry an activation warning unless this session has verified the exact installed saved generation and its matching applied peer snapshot; historical verification and unknown runtime state do not suppress it.
Later delivery uses `Application::reissue_client_file`, which takes the client public key, a password for that action, a prepared destination, and an optional QR choice. It freshly unlocks the installed saved vault each time, reads saved server/settings bytes rather than draft state, preserves validated imported parameters, and rejects blocked or stale records. It never reads original plaintext client files as a fallback. The result reports a secret-free failure stage and warns when the current saved peer set has not been verified as applied in this live session. Historical verification timestamps do not suppress that warning after reopen. File output uses mode `0600`, explicit overwrite confirmation, and separate QR failure reporting. These APIs and the TUI password prompt/terminal QR display have synthetic fixture coverage. No production configuration or key is included in the repository.
## Test commands and integration limits
Run the default synthetic workflow and UI checks as an unprivileged user:
```sh
cargo test --locked --test tui_workflow
cargo test --locked tui::
```
The first exercises setup, client staging, Commit, delivery, backup listing, and restore through the serialized worker with temporary files and a fake statistics reader. The second covers navigation/forms/rendering, delayed worker behavior, terminal lifecycle seams, and QR sizing. Neither command starts the management UI or uses host service/runtime mutation. See [`docs/TESTING.md`](docs/TESTING.md) for full gates and the isolated Alpine (M8) and disposable systemd VM (M9) boundaries. An actual PTY smoke run using synthetic application/runtime adapters is included in `tui_workflow`; it uses `script` and a fake unavailable-statistics reader, without root or host settings. It checks an 80×24 to 40×8 to 80×24 resize sequence and terminal cleanup after `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).
+1 -1
View File
@@ -43,7 +43,7 @@ with permission to create Git commits.
The root instructions define prepare, implement, review, repair, re-review, and
acceptance. PLANS.md is the durable record of milestone/task IDs, ownership,
progress, findings, validation, and blockers. The TOML files configure model
progress, findings, validation, and blockers. Follow [execution record rules](EXECUTION_RECORDS.md): update current state in place, keep detailed investigations in `docs/diagnostics/`, detailed evidence in `docs/evidence/`, and superseded history in `docs/history/`. Include these boundaries in worker dispatches; reassess repeated failed repairs before another expensive run. The TOML files configure model
selection and agent instructions; they do not implement a deterministic workflow
engine or automatically start execution.
+200
View File
@@ -0,0 +1,200 @@
# Client configuration rendering
`delivery` contains pure rendering functions. They accept the committed
byte-preserving `ConfigDocument`, validated saved settings, and either the new
client's private key or a freshly password-retrieved `RetrievedClientRecord`.
Each renderer derives the server public key inside its boundary from exactly one
valid `[Interface] PrivateKey` in that saved document. Missing or duplicate
interface sections, missing or duplicate private-key directives, malformed key
text, and structurally malformed private-key directives fail closed. Callers
cannot supply or retain an unrelated server identity. The derivation keeps the
source key borrowed and the parsed secret local to the call; neither is copied
into an error or retained object. The renderer does not read paths, inspect the
host, access the running interface, save a file, or authorize a QR request.
The renderer locates the client's peer by public key and requires one unique
peer with one valid public key field and supported authoritative address routes.
Addresses come from that saved peer's `AllowedIPs` host entries. Each assigned
address is rendered as a host prefix (`/32` for IPv4 and `/128` for IPv6); these
client `[Interface] Address` values remain distinct from server-side
`[Peer] AllowedIPs` routes. A new client requires one IPv4 and one IPv6 saved
address, uses `0.0.0.0/0, ::/0`, and gets endpoint and DNS from the saved
application settings. No MTU, keepalive, or preshared-key defaults are added.
For imported reissue, the record's client addresses and optional saved server
public key are checked against current authoritative saved values. A mismatch,
stale address, missing or ambiguous peer, unsupported peer shape, or malformed
retained field fails closed. The renderer never truncates routes. It preserves
validated retained DNS, MTU, endpoint, AllowedIPs, keepalive, and preshared key
when present; application DNS and endpoint settings supply values absent from
the imported record. Saved peer addresses and derived server identity always
remain authoritative. DNS and endpoint values are parsed and emitted as
canonical single-line text; unknown directives and hooks are never copied.
The precedence is therefore:
1. The saved interface private key determines the server public identity, and
saved server peer identity and host addresses are authoritative for both
rendering paths. Imported retained server identity, when present, must match
this derived identity.
2. For new clients, saved application endpoint and DNS settings fill the
template; full-tunnel routes are the fixed new-client template.
3. For imported clients, authenticated retained optional values take precedence
over current endpoint and DNS defaults when present; retained server identity
and addresses must still match saved state. Retained AllowedIPs are preserved
after validation.
`ClientConfigPayload` owns rendered plaintext in a zeroizing byte buffer. Its
`Debug` output reports only length and redaction; it has no generic serialization
or display implementation. `as_bytes()` is the explicit disclosure boundary
that later delivery code must keep operation-local. The payload is capped at
64 KiB. Temporary Base64 encodings and application-owned copies created after
`as_bytes()` cannot be erased by this owner; delivery code must minimize and
bound those copies. No payload, password, or key is returned in errors.
Fresh retrieval is required for imported records: `VaultBundle` checks the
authenticated reissue-blocked state before constructing a
`RetrievedClientRecord`. This renderer offers no password-free retrieval API
and does not create first-issue eligibility. Retained fields are readable
through reference accessors, while their authenticated values cannot be changed
by library callers after retrieval. The imported renderer requires imported
provenance and a present matching server public key; a key-only new-template
record has no such binding and cannot use the imported path. The application grants initial
output only after Commit in the same creation session and supplies the payload
only inside its file-publication closure.
Later reissue is exposed by `Application::reissue_client_file`. Every call
reads installed saved bytes, freshly decrypts the installed vault identity with
the supplied per-action password, checks the authenticated blocked status, and
renders against the saved server document and settings. Draft peers and
retained original client files are never consulted. The short-lived decrypted
record is dropped after the file and optional QR operation; no unlocked vault
or identity is cached in the application.
Authenticated key-only records created by this application are classified as
new-template records only when every retained connection field is absent or
empty. That validated provenance path renders the current saved peer addresses
with the saved endpoint/DNS template. An empty address list combined with any
retained field is treated as a corrupt partial record. Imported records must
carry valid addresses and a server identity; those values are checked against
the saved peer and derived server identity, and retained optional parameters
are validated before rendering. Missing or malformed required retained values
fail closed instead of falling through to the new-client template.
First-issue file and file-plus-QR success results, including definite
publication with uncertain post-publication bookkeeping, carry an activation
warning. The warning is suppressed only when the current application session
has verified the exact installed saved bytes and matching saved/applied peer
snapshot. Unknown runtime state and historical verification timestamps do not
suppress it.
Reissue failures expose a fixed stage (`SavedState`, `Authorization`,
`Rendering`, or `FilePublication`) and secret-free category. The mandatory file
is published before optional QR encoding; QR failure is returned separately
after file success. Reissue uses the same generation-bound activation warning.
Delivery performs no runtime read, service operation, restart, or applied-state
mutation.
## Plaintext file publication
Initial delivery asks the caller for an explicit destination path; aliases are
never used to construct a path. Destination preparation performs no writes.
It rejects non-absolute paths, parent traversal, symlinks in existing path
components, symlink or special-file targets, the settings/server/internal-state/
vault files, the data and archive directories, and every existing entry in the
configured original-client directory. An existing regular file can be selected,
but preparation records its filesystem identity and requires a separate
confirmation tied to that prepared destination. The configured source directory
is enumerated with the same entry bound used by import; enumeration failures or
an unsafe source directory stop output preparation.
Publication requires the chosen output parent to be owned by the effective
user or root. The output parent cannot be group- or world-writable; existing
ancestors cannot be group- or world-writable unless the sticky bit protects
entries (for example `/tmp`). These checks are repeated immediately before
opening the parent, and the opened directory is checked as well. This keeps
other users from substituting the temporary entry or destination inside the
output parent. A bounded payload is written to a same-directory temporary
file created with mode `0600` and a 128-bit operating-system-random name, then
all bytes are flushed and synced before publication. A new destination uses
no-replace publication, so a file created after preparation is not silently
overwritten. A confirmed replacement uses an atomic exchange: the displaced
entry is checked against the confirmed inode before it is removed. If a
different entry was substituted at publication, the adapter attempts a second
exchange and checks both resulting identities. It never unlinks by name during
rollback: a same-privilege concurrent substitution could otherwise cause an
unconfirmed entry to be destroyed. Even after an identity-verified rollback,
the staged configuration remains under its mode-`0600` temporary name because
unlink-by-name cannot be made conditional on inode identity. A completed
rollback is reported as `DestinationChanged` only after the parent directory
sync succeeds. If the second exchange, identity checks, or sync fail,
publication is reported as uncertain and all entries still reachable by their
names are retained.
Missing parent directories are created with mode `0700`; existing directories
are not chmodded. The destination parent is synced after publication. On a
pre-publication error, the temporary file is removed and first-issue eligibility
remains retryable. If removal of a pre-publication temporary file fails, the
operation reports that a private temporary configuration may remain and
requires manual cleanup. First-issue retry requires an explicit cleanup
acknowledgement; inspect the selected output parent for
`.wg-admin-client-*.tmp` files and remove the retained file after verifying its
identity. This result does not mean the selected destination was published.
If parent sync fails after publication, the output may
already exist: it is reported as uncertain, retained, and a retry requires a
fresh selection and explicit overwrite confirmation. The application never
removes a published file to pretend an uncertain save did not happen.
The Linux exchange operation is atomic for the two names but cannot condition
the exchange on previously observed inode identities. The parent-directory
ownership and permission checks plus the unpredictable temporary name prevent
other users from racing those names. A process with the same effective
privilege can still change names after identity checks; concurrent edits by the
administrator are outside this adapter's single-administrator model. Callers
must treat `PublicationUncertain` as requiring inspection before retrying. A
retained temporary file is private to the current user and may need explicit
cleanup after the output state is understood.
Successful file publication consumes the session's first-issue eligibility. If
the file is published but the subsequent saved-state read needed to record that
transition fails, the result explicitly reports the file as published with
bookkeeping uncertain and drops the cached first-issue key; a later issuance
requires password authorization.
The selected path and confirmation are operation-local and contain no
configuration bytes or key material. These checks are filesystem adapter tests
using synthetic payloads and temporary directories; they do not represent a
privileged production-path test.
The renderer is verified with synthetic keys and configurations, including
round-trip parsing through `vault::import::parse_client_config`. The file
adapter is verified with synthetic payloads and temporary paths. This is not
evidence of a phone import, live tunnel, production filesystem, or QR scan.
## Optional QR output
An authorized first-file operation may request a QR representation in the same
operation. The app first publishes the mandatory configuration file, then gives
the same borrowed `ClientConfigPayload` to the QR encoder. There is no QR-only
entry point and no reconstruction from cached key material. File publication
failure skips QR encoding and preserves the existing retry behavior. If QR
encoding fails after the file was published, the result reports the file as
delivered and the QR error separately; first-issue eligibility is consumed.
The QR module and encoder are crate-private. External callers can request QR
output only through the application file-and-QR operation; the returned matrix
and fixed-category error types remain public so callers can display the result.
The encoder uses `qrcode` 0.14.1 with default features disabled (no image, SVG,
or external utility). The application owns a row-major matrix of 0/1 modules with
a four-module quiet zone. Its side is at most 185 modules (version 40 plus the
quiet zone). Matrix access is explicit; its `Debug` output redacts module data,
and the owner zeroizes its buffer on drop. The qrcode encoder necessarily keeps
internal payload/codeword and matrix copies that this application cannot
zeroize; the app drops its local encoder result immediately after copying the
modules into the bounded owner. QR errors contain only a fixed category.
Tests establish that the encoder receives bytes equal to the just-published file,
that encoding succeeds for a synthetic payload, and that capacity failure does
not undo file delivery. They do not decode the matrix, establish phone scanning
compatibility, or test terminal rendering; those remain future UI/integration
checks.
+6 -4
View File
@@ -16,25 +16,27 @@ The user has approved the following choices. They may be introduced when their i
| QR encoding | `qrcode` | Additional terminal delivery of client configurations |
| Backup archives | `tar` | Archives with manifests; initially without compression |
| Temporary files | `tempfile` | Staged file replacement and isolated test directories |
| Linux operations | `rustix` | User checks and controlled low-level filesystem operations |
| Linux operations | `rustix` | User checks, controlled filesystem operations, and bounded Generic Netlink runtime reads |
| Encrypted vault | `age` | Recipient encryption and password protection of the vault secret key |
| WireGuard key pairs | `x25519-dalek` | Key generation and public/private key matching |
| Secure randomness | `getrandom` | Operating-system randomness for key generation |
| WireGuard key encoding | `base64` | Reading and writing WireGuard key representations |
| Secret handling | `secrecy`, `zeroize` | Explicit secret exposure and supported memory cleanup |
| WireGuard runtime | `wireguard-uapi` | Direct Linux interface, peer, handshake, and traffic queries |
| Service management | `zbus` | Direct systemd D-Bus requests and job completion tracking |
| Service management | `zbus` 5.19.0 | Async systemd D-Bus requests and job completion tracking |
| Container integration tests | `testcontainers` | Alpine server/client lifecycle; development dependency only |
- Use a separate vault key pair from all WireGuard client/server key pairs. Encrypt each client secret to the vault public key; protect the vault secret key with password-based encryption using the established `age` format. Do not replace this with password-only encryption of every new client record, which would require a password on creation.
- Validate `wireguard-uapi` against the isolated integration environment before relying on it. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application.
- `wireguard-uapi` remains an approved option but is not selected for the bounded runtime reader: its public read API does not expose a receive deadline or a complete-dump guarantee. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application.
- Runtime observation uses the already approved `rustix` 1.1.5 safe `net` and `event` APIs for a nonblocking Linux Generic Netlink socket, polling and datagram I/O. This keeps one monotonic deadline and exact truncation/sender checks around the checked WireGuard decoder. It does not add a direct dependency or use a subprocess.
- The systemd adapter pins `zbus` 5.19.0 with default features disabled and `async-io` plus `p2p` enabled. It first uses the system bus, then selects `/run/systemd/private` peer mode only when the bus connection reports a missing/refused/disconnected endpoint, before submitting any restart request. Permission errors, authentication/protocol failures, and deadline expiry do not trigger fallback. Private mode uses EXTERNAL authentication and checks the peer UID is root. Bus calls use the systemd well-known destination; private calls omit the destination and avoid bus-daemon owner monitoring. zbus's local peer signal matching tracks `JobRemoved` after systemd `Subscribe`. Each call uses zbus's asynchronous APIs driven by a small standard-library wake/park loop so operations share the caller's monotonic deadline. zbus's async-io backend owns internal I/O executor thread(s); callers must allow the connection to be dropped after each operation. Signal polling uses zbus 5.19.0's doc-hidden `export::futures_core` re-export to avoid another direct dependency; keep zbus pinned until that implementation is deliberately migrated. The private systemd socket is an internal systemd interface and may change between releases.
- Avoid incompatible duplicate versions of secret-handling types where library APIs exchange them. Use supported re-exports where appropriate rather than adding redundant direct dependencies.
- Additional direct dependencies still require the comparison and prior agreement required by [AGENTS.md](../AGENTS.md), including any test/build helpers. Transitive dependencies of approved crates are resolved through Cargo; review their compatibility and runtime requirements. Do not add a direct `tokio` dependency merely because an approved library uses an async runtime internally.
- `clap`, `chrono`, SQLite, a generic INI writer, a mocking framework, and archive compression libraries are not part of the approved initial set. Use the standard library for the small startup argument surface and background threads/channels where sufficient.
### External runtime and build requirements
- Production requires Linux with WireGuard support, the existing configured systemd service, and the system D-Bus needed by `zbus`. Existing `wg-quick` service dependencies remain necessary if that is how the service manages the interface.
- Production requires Linux with WireGuard support and the existing configured systemd service. Restart uses the system D-Bus when available and can use systemd's root-only `/run/systemd/private` peer socket when the bus endpoint is unavailable. Permission or authentication failures and timeouts stop the operation without trying the private socket. The private socket is not a stable public systemd API. Existing `wg-quick` service dependencies remain necessary if that is how the service manages the interface.
- The application uses library APIs rather than invoking `wg`, `systemctl`, `age`, `qrencode`, `tar`, or `openssl` for its own operations. Test fixture setup and the existing WireGuard service may use their required system tools; that does not create an application subprocess fallback.
- Rust/Cargo and the target toolchain/linker are build requirements, not production application requirements. Verify the release binary's actual dynamic-library requirements rather than promising an automatically static executable.
- A Docker-API-compatible runtime, test images, and container tools belong to the integration environment only. Production does not need Docker or Testcontainers.
+65 -5
View File
@@ -46,15 +46,75 @@ all native compilers/linkers needed by a cross build. Establish the musl linker
and any C dependency requirements before milestone 8, or build in a dedicated
Alpine/musl builder. Keep build tools out of the small runtime test image.
Once prerequisites and Cargo scaffolding exist, the planned cross-build check is:
The Alpine suite has two separate execution environments:
- The host runs the Rust Testcontainers harness and must have the pinned Rust
toolchain, approved development dependencies, and access to an explicitly
selected disposable Docker-compatible Linux daemon. Testcontainers manages
only scenario-owned networks/containers. Do not silently use a remote daemon
or the default local daemon before it has been selected as disposable.
- The daemon's Linux host must already provide kernel WireGuard support and
IPv4 networking. Server and client containers share that kernel but receive
only scoped `NET_ADMIN` in their own network namespaces. The suite verifies
support in disposable probe containers on a private internal IPv4 network. It
must not load modules, alter host sysctls, or create host interfaces. Missing
kernel, daemon, IPv4, or namespace prerequisites are an incomplete explicit
run, not a passing skip. IPv6 capability and traffic are intentionally not
tested by M8; production dual-stack behavior remains unchanged.
The fixture image is pinned to an Alpine release or digest and records the
resolved image identity and complete installed package closure. It contains
runtime utilities only (`wireguard-tools`, `iproute2`, BusyBox tools actually
used, and any justified additions). Driver
and fixture inputs are staged per fresh scenario container; keys and configs
must never enter the image build context or layers. The test-only server driver
must be built for `x86_64-unknown-linux-musl` or inside a pinned Alpine/musl
builder matching the repository toolchain, then checked for actual dynamic
requirements and executed in the fixture image. A successful cross build alone
does not establish Alpine compatibility.
The current host observation above records that the musl standard library is
missing. Do not install it or a linker implicitly. The fixture recipe now lives
in `tests/containers/alpine/` and the exact image commands are in
[TESTING.md](TESTING.md). The base is pinned to Alpine 3.22.7 for linux/amd64;
the selected disposable daemon built and cached the image, and the exact
installed closure is recorded in `packages.lock`. Compiler/toolchain layers stay
out of this runtime image.
M8.4 provides an in-crate `#[cfg(test)]` library test driver so that its fixture
command and restart seams stay inaccessible to the production executable. It
includes an ignored test named `container_driver::alpine_readiness`, selected
with libtest's `--ignored --exact` arguments. This harmless test checks only its
Linux x86_64 execution environment. Once the musl standard library and a
compatible linker are available, build the library test harness from the
repository root with:
```sh
cargo build --locked --release --target x86_64-unknown-linux-musl
cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl
```
Then run the built executable in Alpine and inspect actual dynamic-library
requirements. A target name alone is not evidence that the binary runs there.
Musl readiness is a milestone 8 prerequisite, not a milestone 1 acceptance gate.
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 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-<hash>`
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
+111
View File
@@ -0,0 +1,111 @@
# Execution records and diagnostic workflow
This document governs development records, not application logging. It supplements
[the implementation order](IMPLEMENTATION.md) and [agent instructions](../AGENTS.md).
Product requirements, independent review and acceptance gates are unchanged.
## Record boundaries
| Record | Purpose | Contents |
| --- | --- | --- |
| [PLANS.md](../PLANS.md) | Current execution contract and resume point | Scope, acceptance checklist, task state/owner, open findings, concise closed-finding disposition, latest applicable evidence, limitations and next action. |
| `docs/diagnostics/milestone-N.md` | Investigation notebook, created only when needed | Failed attempts, hypotheses, bounded diagnostics, observations, cause and repair verification. |
| `docs/evidence/milestone-N.md` | Reproducible evidence report, created when evidence no longer fits a concise plan table | Exact commands, source/build identity, environment, outcomes, review disposition and acceptance mapping. |
| `docs/history/` | Preserved superseded records | Historical snapshots with an explicit non-current notice and a link back to the plan. |
The plan is a current snapshot, not an append-only diary. Do not copy worker
reports or full diagnostic sequences into it. Keep the detailed report in one
place and link it. Evidence and diagnostic reports never override the plan's
scope or the specification. Read only the relevant investigation/report when
resuming; reading all historical logs is not a prerequisite for implementation.
## Updating the plan
The coordinator updates a task row in place when its result, owner, next action,
review disposition or blocker changes, and before handoff. A failed command alone
does not need a plan entry unless it changes current status or invalidates evidence.
For an active failure keep one concise entry: affected behavior, known facts,
owner, next discriminating check and acceptance impact. Replace it as facts change.
Keep the milestone register, milestone status/outcome and top-level resume point
consistent in the same edit. Use the existing lifecycle states; distinguish a
repairable defect (`needs_changes`) from an unavailable prerequisite (`blocked`).
Task completion does not imply milestone acceptance. Closed findings retain their
stable ID, effect, final resolution and review/evidence reference; intermediate
repair attempts belong in diagnostics. Superseded passing runs are historical,
not additional proof for the current revision.
Aim for at most one short paragraph per task/finding and one row per evidence
scope. This is a readability target, not permission to omit acceptance criteria,
required evidence, unresolved findings or safety constraints. If detail exceeds
that target, move the detail to the appropriate linked report.
## Investigating failures
1. Classify the failure: prerequisite, fixture/transport, application behavior,
assertion, or currently unknown. Do not assume the application or fixture is
correct before checking.
2. Record a concrete hypothesis and the smallest bounded check that distinguishes
it from alternatives. Reuse the same task/finding ID across attempts.
3. Run that check and record the observation. Retry unchanged commands only for
an evidenced transient condition or deliberate repeatability check; record why.
4. After two failed repair attempts for the same symptom, reassess the cause and
task boundary before another expensive full run. Obtain a focused independent
review or reduce to a minimal reproduction when useful. This is an escalation
of investigation, not an arbitrary acceptance cutoff or a reason to ask the
user to resolve routine implementation choices.
5. Repair the cause, run focused checks, then rerun affected integration behavior.
Rebuild the container driver when its inputs changed; reuse a compatible pinned
builder/cache. Do not run the full Rust gates for every diagnostic edit. Run
them at the required completion gates and again if subsequent changes invalidate
them, following [TESTING.md](TESTING.md).
6. Review the actual repair and affected behavior independently. Consolidate the
plan to the final result, remaining uncertainty and next action.
A diagnostic entry contains: ISO timestamp with offset; task/finding ID; symptom;
hypothesis; exact safe command or reproduction reference; observed result; cause
if established; next action or closure/evidence reference. Record only useful
redacted excerpts. Raw logs, secrets, configuration payloads, private keys and
working backups must not be committed. An agent stall or usage limit needs a
brief handoff note only when it affects ownership or the next action.
## Evidence and handoff
An evidence entry identifies:
- Task/criterion and run timestamp with offset.
- Tested commit plus dirty-state description, or a reproducible fingerprint of
relevant tracked/untracked source inputs; identify the built artifact and its
source inputs when a separate container binary is used. A commit alone does
not identify a dirty working tree. Never fingerprint secret-bearing data into
a report or claim a source identity that was not captured.
- Exact command, toolchain/target and required environment or immutable image
identity, excluding secrets. Mark missing historical identity as unavailable.
- Actual outcome: exit/result counts, fixture versus Alpine versus VM scope,
cleanup outcome, limitations and which later edits invalidate the evidence.
- Independent reviewer disposition and remaining findings where applicable.
Do not manufacture missing evidence when migrating old records. Carry forward
reported results as historical evidence and state whether they were rerun.
Before accepting a milestone, verify the integrated files, close actionable
findings, run required gates on that revision and update the acceptance mapping.
A handoff consists of current task/state/owner, open issue, latest applicable
checks, environment prerequisites and one concrete next action. Link diagnostics
only if needed to avoid repeating an investigation. It must be possible to resume
from the current plan without reconstructing chronology.
## Migration and ownership
Preserve the old record in `docs/history/` before compacting existing diagnostic
history. Keep scope, acceptance criteria, stable finding IDs, final outcomes,
limitations and source links. Clearly label contradictory historical statuses as
superseded. Migrate older accepted milestones when they need maintenance; do not
rewrite their acceptance merely for uniform formatting.
The coordinator owns PLANS.md and evidence consolidation. Assign a diagnostics
file to one writer at a time, or have workers return concise entries for the
coordinator to integrate. Include these record boundaries in dispatch briefs;
workers still report actual checks and unresolved issues. New agent configuration
may not affect an existing session: supply this document explicitly at its next
authorized dispatch/handoff. Do not message other chats without user authorization.
+1 -1
View File
@@ -1,6 +1,6 @@
# Implementation Plan
Basis: the approved [specification](SPEC.md). This document defines the order of work without changing the requirements. Specific libraries and serialization formats remain implementation decisions.
Basis: the approved [specification](SPEC.md). This document defines the order of work without changing the requirements. Specific libraries and serialization formats remain implementation decisions. Maintain current state, evidence, and diagnostics according to [Execution records and diagnostic workflow](EXECUTION_RECORDS.md); PLANS.md must not become an append-only debugging diary.
## 1. Model and configuration
+150
View File
@@ -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.
+13
View File
@@ -103,3 +103,16 @@ WireGuard or systemd operation, does not provide concurrent-editor locking,
and is not automatic rollback after a failed runtime restart. The separate
pre-operation archive remains the recovery source if local journal data is
unusable; the adapter does not silently discard an invalid journal.
For an explicit restart, the application creates the pre-restart archive before
calling the service adapter. After a successful terminal service job, it checks
the complete observed peer set against the saved server document. A match is
then recorded by installing the updated `internal-state.toml` through this same
journal, with reservations removed only from the proposed state as appropriate.
The in-memory saved/applied state advances only after installation succeeds. If
that write or journal recovery fails after the VPN may have changed, the
application reports the persistence stage, retains in-memory reservations, and
requires manual recovery; it does not roll back or restart again. Default tests
exercise this orchestration with private fake adapters and synthetic files.
Actual interface and systemd behavior remain deferred to the isolated Alpine
and disposable VM acceptance environments described in `RUNTIME.md`.
+90 -40
View File
@@ -1,12 +1,13 @@
# Project Layout
Read this document when creating or reorganizing modules. The paths below are planned until implemented.
This is the integrated package layout after M26. Paths below exist in the
repository; scenario descriptions name current owners. Add modules when a
cohesive responsibility needs them, not as placeholders.
## Target project layout
Start with one Cargo package containing a binary (`src/main.rs`) and a library (`src/lib.rs`). Keep startup, argument handling, and process exit in `main.rs`; place testable application logic in library modules. A library target does not require publishing a separate library package. Introduce a workspace only when independently reusable or separately built components justify it.
The following is the intended layout, not a claim that these files already exist. Create modules and directories as implementation requires them; do not generate empty scaffolding for the entire tree.
The repository has one Cargo package with a binary (`src/main.rs`) and a library
(`src/lib.rs`). Startup and process exit remain in `main.rs`; testable
application logic and the TUI are library modules. The library target is not a
separately published package.
```text
Cargo.toml
@@ -16,49 +17,98 @@ AGENTS.md
CONTEXT.md
PLANS.md
README.md
.gitignore
src/
main.rs # Startup, arguments, process exit
lib.rs # Module declarations and application API
config.rs # Application settings and path resolution
domain.rs # Clients, identities, address allocation, change states
app.rs # Application operations and orchestration
lib.rs # Library modules; test-only container driver gate
startup.rs # Root boundary, settings/store loading, session entry
config.rs # Settings validation and executable-relative paths
domain.rs # Client projection, address allocation/reservations, apply knowledge
app.rs # Application facade, session, and public operation types
app/
draft.rs # Coordinated document/client/vault draft replacements
first_delivery.rs # Session-local one-use capability and retry state
first_file.rs # First-delivery file and optional QR scenario
reissue.rs # Password-per-action saved-client reissue
read_model.rs # Secret-free application facts and per-action eligibility
commit.rs # Backup, saved install, and Commit publication
restore.rs # Preview, reservation rebuild, and confirmed saved replacement
backups.rs # Manual/pre-operation backup and archive listing
restart.rs # Restart preview, backup/job/verify/publication sequence
wireguard.rs # Server configuration module boundary
wireguard/
document.rs # Parsing and edits preserving original text
metadata.rs # Managed client comment blocks
vault.rs # Encryption, import, secret retrieval
backup.rs # Backup archives and restoration
runtime.rs # Running WireGuard interface and statistics
service.rs # Systemd operations and restart verification
delivery.rs # Client configuration files and QR generation
tui.rs # Terminal UI entry point
document.rs # Lossless parse, peer inspection and document authority
edit.rs # Byte-preserving peer and metadata edits
metadata.rs # Managed comment grammar and metadata validation
vault.rs # Vault format, encryption, key matching and retrieval
vault/
source.rs # Read-only configured client-source access
import.rs # Client-file reconciliation and encrypted import
persistence.rs # Saved-file installation, recovery and state codec
backup.rs # Versioned archive encoding, parsing and preview
runtime.rs # Runtime observations, peer projections and comparison
runtime/
netlink.rs # Bounded Generic Netlink WireGuard reader
transport.rs # Read-only runtime transport
service.rs # Systemd request and job completion adapter
service/
dbus.rs # D-Bus systemd implementation
delivery.rs # Client configuration rendering and adapter boundary
delivery/
files.rs # Secure client file publication
qr.rs # Terminal QR generation
tui.rs # TUI entry and session lifecycle
tui/
state.rs # Screen state and navigation
events.rs # Keyboard and background events
render.rs # Rendering
terminal.rs # Terminal setup and cleanup
state.rs # Screens, selection, UI generations and navigation
events.rs # Typed commands, worker queue and lifecycle
events/
executor.rs # Application command adaptation and prepared contexts
forms.rs # Short-lived form inputs and sensitive commands
render.rs # Visual strings, styles, wrapping and layout
terminal.rs # Terminal setup, event loop and cleanup
container_driver.rs # Test-only Alpine scenario driver (`cfg(test)`)
tests/
config_preservation.rs # Untouched server configuration remains identical
commit_restore.rs # Persistence, backups, restore, runtime isolation
client_lifecycle.rs # Import, creation, delivery, deletion, state transitions
fixtures/ # Synthetic configuration and archive inputs
support/
mod.rs # Shared test adapters and helpers
config_preservation.rs # Synthetic byte-preservation behavior
commit_restore.rs # Persistence, backups, restore and runtime isolation
client_delivery.rs # First delivery, retry, reissue and lifecycle behavior
runtime_restart.rs # Restart scenario behavior using controlled adapters
vault_import.rs # Import and reconciliation behavior
tui_workflow.rs # Worker-level TUI workflows and synthetic PTY checks
testcontainers_wireguard.rs # Explicitly selected, ignored Alpine integration suite
support/ # Shared fixtures, adapters and container harness
fixtures/wireguard/ # Synthetic server configuration
containers/alpine/ # Pinned Alpine runtime image and readiness support
vm/ # Guarded disposable-VM preparation/test scripts
config/
wireguard-admin.example.toml # Safe example; settings format chosen at implementation
wireguard-admin.example.toml # Safe distributable example settings
docs/
SPEC.md
IMPLEMENTATION.md
PROJECT_LAYOUT.md
architecture/domain-refactoring.md
adr/
target/ # Cargo output; never commit
target/ # Cargo output; never commit
```
- Use modules named for their responsibility. Avoid catch-all `utils`, `helpers`, or `common` modules; place supporting code with its owner.
- Begin with a single module file and split into child modules when needed. Prefer `name.rs` with `name/child.rs` for production modules; `tests/support/mod.rs` keeps test helpers from becoming a separate integration test target.
- Declare application modules once in the library, rather than duplicating them in both `main.rs` and `lib.rs`. Keep internals private or `pub(crate)`; expose only the library API needed by the binary and behavioral integration tests.
- Keep focused unit tests beside their modules under `#[cfg(test)]`; put cross-module behavioral tests in `tests/`. Use `tests/support/` for test-only adapters rather than exporting test infrastructure from the production library.
- The `config/` directory contains distributable examples, not live settings. The example filename above is illustrative and does not settle the settings format. Actual settings remain alongside the deployed executable as specified.
- Keep live server configurations, private keys, delivered client files, and runtime `Backup/` data out of the source tree and version control. Ignore generated build output and local deployment data without hiding synthetic fixtures.
- Add `examples/` only for runnable Rust examples and `benches/` only when benchmarks are needed. Do not add empty directories or scripts that duplicate Cargo commands.
- Keep the layout aligned with actual module responsibilities as the code grows. Update this section when files move; do not preserve obsolete paths merely to match the initial tree.
## Ownership rules
- `Application` is the public facade. Scenario ordering belongs in `src/app/`
owners; the facade delegates without transferring authority to the TUI.
- The server `ConfigDocument` is authoritative for peers and metadata.
`ClientStates` is only the manageable-client projection. Editing preserves
unowned bytes and comments.
- Domain logic does not perform file, crypto, runtime, service, or rendering
work. Persistence owns installed state; backup owns archive encoding;
runtime owns observations/comparison; service owns systemd calls.
- `app/read_model.rs` supplies secret-free facts and action eligibility.
TUI state owns selection/navigation/generation; the executor binds prepared
continuations and calls application operations; renderers own presentation.
- Keep module internals private or `pub(crate)` where possible. Public facade
APIs retained for integration fixture setup are documented with their caller
audit in [the architecture map](architecture/domain-refactoring.md).
- Keep focused unit tests beside their modules and cross-module behavior in
`tests/`. `src/container_driver.rs` is included only in the library test
harness. Its opt-in Alpine application fixture is not systemd acceptance.
- `config/` holds safe examples, not live settings. Runtime data and archives
remain outside the source tree. Do not commit live configuration, keys,
delivered client files, or generated `Backup/` data.
- Keep this map aligned with actual files and responsibility. Do not add empty
directories or speculative traits/modules.
+85
View File
@@ -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.
+173
View File
@@ -0,0 +1,173 @@
# Runtime observation and verification boundary
`runtime` defines public, read-only observation values, a bounded Linux
WireGuard Generic Netlink reader, and a pure comparison between an observed
interface and the authoritative saved server document. The reader accesses
only the configured interface and never changes the running interface.
## Observation values
An observation records an optional UTC sample time and distinguishes an unknown
interface, an unavailable read, a missing interface, and a present interface.
A present interface carries its observed interface name and peers keyed by
`PublicKey`; duplicate keys are retained as an explicit ambiguity signal. The
comparison requires the currently configured interface name and rejects a
different observed interface, even when its peer set happens to match. The peer
set states whether it is unknown, unavailable, partial, or complete. Only a
complete set can prove that a saved peer is absent or that an extra runtime peer
exists.
Each peer has AllowedIPs and statistics with their own availability. A valid
zero byte counter is `Available(0)`. A peer with no recorded handshake is
`Available(None)`. Unavailable statistics do not become zero or a disconnected
state. Counters are current values from the server interface's perspective and
may reset after restart; they are not history and do not establish online or
offline status.
## Saved technical projection
`SavedPeerProjection::from_document` includes every `[Peer]` section from the
byte-preserving `ConfigDocument`, including valid routes unsupported by the
narrow `domain::Client` address model and legacy IPv4-only peers. Routes are
normalized and compared as complete sets, independent of ordering and duplicate
entries. Aliases and managed creation dates are not technical runtime fields;
metadata anomalies alone do not prevent comparison. Missing or ambiguous keys,
malformed routes, structural document issues, and duplicate identities remain
unverifiable and cannot be omitted to obtain a matching reduced set.
`compare_peers(saved, expected_interface, runtime)` checks interface presence
and identity, the complete public-key set, and each peer's complete AllowedIPs
set. It reports a wrong interface, missing, extra, route-mismatched, duplicate,
partial, and unverifiable cases. An empty saved peer set can match only a
present expected interface with an explicitly complete empty runtime peer set.
Handshake values, counters (including resets), endpoint changes, ordering, and
sample time do not affect technical equivalence. The comparison returns only a
technical result; it does not create online status, applied state, or history.
This result is separate from M4's `domain::Snapshot` and applied marker. A
successful technical comparison is necessary evidence for applying state, but
it is not itself a durable M4 transition. Application restart orchestration
joins saved aliases and representable addresses by public key only after full
technical equivalence succeeds. If any authoritative peer cannot be safely
represented for that narrower durable view, verification remains unverifiable
and reservations are retained instead of supplying a fabricated subset.
## WireGuard Generic Netlink decoder
`runtime::netlink::GetDeviceDecoder` is a pure decoder for one WireGuard
`GET_DEVICE` dump. It consumes caller supplied datagrams and expected request
sequence, local netlink port ID, family ID, and interface name. It has no socket
or host access. The transport rejects truncated datagrams, enforces its
monotonic receive deadline, and wipes its receive buffer when its lifetime ends.
The decoder checks every netlink and attribute length/alignment, message type,
sequence and port ID, Generic Netlink command/version, configured interface,
nested peer and AllowedIPs records, duplicate fields and aggregate limits. It
returns data only after an explicit `NLMSG_DONE` with an embedded zero error
code. It rejects `NLMSG_ERROR`, interrupted/overrun dumps, malformed or
incomplete data, and limits exceeded. Peer fragments are coalesced only when a
repeated public key is adjacent across multipart messages and the fragment
contains only that key and AllowedIPs; duplicate routes remain invalid. A
complete empty peer list is valid.
Server private keys and peer preshared keys are length-checked through borrowed
input slices and are neither copied into decoder-owned structures nor returned.
The result contains only public keys, AllowedIPs, last-handshake timestamps and
current received/transmitted counters. Errors carry stable categories without
raw message data. The decoder uses configurable aggregate limits with bounded
defaults; future transport code must set request-specific limits explicitly.
Protocol details follow the [WireGuard Generic Netlink specification](https://docs.kernel.org/netlink/specs/wireguard.html)
and the [Linux Netlink message specification](https://docs.kernel.org/6.1/userspace-api/netlink/intro.html).
## Systemd restart job tracking boundary
`service::RestartJobTracker` is the pure event state machine for one resolved
primary systemd unit ID. The bounded zbus 5.19.0 adapter is internal and
crate-private; public runtime mutation is exposed only through
`Application::restart`, after an explicit restart choice. The adapter installs
`JobRemoved` and systemd name-owner observation, calls Manager `Subscribe`, and
resolves the configured target with read-only Manager `GetUnit` followed by the
unit's `Id` property. It then submits `RestartUnit` using the original
configured target and tracks the returned job object path together with the
resolved primary ID, which is the unit name reported by `JobRemoved`. If
`GetUnit` cannot resolve a loaded unit, or reading its ID fails, the operation
stops with a redacted resolution failure; it does not load a unit or guess its
canonical name. The signal stream is installed before resolution and restart,
so a fast completion queued before the method reply remains available once the
reply supplies the path. The pure tracker also supports a fixed bound of early
events for transports that deliver events before the method reply; overflow
fails closed.
Only systemd's `done` job result is successful. Failed, canceled, timed-out,
skipped, dependency, or unknown job results remain failures. Bus disconnection,
manager owner change, subscription failure, and permission/request failures are
separate redacted outcomes. `RestartDeadline` carries the operation's monotonic
start and timeout so connection, match registration, Subscribe, unit
resolution, RestartUnit, job wait, and later runtime verification can share one
budget. The adapter drives zbus async futures with a standard-library thread
waker and parks only for the remaining budget. zbus's `async-io` backend
continues socket I/O on its own executor; its connection is short lived and is
dropped after the adapter returns. Deadline expiry stops the application's
wait; a submitted job may still complete. The adapter never cancels or retries
the job, checks unit active state, or verifies the WireGuard interface.
Ordinary adapter tests inject a fake event transport and never connect to the
system bus. Real D-Bus behavior remains for the disposable systemd VM
acceptance at M9. Protocol
references: [systemd Manager D-Bus API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html)
and [zbus 5.19.0 API](https://docs.rs/zbus/5.19.0/zbus/).
## Application restart orchestration
`Application::preview_restart` binds a read-only preview to the current saved
generation and resolves the configured interface, systemd unit, and timeout
from that generation. `Application::restart` accepts an explicit choice. Cancel
returns before backup, service, runtime, or persistence adapters run. A
confirmed operation completes a `pre-restart` backup, submits a systemd restart,
waits for a successful terminal job, reads and compares the complete runtime
peer set within the shared monotonic deadline, then writes verification state
through the saved-file journal. Only after that write succeeds does in-memory
applied state advance and deleted-client reservations release.
Failure reports identify the stage, whether a restart was submitted, whether it
may still complete, the backup path when available, and whether manual recovery
is required. A timeout does not cancel a submitted systemd job. A runtime mismatch
or verification-state write failure does not roll back the VPN or release
reservations. Commit and restore remain saved-file operations and never invoke
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. 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
`runtime::transport::observe_interface` resolves the WireGuard Generic Netlink
family through `nlctrl`, then sends only `WG_CMD_GET_DEVICE` for the supplied
interface. It uses rustix 1.1.5's safe nonblocking socket, `poll`, and datagram
APIs; no `SET_DEVICE`, subprocess, unsafe block, or host socket is used by the
default tests. Family lookup and the complete multipart dump share one
monotonic deadline. The datagram size is capped, `MSG_TRUNC` is detected using
the returned full datagram length, the sender socket address must be kernel
port 0, and decoder aggregate limits apply across the dump.
Linux's Generic Netlink reply header `nlmsg_pid` carries the requester port ID
(the destination); the actual kernel sender port ID is 0 in the datagram
address. The transport validates both identities. A complete read is returned
only after the decoder consumes successful `NLMSG_DONE`; kernel errno values
map to distinct no-device and permission outcomes. Malformed data, transport
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. 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.
+189 -12
View File
@@ -17,6 +17,21 @@ cargo build --locked --release
Use only supported feature combinations; do not assume `--all-features` is valid. Keep privileged integration tests out of the default test suite. If a check cannot run, report the exact limitation rather than claiming success. Documentation-only edits do not require rebuilding the application.
The unprivileged TUI fixture commands currently available are:
```sh
cargo test --locked --test tui_workflow
cargo test --locked tui::
```
`tui_workflow` runs setup with a redacted invalid-source anomaly, client
creation/rename, Commit, first-file and QR delivery, manual backup/list, and
preview/cancel/confirm restore through the serialized application worker. It
uses temporary settings/server/source files and a fake statistics reader; it
does not invoke service or runtime mutation.
The `tui::` filter covers forms, rendering, worker responsiveness/staleness,
QR fit, and terminal cleanup/error seams. It does not require root or a PTY.
Prioritize meaningful behavioral checks:
- Configuration round trips preserve untouched bytes, unknown fields, and administrator comments; managed metadata cannot escape its comment block.
@@ -30,35 +45,197 @@ Prioritize meaningful behavioral checks:
Test externally observable behavior and high-impact failure paths. Avoid tests that merely repeat implementation details, introduce root requirements, or assert real secrets in snapshots. Broaden testing when failures or new risks justify it.
### Terminal smoke coverage
`cargo test --locked --test tui_workflow terminal_pty_smoke -- --nocapture`
launches the terminal loop through `script` with a synthetic `Application` and
an injected fake unavailable-statistics reader. It enters at 80×24, changes to
40×8 and back to 80×24, sends Ctrl-C (handled as the normal exit request), and
asserts the alternate screen and cursor are restored. The setup is not root
management startup and does not read host settings or query host WireGuard.
The test reports itself incomplete if the
`script` utility is unavailable. In the workflow test, an actual first-delivery
QR result is transferred into the disclosure owner and Esc clears it; the
renderer fit test separately checks complete quiet-zone dimensions. These
checks are synthetic PTY/UI evidence, not phone/decoder interoperability.
The PTY test exercises handled interruption and normal terminal restoration;
`cargo test --locked tui::terminal::tests::lifecycle_failures_restore_terminal_modes_in_isolated_pty -- --nocapture`
uses `script` to launch five isolated child probes at 80×24. The probes stop
after raw-mode setup, stop after alternate-screen setup, return synthetic
render and input I/O errors after full setup, and panic after full setup. Each
child checks raw mode is off on return; the parent checks that alternate-screen
and cursor enter/restore sequences match the resources reached. The panic case
also checks that the panic hook emits its generic internal-failure diagnostic
without the synthetic panic payload. These probes use the same private lifecycle
guard and setup methods as production, without an environment-controlled
production path or application/service adapters. The test reports itself
incomplete if `script` is unavailable. PTY evidence remains synthetic; it does
not test terminal-emulator behavior beyond the emitted control sequences.
## Integration testing with Alpine Testcontainers
Production access is limited and experimentation on the production VPN is prohibited. Use three complementary levels: ordinary unit/fixture tests, explicitly selected Alpine container integration tests, and a separate disposable Linux VM for real systemd/D-Bus acceptance. No level authorizes using production as a test environment.
### Alpine environment
- Use `testcontainers` to start separate Alpine Linux server and client containers on a dedicated test network. Keep the image minimal: `wireguard-tools`, `iproute2`, and only any additional tools actually needed by the fixture. Alpine is not a systemd test environment.
- Build and cache the fixture image ahead of repeated test runs. Pin the Alpine base/version or digest and document the image recipe; avoid package installation and image rebuilding for every test. Reuse image layers, not mutable containers or secret-bearing volumes between tests.
- The Docker host must provide Linux kernel WireGuard support. Containers share that kernel; installing userspace tools in Alpine does not supply it. Probe prerequisites in the disposable environment and report missing capabilities clearly. Do not automatically load host modules or change host settings.
- Grant `NET_ADMIN` in the test containers for their own network namespaces. Do not default to `--privileged`, `SYS_MODULE`, host networking, or shared host PID namespaces. Configure IPv6 for the dedicated test network as needed; exercise both address families.
- Run the application operations or a test driver linked to the real library inside the server container, including the real `wireguard-uapi` adapter. A host-side test harness may orchestrate containers, but must not query or modify the host's WireGuard interface as a shortcut.
- Build the in-container executable for Alpine compatibility, preferably the matching musl target. Do not assume a glibc-linked host binary runs in Alpine. Keep compiler/toolchain layers out of the small runtime fixture image where practical.
- Generate synthetic server/client keys and configurations for each test. Copy fixtures into container-local filesystems. Never mount production files, the host's `/etc/wireguard`, host system D-Bus sockets, or the Docker socket inside fixture containers.
- Poll explicit readiness and handshake/traffic conditions with deadlines rather than fixed sleeps. Always clean up containers, dedicated networks, and temporary resources after success or failure. Keep diagnostic artifacts redacted.
- Use the approved `testcontainers` development dependency to start a fresh Alpine server and client on a dedicated internal IPv4 network. The RFC1918 bridge subnet `192.168.253.0/24` is reserved for this isolated underlay and must not overlap the synthetic tunnel ranges used by scenarios. Alpine is not a systemd test environment. The host process owns only resources it creates for the current scenario: uniquely named network, server/client containers, and private temporary fixture files. Run the probe synchronously so cleanup does not race a forcibly stopped Testcontainers request. When Testcontainers returns a container handle, RAII drops that owned container on success and recoverable failure; a final bounded cleanup inspects only the scenario's exact names and removes IDs only when the current process's ownership label matches. If a start request times out before returning an ID, an immediate absent inspection cannot prove the daemon will never finish creation. Report that resource status as uncertain, retain its exact name, and do not broaden cleanup.
- Pin the Alpine base to a release or digest, record the resolved image identity and package versions, and build/cache the image before repeated suite runs. The runtime image contains only `wireguard-tools`, `iproute2`, BusyBox facilities that are actually used, and justified fixture requirements. It contains no systemd, Cargo registry, compiler, production configuration, generated key, or secret-bearing volume. Reuse image layers only; each scenario gets fresh containers and fresh private data directories.
- The Docker-compatible daemon must be explicitly selected as a disposable Linux environment. The suite first identifies the selected endpoint and requires an explicit test-environment opt-in before making Docker API calls. It must not silently use a configured remote context. The daemon host must already provide kernel WireGuard support: containers share that kernel, and Alpine userspace tools do not supply it. A disposable probe verifies WireGuard interface creation and IPv4 address/route operations inside throwaway containers; it must not load modules, alter host sysctls, or create host interfaces. Missing support fails an explicitly requested run. IPv6 capability and traffic are intentionally not tested by M8.
- Testcontainers 0.28.0 is built with only its `blocking` feature; `properties-config` is disabled. Its Docker client therefore reads `DOCKER_HOST` from the process environment and cannot select `tc.host` or `docker.host` from `~/.testcontainers.properties`. The probe requires `DOCKER_HOST` to exactly equal the explicitly selected local Unix endpoint, and its Docker CLI checks pass that same endpoint with `--host`. A mismatch fails before either client is used.
- Give only the server and client containers `NET_ADMIN`, scoped to their own network namespaces. Run them in separate container network and PID namespaces. Do not use `--privileged`, `SYS_MODULE`, host networking, host PID/IPC namespaces, host `/etc/wireguard`, host D-Bus, Docker socket mounts, or production bind mounts. Use a uniquely named internal IPv4 network and choose its private underlay subnet so it does not overlap the tunnel ranges.
- The host harness creates and tears down containers/networks and transfers bounded fixture inputs and outputs. Server-side operations run in the server container against its private fixture paths and namespace, using the real Application and `runtime::transport::observe_interface` path. Never query or mutate the host WireGuard interface as a shortcut. Use Testcontainers' supported synchronous runner (`SyncRunner`) and lifecycle/image APIs such as `ImageExt` network configuration only after checking the pinned crate version's API. Fixture commands use the selected endpoint and owned container ID through the bounded Docker CLI transport so a Testcontainers attach stream cannot leave the harness waiting indefinitely. See the [testcontainers 0.28.0 API](https://docs.rs/testcontainers/0.28.0/testcontainers/) and its [`ImageExt`](https://docs.rs/testcontainers/0.28.0/testcontainers/core/trait.ImageExt.html), [`Container`](https://docs.rs/testcontainers/0.28.0/testcontainers/core/struct.Container.html), and [`SyncRunner`](https://docs.rs/testcontainers/0.28.0/testcontainers/runners/trait.SyncRunner.html) references. Keep those APIs in host orchestration; they do not authorize fixture code to control unrelated containers.
- Build only the required server-side test driver for `x86_64-unknown-linux-musl`, or build it in a pinned Alpine/musl builder using the repository's pinned Rust version. A host glibc executable, a target name, or a successful host build is not runtime compatibility evidence. Inspect dynamic requirements and execute a harmless readiness operation in the pinned Alpine image before the suite. Keep the compiler and build cache out of the runtime image. Missing target/linker prerequisites mean the suite cannot run; do not install global tools implicitly.
- Keep the in-container driver boundary private to tests. Prefer an explicitly selected `cfg(test)` driver/scenario entry point that invokes the real application and the real Rustix reader; do not add a production apply command or an environment-variable bypass. The fixture apply operation may use explicit `ip`/`wg` commands with argument arrays and fixed fixture inputs, or equivalent direct fixture setup, but it must never execute imported hooks or arbitrary configuration text. It is not the production zbus adapter and all resulting reports must say “fixture apply.”
- Preserve staged application authority in one long-lived in-container driver session for each scenario. That driver owns the `Application` instance from client creation through Commit and first delivery, retaining the application's in-memory first-issue eligibility. Reopening the saved store or launching a new process between Commit and first delivery is not a valid substitute: first delivery must remain bound to the creation session, and a reopened process must use password-authorized reissue. After first delivery, the driver may perform fixture apply through the private test seam and then verify through the real observation and normal application verification/persistence path. It must not mark state applied or release reservations directly.
- The driver's control channel carries bounded, typed phase requests and redacted phase results only. Do not put passwords, private keys, complete client configurations, QR payloads, or vault plaintext in arguments, environment variables, image metadata, logs, or captured stdout/stderr. Generate WireGuard keys, vault material, and synthetic server/client configurations inside the private server fixture session. Seed only nonsecret driver binaries and fixture scripts with pre-start image copy configuration. Store secret-bearing files in mode-`0600` files under mode-`0700` container directories. Send only opaque path/session identifiers over the control channel. Copy the actual first-delivery file from the server container to a host-private mode-`0600` temporary file, then seed a fresh client container from those exact bytes before it starts. Configure the client from that file and verify its integrity without rendering it in diagnostics. Do not reconstruct or edit the payload to make the client work. The official [files and mounts guide](https://rust.testcontainers.org/features/files/) documents `ImageExt::with_copy_to`, `CopyTargetOptions::with_mode`, and `Container::copy_file_from`; the [exec guide](https://rust.testcontainers.org/features/exec_commands/) documents `Container::exec`. `with_copy_to` packages pre-start content in a TAR; accept only regular paths and reject symlinks. Confirm the pinned 0.28.0 crate supports the chosen sequence before implementation. Never use a host bind mount for secrets.
- Generate synthetic keys/configurations per scenario in private fixture storage. Never put real keys/configs or working `Backup/` data in the build context. Diagnostics may include scenario, phase, public identity, status, elapsed time, and fixed redacted failure category, but not private payloads, command output, QR matrices, or `wg show all dump`. Each in-container command runs through `/bin/busybox timeout -s KILL 15 PROGRAM ...` and uses the bounded Docker CLI transport rather than a Testcontainers attach stream. Each CLI call has a 20-second host deadline, which also bounds a stalled exec response. Stdout and stderr are drained while retaining at most 128 bytes for namespace stdout and 1024 bytes for other stdout/stderr; output overflow fails with a fixed redacted category. The in-container timeout category is `In-container fixture command timed out`; if the host deadline expires, command completion inside the owned container is reported as uncertain. Testcontainers 0.28.0 configures a 120-second Docker API request timeout. The probe has at most 12 sequential Testcontainers API requests: two container starts (at most five each) and two removals, plus a conservative allowance for cleanup on partial starts. This gives a 24-minute upper bound for those API requests. Eight bounded fixture CLI calls add at most 160 seconds; daemon/image checks and network create/remove add at most 60 seconds; partial-start inspection/removal adds at most 60 seconds; final cleanup has a 40-second budget. The conservative aggregate bound is 27 minutes. No shorter outer deadline interrupts an in-flight Testcontainers request. If a start request fails before returning a resource ID, the harness reports an uncertain resource status even if immediate inspection finds no matching resource. Poll explicit conditions against monotonic deadlines rather than sleeping a fixed duration. A cleanup error must not replace the original test failure.
### Required container scenarios
1. Start an isolated server and existing client, establish a tunnel, and read real peer state through the runtime adapter.
2. Create a dual-stack client through the application, Commit, and verify saved files/vault plus unchanged running peer state. Deliver the client configuration and verify that it cannot connect before explicit application.
3. Use a test-only fixture operation to apply the saved configuration inside the container. Establish the new tunnel, generate IPv4/IPv6 traffic, and verify handshake and increasing counters without asserting exact byte totals.
2. Create a client through the application, Commit, and verify saved files/vault plus unchanged running peer state. Deliver the client configuration and verify that it cannot connect before explicit application. M8 checks IPv4 behavior only.
3. Use a test-only fixture operation to apply the saved configuration inside the container. Establish the new tunnel, generate IPv4 traffic, and verify handshake and increasing counters without asserting exact byte totals. IPv6 capability and traffic are intentionally untested.
4. Delete a client and Commit; verify it is removed from saved data but remains in running state until explicit application, and its address remains reserved. After verified application, check removal, loss of tunnel access, and address release.
5. Exercise backups and restoration against container-local data: restoration requires no vault password and does not mutate running state. Verify that applying the restored configuration returns the expected clients.
6. Cover mandatory-backup failure and invalid client import with assertions about unchanged protected state and preserved source files.
Any fixture apply/restart adapter is test-only. It must not weaken the production requirement for a separate systemd restart or be reported as testing the production `zbus` implementation. If application orchestration uses the fixture service adapter, label those results accordingly.
### Opt-in contract and command status
The planned suite is an explicitly named ignored integration target, selected
with a single-target command so `--ignored` cannot pick up unrelated privileged
tests. The harness must additionally require an explicit environment selection
for the disposable daemon before contacting Docker. A missing prerequisite after
explicit opt-in is a nonzero test failure with a redacted prerequisite category,
never a successful “skipped” test. Ordinary `cargo test`, `--help`, and
`--version` must not contact Docker, require root, or inspect host interfaces.
The runtime image recipe is `tests/containers/alpine/Dockerfile`. Its build
context is only `tests/containers/alpine/`; `.dockerignore` is an allowlist for
the Dockerfile, package lock, and harmless readiness script. It does not include
the repository, `.git`, Cargo data, credentials, `Backup/`, keys, or configs.
The image pins the official Alpine 3.22.7 `linux/amd64` manifest
`sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec`.
The [official image metadata](https://github.com/docker-library/repo-info/blob/master/repos/alpine/tag-details.md)
reports config/image ID
`sha256:24ab38714e948bd49d24d566e97c1c104b63f85c2e642685e72393b37cfb9862`.
The Alpine v3.22 x86_64 package indexes currently list [`wireguard-tools`
1.0.20250521-r0](https://pkgs.alpinelinux.org/packages?branch=v3.22&name=wireguard-tools)
and [`iproute2` 6.15.0-r0](https://pkgs.alpinelinux.org/packages?branch=v3.22&name=iproute2).
The exact versions of every package in the installed closure are recorded in
`packages.lock` and passed to `apk add` as constraints. Alpine branch indexes
remain mutable; if an indexed version disappears, the build fails instead of
silently selecting a different closure. The image installs only the listed
runtime package closure; BusyBox is included in that closure.
From the repository root, after selecting the disposable daemon, build and cache
the image with:
```sh
docker build --pull=false --platform linux/amd64 --tag wireguard-admin-alpine:3.22.7 --file tests/containers/alpine/Dockerfile tests/containers/alpine
docker image inspect --format='{{.Id}} {{.Os}}/{{.Architecture}}' wireguard-admin-alpine:3.22.7
docker run --rm --network none --read-only --cap-drop ALL wireguard-admin-alpine:3.22.7 /usr/local/bin/wireguard-admin-alpine-check
docker run --rm --network none --read-only --cap-drop ALL wireguard-admin-alpine:3.22.7 apk info -v
```
The first command produces daemon-local image tag
`wireguard-admin-alpine:3.22.7`; the inspect output records the actual image ID.
The final command records the complete installed package closure. On the
selected daemon, the image built successfully with ID
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`;
`apk info -v` matched the 33 exact constraints in `packages.lock`, and the
networkless, read-only, capability-dropped readiness command exited 0. This
validates the shell tools in the image only; no WireGuard interface was created
and no Rust driver compatibility is claimed.
M8.4 provides the ignored `#[cfg(test)]` library test
`container_driver::alpine_readiness`; libtest selects it with the supported
`--ignored --exact` arguments. It checks only that the process is running on
Linux x86_64 and does not access or modify an interface. The wrapper invokes
only that test with one test thread. M8.4 also provides a test-only fixture
command enum for explicit `ip` link/address/route and `wg setconf` operations.
It passes argument arrays through BusyBox's 15-second timeout, limits interface,
address, route and config-file inputs to typed/validated forms, discards child
output, and returns a fixed redacted failure category. `wg setconf` consumes a
fixture-local file; imported hooks and arbitrary configuration text are never
executed. The application fixture-restart adapter is also `cfg(test)` and
continues through the real Rustix observation, saved-peer verification and
journaled persistence path. Its result represents fixture apply, not a systemd
job.
M8.5's scenario driver must keep a single `Application` value alive from
creation through Commit and first delivery so the in-memory first-issue
authorization remains bound to that creation session. It must not reopen the
store between those operations. Build the test harness for musl with:
```sh
cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl
```
The host lacks the musl target. The official `rust:1.97.1-alpine3.22`
linux/amd64 builder manifest is pinned to
`sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`.
On the selected disposable daemon its image ID is
`sha256:bc2b74a0f3f942897f678aa466c3cce54048cf1e094893af3596cf43766153c2`;
it reports Rust/Cargo 1.97.1 and the installed musl target. From the repository
root, build without installing host tooling or copying source into the image:
```sh
docker run --rm --platform linux/amd64 --mount type=bind,src="$PWD",dst=/src,readonly --mount type=bind,src=/tmp,dst=/out -e CARGO_TARGET_DIR=/out/wga-m8-build-target -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl
```
The 2026-10-23 build completed in 4m25s and emitted
`/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031`.
The hash may change after source edits; use Cargo's printed executable path.
If `/tmp` has been cleared and an offline rebuild is needed, mount an existing
public Cargo registry cache read-only and set `CARGO_HOME` as shown in the
[M8.7 evidence](evidence/milestone-8.md). The pinned builder then produced the
same driver SHA256 from the unchanged source on 2026-10-25. Missing cached
dependencies remain a failed prerequisite until obtained through an approved
build setup; an absent driver must never count as a skipped scenario.
`file` identified that artifact as a static PIE x86-64 ELF, and `readelf -l`
showed no dynamic interpreter. A preliminary networkless, read-only,
capability-dropped Alpine run selected its readiness test and passed 1/1 using
only a read-only bind of this nonsecret binary. The M8.4 host smoke now copies
the current binary into a fresh Testcontainers fixture and selects one ignored
`container_driver::alpine_scenario_driver` test through a bounded stdin/stdout
channel. It requires bootstrap, Create, Commit, first delivery with activation
warning, and verified fixture Apply phase markers. The readiness wrapper executes
`PATH --ignored --exact container_driver::alpine_readiness --test-threads=1`;
it does not pass a custom argument to the harness. No fixture key or
configuration is copied into the builder image or runtime image build context.
The M8.3 prerequisite and M8.4 Application smoke share one exact ignored host
test. After building the current musl artifact, run it on the explicitly selected
local daemon with the matching endpoint and daemon ID:
```sh
DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1
```
The probe uses a uniquely named internal IPv4 bridge and no longer requires
Docker IPv6 or IPv6 iptables support. It must pass before M8.4–M8.5 scenarios
can be run. On 2026-10-23 the current musl artifact and host smoke passed 1/1
in 4.05 seconds on the selected local daemon, and exact-name Docker checks
found no leftover test resources. It proves test-only saved-state creation,
Commit, first delivery and fixture Apply with real runtime observation; it does
not establish a client tunnel or traffic. IPv6 capability and traffic are
intentionally untested; production dual-stack behavior remains specified in
SPEC.md. The M8.5 traffic slice transfers the server's actual first-delivery
file to a fresh client and passed real IPv4 traffic checks. Deletion, restore,
import/reissue, and mandatory-backup-failure slices also passed selected-daemon
runs, with their slice findings closed. The evidence report records the
known-source backup-failure run. On 2026-10-25 the final integrated host gates
and two fresh selected-daemon runs passed on one unchanged source fingerprint;
the documented unfiltered opt-in command passed as a third fresh run. See the
[M8.7 evidence](evidence/milestone-8.md) for identities, counts, durations and
limits. These Alpine fixture results do not cover actual systemd/D-Bus jobs or
the user-excluded IPv6 capability and traffic checks.
### Systemd acceptance and test execution
- Use a disposable Linux VM with WireGuard, systemd, and a system D-Bus to verify the actual `zbus` service adapter, job completion/timeouts, restart verification, and recovery from a selected backup. Do not substitute a mounted host D-Bus or claim Alpine tests cover systemd.
- Use a disposable Linux VM with WireGuard and systemd to verify the actual `zbus` service adapter, job completion/timeouts, restart verification, and recovery from a selected backup. Exercise both the system-bus path and the root-only `/run/systemd/private` path with the system bus endpoint unavailable. Also verify that permission/authentication failures and connection deadlines do not trigger fallback. Current synthetic tests cover transport selection and failure classification; they do not emulate systemd or prove the D-Bus peer protocol. Do not substitute a mounted host D-Bus or claim Alpine tests cover systemd. The private socket is an internal systemd interface and may change between releases.
- Keep container and VM suites out of the default `cargo test` execution. Once implemented, document their exact opt-in commands and prerequisites in this document. Do not list a planned command as available before its test target exists.
- When integration testing is explicitly requested, unavailable Docker, WireGuard support, IPv6, or required capabilities means the suite is not executed or incomplete, not passed. Distinguish prerequisite failures from application failures in the report.
- When integration testing is explicitly requested, unavailable Docker, WireGuard support, IPv4 networking, or required capabilities means the suite is not executed or incomplete, not passed. Distinguish prerequisite failures from application failures in the report. IPv6 capability and traffic are intentionally not tested by M8.
- Report unit/fixture results, Alpine real-WireGuard results, and VM real-systemd results separately. Full acceptance requires the relevant real-system checks, not just mocks or successful container startup.
+392
View File
@@ -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 `<VM name>:<UUID>`.
- Uses `VBoxManage list vms` to require that exact name occur once with the pinned
UUID, then `showvminfo <UUID> --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 `<name>:<UUID>` 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 <pinned UUID>` dry run
first with `--iso=<pinned Debian 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.
+285
View File
@@ -0,0 +1,285 @@
# Domain and application refactoring contract
Status: integrated architecture map and handoff after M26. M20–M26 moved the
responsibilities below to their current owners. This document records verified
source ownership and behavioral proof; it does not authorize behavior,
file-format, dependency, or product changes. M27 review and acceptance remain
pending.
## Authority and boundaries
The server configuration is authoritative for server peers and their managed
descriptive metadata. `ConfigDocument` preserves its bytes and exposes every
peer section, even when a section has no usable public key or cannot be
represented by the supported client model. `ClientStates` is a restricted
projection used for client operations; it is not a second peer registry. The
vault is authoritative for encrypted client secrets, and the saved files are
authoritative for persisted state. Runtime reads are observations, not desired
configuration. Historical successful verification is not current verification.
The dependency direction remains:
```text
TUI -> application facade / application read model -> domain rules
| -> config document/editor
| -> vault/import/delivery
| -> backup/persistence
| -> runtime/service adapters
```
Domain rules do not read files, parse server configuration, perform cryptography,
call the runtime/service, or render terminal text. The application coordinates
these owners. The TUI submits typed requests and displays secret-free facts; it
does not authorize business operations or parse saved configuration. The
document editor never rebuilds the server file from a client snapshot.
## Reading guide: common workflows
These paths identify the application entry, the rule owner, the side-effect
boundary, and tests that exercise failures as well as success.
### Create, Commit, first delivery, explicit restart
- **Entry:** TUI `UiCommand::CreateClient`, `Commit`, `DeliverFirstIssue`, and
`PreviewRestart` followed by the explicit restart choice; the adapter is
`src/tui/events/executor.rs`. The facade methods are
`Application::prepare_new_client`, `commit`, `deliver_first_issue_file`,
`preview_restart`, and `restart`.
- **Rule owners:** `app/draft.rs` coordinates the draft; `app/commit.rs` owns
Commit; `app/first_delivery.rs` owns the one-use session capability and
retries; `app/first_file.rs` owns first-file output; `app/restart.rs` owns
backup, job completion, comparison and durable apply publication. The app
read model reports eligibility; the TUI does not grant it.
- **Effect boundary:** creation changes memory only. Commit backs up and installs
saved files/vault, without a runtime call. First delivery writes the selected
client file and may display its QR. Only the later explicit restart may call
the service/runtime adapters; addresses and current applied knowledge change
only after complete verification and durable bookkeeping.
- **Failure proof:** `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`,
`app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it`,
`app::failed_file_publication_can_retry_in_a_different_directory`,
`app::restart_backup_failure_preserves_draft_and_submits_no_job`,
`app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`.
### Delete, Commit, verified address release
- **Entry:** `UiCommand::DeleteClient`, then `Commit`, then `PreviewRestart`
and a confirmed restart choice through the executor; facade methods are
`Application::delete_client`, `commit`, and `restart`.
- **Rule owners:** `app/draft.rs` stages peer removal; `ConfigDocument` in
`wireguard/document.rs` and `wireguard/edit.rs` owns peer identity and
byte-preserving edits; `domain.rs` owns pending/committed reservations and
allocation; `app/commit.rs` persists the deletion; `app/restart.rs` publishes
release only after `runtime.rs` verifies the full saved peer set and durable
state install succeeds.
- **Effect boundary:** delete is draft-only. Commit backs up and changes saved
files/vault but leaves the running interface unchanged. The address remains
reserved through any restart failure or mismatch and is released only after
verified publication.
- **Failure proof:** `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`,
`app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change`,
`commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`,
`app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`.
### Preview and confirm restore
- **Entry:** TUI `UiCommand::PreviewRestore`, then `ConfirmRestore` or cancel;
`src/tui/events/executor.rs` binds the preview to its worker generation.
Facade methods are `Application::preview_restore` and `confirm_restore`.
- **Rule owners:** `app/restore.rs` validates the archive, rebuilds internal
reservations and coordinates confirmed replacement; `backup.rs` owns archive
validation/encoding; `persistence.rs` owns safe installation and recovery.
The selected archive's settings, configuration and vault remain authoritative
for restored saved state.
- **Effect boundary:** preview and cancel are read-only. Confirmation requires a
fresh precautionary backup, then replaces saved files/vault as a whole. It
resets current apply knowledge to Unknown and does not call the running VPN or
request a vault password.
- **Failure proof:** `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`,
`app::restore_accepts_changed_non_path_settings_and_rejects_changed_paths`,
`app::restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`,
`commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`,
`events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`.
## Current concepts and migration map
References use current repository-relative module paths. The M19.1 map is
resolved below against the integrated M20–M26 implementation. Test names point
to existing behavioral coverage; this documentation update does not claim those
tests were rerun.
### Application session fields and workflows
| Current type / field / function | Business meaning | Authority | Current owner | Readers / writers | Existing proof | Resolution |
| --- | --- | --- | --- | --- | --- | --- |
| `Application::store` (`src/app.rs`) | Saved-file boundary, configured paths, validation and install capability | `SavedStore` paths and installed files | Application facade; scenario owners in `app/commit.rs`, `app/restore.rs`, `app/backups.rs`, `app/restart.rs` | Read by application scenarios; saved files change only through saved-operation owners | `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`; persistence `complete_install_publishes_components_with_private_modes` | Resolved M23/M25 |
| `Application::saved` (`src/app.rs`) | Exact installed generation held by this session | `SavedBytes` read from `SavedStore` | Application session; bound contexts in `app/restore.rs` and `app/restart.rs` | Open/read, Commit, restore, verified bookkeeping, restart preview | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it` | Resolved M23/M25 |
| `Application::draft` (`src/app.rs`, `src/app/draft.rs`) and its document | Coordinated in-memory document, client projection, vault, and draft-only reservations | Authoritative server document plus encrypted vault; `ClientStates` is a restricted projection | `app/draft.rs` stages and publishes coordinated replacements; `wireguard/edit.rs` owns byte-preserving edits | Draft operations prepare replacements; Commit serializes the staged values | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `config_preservation::edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed` | Resolved M22 |
| `Application::states` (`src/domain.rs`) | Narrow draft/saved management snapshots, current apply knowledge, and address reservations | Document projection for manageable clients plus persisted internal state | Domain `ClientStates` and `AddressReservations`; persistence only encodes/decodes durable fields | App draft/Commit/restart/reopen; read model exposes secret-free facts | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `persistence::persisted_history_never_marks_the_runtime_verified` | Resolved M20–M22 |
| `Application::draft.vault` (`src/app/draft.rs`) | Encrypted vault draft used for secret encryption, import, deletion, and reissue | Installed vault bytes at open, then current app draft | `app/draft.rs` coordinates; cryptographic/value operations stay in `vault.rs` and `vault/import.rs` | App setup/import/create/delete/Commit/reissue | `app::selected_file_import_uses_the_existing_vault_draft_without_saving`; `app::deletion_only_removes_selected_current_record_and_retains_other_records`; `vault::encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password` | Resolved M22/M24 |
| Pending exact-host reservations (`ClientStates::address_reservations`, `src/domain.rs`) | Draft-only exact host addresses retained when a saved peer cannot project to `Client` | Saved document peer routes and pending deletion | Domain `AddressReservations`, coordinated by `app/draft.rs` | Delete adds; Commit persists/promotes; discard clears | `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` | Resolved M21/M22 |
| `historical_apply` | Last successfully persisted verification timestamp/status, for archive/history display | `PersistedState.last_verified_at_utc` and archive manifest | Persistence codec owns representation; application read model presents it | Open/backup/restore/restart bookkeeping | `persistence::persisted_history_never_marks_the_runtime_verified`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied`; `app::historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen` | Resolved M21/M23/M26 |
| `verified_saved_projection` | Saved generation verified by the current session, used for delivery activation warning | Successful full runtime comparison bound to saved bytes | Application session/restart scenario; read-only view exposes only safe status | Restart publication writes; delivery warning reads; edits/restore invalidate as appropriate | `app::first_issue_warning_tracks_verified_saved_generation_for_file_and_qr`; `app::historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen` | Resolved M21/M24–M26 |
| `active_restart_attempt`, `next_restart_attempt` | One-use authorization that binds verification publication to the confirmed attempt and saved generation | Application-created attempt ID plus saved-byte equality | Private `app/restart.rs` scenario context | Preview/confirm/restart lifecycle; verified persistence consumes it | `app::restart_confirmation_rejects_a_generation_changed_after_preview`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` | Resolved M20/M25 |
| `FirstDeliverySession` (`src/app/first_delivery.rs`) | Creation-session-only first-delivery eligibility, one-use authorization and retry state | Generated key held in memory, committed peer directives and encrypted record binding | `app/first_delivery.rs` owns session capability state; `app/first_file.rs` runs first-file output; vault owns ciphertext/crypto and delivery adapters own publication | Coordinated creation registers; Commit promotes; file delivery consumes or updates; delete/discard/invalidation removes | `app::first_issue_authorization_is_single_flight_and_success_consumes_it`; `app::deletion_and_discard_revoke_only_session_bound_initial_material`; `app::uncertain_first_file_publication_retains_warning_and_skips_qr` | Resolved M20/M22/M24 |
| `open_from_saved_store`, `open` (`src/app.rs`) | Construct a session from installed saved bytes; injected-state `open` exists for fixture/state-seeding callers. Reopen is Unknown regardless of history. | Installed files/document; persisted reservations/history | Application session constructors | Production startup uses `open_from_saved_store`; `open` is still called by library unit and integration fixtures | `startup::management_session_loads_supported_state_and_keeps_unsupported_peers_visible`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` | Resolved M22; retained fixture API, see caller audit |
| `prepare_new_client`, `add_client`, `rename_client`, `delete_client`, `discard_draft` (`src/app.rs`) | Stage client/document/vault changes without saved-file or runtime effects; `add_client` is a legacy fixture helper and does not issue a key/vault secret | Document plus restricted client projection and vault | `app/draft.rs`, application facade and domain/editor/vault owners; no separate `app/clients.rs` was needed | TUI uses prepared create/rename/delete/discard; test fixtures still call `add_client` | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `client_delivery::committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent`; `client_delivery::deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password` | Resolved M22; retained fixture helper, see caller audit |
| `commit`, `preview_restore`, `confirm_restore` (`src/app.rs`) | Commit atomically installs staged saved bytes after backup; restore is a preview-bound full saved-state replacement after backup | App draft for Commit; validated archive payload for restore | `app/commit.rs`, `app/restore.rs`; low-level formats/install remain in `backup.rs`/`persistence.rs` | TUI executor calls facade; store installs only with `BackupComplete` | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes` | Resolved M23 |
| `preview_restart`, `restart`, verified-result publication (`src/app.rs`, `src/app/restart.rs`) | Confirmed restart scenario: preconditions, backup, service job completion, runtime comparison, durable status then publish applied knowledge | Saved generation + complete `SavedPeerProjection` + runtime observation | `app/restart.rs`; service submission in `service.rs`; comparison in `runtime.rs`; durable fields in persistence | TUI previews/confirms; fixture apply callback is `#[cfg(test)]`; success updates state only after durable write | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::service_timeout_reports_submission_and_may_still_complete`; `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified` | Resolved M20/M21/M25 |
### Domain state and transitions
| Current type / field / operation | Business meaning | Authority | Current owner | Readers / writers | Existing proof | Resolution |
| --- | --- | --- | --- | --- | --- | --- |
| `Client` / `ClientAddresses` / `Snapshot` (`src/domain.rs`) | Public, manageable projection keyed by public key; aliases may repeat, IPv6 may be absent for legacy clients | Public key and supported host-address projection from document | Domain value types in `domain.rs`; no child module split was needed | App operations and read model; draft/saved snapshot transitions | `domain::aliases_may_repeat_but_control_characters_are_rejected`; `domain::existing_ipv4_only_clients_remain_ipv4_only`; `wireguard::wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes` | Resolved M20/M21 |
| `ClientStates::draft` / `saved` (`src/domain.rs`) | Manageable-client snapshots for staged and committed state, not all server peers | Document is source; snapshots are derived | Domain client states, mutated only through narrow transitions | Application edits draft and Commit advances saved; TUI consumes read-model facts | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes` | Resolved M21/M22 |
| `applied_snapshot` / `ApplyStatus` | Optional management snapshot last verified plus current knowledge; may be `None` while full technical verification is Verified for valid unmanageable peers | Current verified runtime comparison; historical timestamp is separate | Domain apply knowledge; technical evidence remains runtime-owned | Restart publication changes; reopen resets current knowledge; read model maps current knowledge | `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` | Resolved M20/M21 |
| `reserved_addresses` and `restore_persisted_reservations` (`src/domain.rs`) | Persisted conservative addresses that cannot be allocated | Prior saved peer routes and internal state | `domain.rs` owns reservation value/rules; `persistence.rs` only encodes/decodes | Allocator reads; Commit/reopen/restart update; restart success may release | `domain::a_draft_only_deletion_does_not_create_a_committed_reservation`; `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `persistence::state_codec_is_canonical_and_rejects_bad_or_unsupported_state` | Resolved M21 |
| Narrow `stage_client`, `remove_draft_client`, `commit_draft`, `discard_draft` (`src/domain.rs`) | Mutate the manageable draft projection through scoped operations and preserve saved/applied separation | `ClientStates` snapshots, ultimately document-derived | Application draft coordinator calls crate-private stage/remove and public commit/discard transitions | No unrestricted `draft_mut` accessor remains; direct mutation in domain tests is limited to transitions | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::discard_restores_draft_without_writing_installed_bytes`; `app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it` | Resolved M21/M22; escape hatch removed |
| Verified apply, technically verified apply, runtime unknown/unavailable transitions (`src/domain.rs`) | Change present apply knowledge only after verified result publication; preserve current uncertainty and optional management projection | Complete runtime comparison plus successful persisted bookkeeping | Domain transitions are crate-private and called by the application restart scenario | App restart success/failure/reopen; statistics reads cannot call transitions | `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`; `tui::statistics_refresh_is_generation_bound_and_never_changes_applied_state` | Resolved M20/M21 |
| `allocate_ipv4`, `allocate_ipv6`, `AddressAvailability` (`src/domain.rs`) | Pure address allocation against server, occupied and reserved sets | Validated settings and document-derived address sets | Domain address allocator and reservations | App create reads | `domain::ipv4_allocation_skips_server_occupied_reserved_network_and_broadcast`; `domain::ipv6_allocates_first_free_address_from_a_very_large_range`; `tui_workflow::create_form_submits_automatic_dual_stack_client_without_changing_saved_state` | Resolved M21 |
### Peer and runtime projections
| Current projection | Business meaning / authority | Current owner and consumers | Existing proof | Resolution |
| --- | --- | --- | --- | --- |
| `PeerInspection`, `PeerIssue`, `DocumentIssue`, `ConfigDocument` (`src/wireguard/document.rs`) | Every authoritative server peer section, byte spans, parsed routes and anomalies. Identity can be absent or duplicated. Malformed/no-key peers remain inspectable by document position. | `wireguard/document.rs`; app editing/deletion/restart and application read model consume it | `wireguard::wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields`; `tui::malformed_peer_can_be_inspected_by_document_location_without_mutation_identity`; `tui::duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable` | Resolved M22/M25/M26 |
| `ClientDisplay`, `SessionDisplay`, `ApplicationView` (`src/app/read_model.rs`) | Secret-free current draft-document rows and authoritative per-action eligibility; mutable identity only for an unambiguous manageable peer | App-owned read model; `Application` facade exposes it | `tui::enter_opens_an_unsupported_peer_for_inspection_without_mutation_identity`; `tui::issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Resolved M26 |
| `SessionView`, `ClientStateRow`, `VisibleRecord` (`src/tui/state.rs`) | Maps `ApplicationView` into UI navigation state; keeps generation, selection, screens and rows, without reparsing saved bytes or constructing authority | TUI state consumes app facts and owns UI selection/navigation | `tui_workflow::saved_peer_projection_counts_legacy_peers_without_inventing_managed_state`; `tui::keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them`; `tui::refresh_preserves_selection_by_public_key_not_alias_or_row_position` | Resolved M26 |
| Renderer-derived labels and issue text (`src/tui/render.rs`) | Styles and renders app facts; no operation eligibility or peer authority is inferred from labels | Renderer owns visual strings, styling, wrapping, geometry and layout | `tui::persistent_details_follow_the_highlight_and_keep_geometry_fixed`; `tui::client_rows_show_draft_state_and_escape_anomalous_unsupported_peers`; `tui::issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Resolved M26 |
| `SavedPeerProjection`, `RuntimeObservation`, `TechnicalMatch` (`src/runtime.rs`) | Complete technical route set independent of manageable `Client`; valid subnet-route peers can be restart-verified without UI mutability. Handshakes/counters are not apply evidence. | Runtime owns projection/comparison and read-only transport; app restart scenario coordinates; read model exposes only safe status/findings | `runtime::projection_keeps_valid_unsupported_routes_and_ipv4_only_peers`; `runtime::counters_and_handshake_changes_do_not_change_technical_match`; `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot` | Resolved M20/M25/M26 |
### Authorization, prepared contexts and worker generations
| Current context | Meaning and authority | Current owner | Readers / writers | Existing proof | Resolution |
| --- | --- | --- | --- | --- | --- |
| UI selection guard `UiState::selected_client_mutable` (`src/tui/state.rs`) | Presentation-level guard requires one matching row, matching document position if selected, and a mutable identity; cannot grant operation authority | TUI selection state only; application owns typed per-action eligibility and execution revalidation | Terminal key handlers and footer hints read it; app methods/editors independently reject invalid actions | `tui::selection_uses_peer_position_for_duplicate_keys_and_disables_mutation`; `tui::removed_saved_client_is_visible_and_never_mutable` | Resolved M26 |
| Per-action UI prerequisites (`src/tui/terminal.rs`, `src/app/read_model.rs`) | Selection is presentation-only; rename/delete/delivery/reissue availability comes from typed app eligibility, and each operation revalidates on execution | App read model owns availability facts; terminal maps them to actions and hints | `tui::state` binds selected row; app methods and config editor validate mutation | `tui::detail_hints_only_offer_saved_delivery_for_mutable_saved_selection`; `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` | Resolved M26 |
| First-issue session context (`src/app/first_delivery.rs`, `src/app/first_file.rs`) | Session-local secret and one-use authorization bound to identity, peer directives and encrypted record; retry state reflects publication/cleanup uncertainty | `app/first_delivery.rs` owns session lifecycle; `app/first_file.rs` owns first-file scenario; adapters own crypto and publication | Create registers; Commit enables; delivery validates/consumes; edits/delete/discard invalidate | `app::committed_first_issue_publishes_selected_file_and_consumes_eligibility`; `app::published_file_is_reported_when_saved_store_bookkeeping_fails`; `app::failed_first_delivery_requires_consuming_capability_before_retry`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry` | Resolved M20/M24 |
| `ApplicationExecutor` prepared destination and restore preview (`src/tui/events/executor.rs`) | Worker-owned continuation state bound to target, delivery mode/QR choice, attempt, preview and request generation | Private named contexts in the executor child | Executor command handling; clears on unrelated actions, consumes on confirmation/cancel | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `client_delivery::overwrite_confirmation_is_bound_to_the_selected_destination`; TUI workflow restore cancellation/confirmation | Resolved M20; both contexts use named structs |
| `PendingOperation`, `SessionView.generation`, worker generation (`src/tui/events.rs`, `src/tui/state.rs`) | Monotonic identity for submitted work and returned view; stale view/statistics cannot overwrite current UI state | TUI worker/execution adapter only; not domain state | Mutating submit increments; worker tags result; UI accepts matching generations; periodic statistics rechecks generation across read | `events::delayed_statistics_are_bounded_and_do_not_advance_generation`; `events::statistics_started_before_a_mutation_are_discarded_as_stale`; `tui::stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable` | Resolved M20/M26 |
| `FormController`, `SecretInput`, `SecretText`, `SensitiveCommand` (`src/tui/forms.rs`, `src/tui/events.rs`) | Short-lived form state and zeroizing password command; secrets excluded from view/debug/clone | Forms own input; worker carries secret command; application consumes only during vault operation | Form submits sensitive command; worker invokes setup/reissue; form clears on cancel/consumption | `tui::compact_form_hints_follow_the_active_form_without_leaking_input`; `vault::every_retrieval_requires_the_correct_password`; `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen` | Resolved M24/M26; lifetimes preserved |
### Public API escape-hatch callers and removal conditions
| API | Actual callers | Safe path and timing |
| --- | --- | --- |
| `ClientStates::draft_mut` | No current declaration or callers. Former unrestricted accessor was replaced by crate-private `stage_client`/`remove_draft_client` and narrow transitions in `src/domain.rs`; full `rg` audit finds no production, test, startup, binary, integration or container-driver reference. | Removed in M22 after caller audit; no replacement is needed by callers. [M22 evidence](../evidence/milestone-22.md) records the review. |
| `Application::vault_draft_mut` | No current declaration or callers. The public mutation bypass was removed and no test-only API replaced it. | Removed in M22; vault state is mutated through coordinated application operations. [M22 evidence](../evidence/milestone-22.md) records the review. |
| `Application::add_client` (`src/app.rs`) | Unit-test setup in `src/app.rs`; integration-fixture setup in `tests/commit_restore.rs` and `tests/runtime_restart.rs`. No binary, startup, production module, `tests/support`, or `src/container_driver.rs` caller. `ConfigDocument::add_client` remains the actual byte-preserving editor used by `prepare_new_client` and preservation tests. | Retained as a public compatibility/test-seeding operation because current integration fixtures still use it. It does not generate/encrypt a private key and must not be mistaken for the normal create workflow. Removal requires deliberate fixture migration. |
| `Application::open(store, states)` (`src/app.rs`) | Unit tests and fixture helpers in `tests/client_delivery.rs`, `tests/commit_restore.rs`, `tests/runtime_restart.rs`, and `tests/tui_workflow.rs`. No binary/startup or production caller; no container-driver caller. | Retained for tests that seed `ClientStates` and apply knowledge. Production startup uses `open_from_saved_store`; fixture construction is the only current consumer class. |
| `Application::open_from_saved_store` (`src/app.rs`) | Binary startup through `startup::open_management_session`; application/TUI/integration tests; `container_driver::open_fixture_application` in `src/container_driver.rs`, which is included only under `#[cfg(test)]` in `src/lib.rs`. | Required production and fixture entry point; retain. Container driver normal tests are synthetic; its ignored Alpine scenario is opt-in and is not a systemd acceptance test. |
| `Application::display_projection` (`src/app.rs`) | No production-module, binary, or actual startup-path call. `startup::tests::management_session_loads_supported_state_and_keeps_unsupported_peers_visible` calls it after exercising session startup. There are no `tests/` integration-test calls. The only other direct callers are in `src/container_driver.rs`'s `#[cfg(test)]` synthetic lifecycle test `phase_protocol_keeps_creation_commit_and_first_delivery_in_one_app_session`; the ignored Alpine scenario does not call it. Current TUI presentation uses `Application::read_model`. | Retain for now as a public compatibility projection: the startup unit and synthetic fixture tests still exercise its inclusion of unsupported peers. Its compatibility value and test coverage do not make it a production authorization/read path. Removal requires deliberate migration of those callers and an API compatibility decision; do not infer it is obsolete solely because M26 moved TUI reads to `read_model`. |
| Former public `Application::create_pre_restart_backup` (`src/app.rs`) | Direct callers are confined to colocated app unit tests: helper `test_mark_verified`, `manual_and_pre_restart_backups_are_listed_without_runtime_calls`, and `failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified`. No binary, integration-test, or container-driver caller. | Restricted to a private `#[cfg(test)]` method because only fixtures use it. Production restart performs its mandatory backup within `app/restart.rs`; no production API exception remains. |
## Transition tables
These tables preserve observed behavior. “No mutation” below means no protected
saved-file or runtime mutation; drafts may remain in memory where specified.
### Draft lifecycle
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Open session | Saved settings/config validate; optional state/vault parse | Draft begins from saved document and client snapshot; current apply knowledge is Unknown | Read installed bytes, restore conservative reservations, load history separately | Invalid saved state rejects open; history never promotes current apply knowledge | `startup::management_session_loads_supported_state_and_keeps_unsupported_peers_visible`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` |
| Create client | Vault exists; alias/address inputs valid; configured ranges available | Document/client/vault drafts all contain new peer/record; first-issue material registers only after staging succeeds | Generate key, allocate/check dual stack, encrypt record, write no files | Any preparation failure leaves all live draft components and issue rights unchanged | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `app::manual_addresses_reject_occupied_and_out_of_range_without_partial_staging` |
| Rename or delete | Target identity resolves to an authoritative unambiguous peer; deletion of unprojectable peer can reserve only exact host routes | Corresponding document and manageable snapshot edit; delete removes its vault record if present | Mutate in-memory drafts; retain exact unsupported routes pending Commit; invalidate relevant issue eligibility | Malformed/ambiguous/unsafe broad-route edit fails without partial draft change | `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change`; `app::deletion_only_removes_selected_current_record_and_retains_other_records` |
| Discard draft | Session can reread saved bytes | Draft returns to installed saved state | Reparse installed document/vault, restore persisted reservations; retain only still-valid awaiting-first-file issue material | Read/parse failure returns error; no installed writes; already-consumed delivery is not recreated | `app::discard_restores_draft_without_writing_installed_bytes`; `app::deletion_and_discard_revoke_only_session_bound_initial_material` |
| Commit | Draft validates; mandatory pre-Commit archive succeeds | Saved state advances to installed draft only after successful recoverable install | Persist document, encrypted vault and internal reservations; no runtime call; apply knowledge becomes unknown when saved differs | Backup failure stops before protected writes; partial install reports failure/recovery; draft remains retryable | `app::backup_failure_preserves_installed_files_and_draft`; `app::injected_archive_and_install_failures_preserve_bytes_and_session_saved_state`; `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes` |
| Restart with draft | Valid saved-state restart preview; explicit choice | Cancel keeps draft; discard choice resets it before restart sequence | Separate restart flow; Commit never applies | Cancel makes no backup/job/runtime call. Once service request is submitted, cancellation/timeout cannot assert that the job did not finish | `app::restart_cancel_preserves_draft_and_never_calls_adapters`; `app::service_timeout_reports_submission_and_may_still_complete` |
### Address reservations
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Delete saved manageable client in draft | Target's addresses known | No committed reservation yet; addresses remain occupied by saved snapshot | Draft deletion only | Discard restores client and no pending reservation is published | `domain::a_draft_only_deletion_does_not_create_a_committed_reservation` |
| Delete saved unprojectable peer in draft | Every route to be retained is an exact IPv4 `/32` or IPv6 `/128` host route | Exact routes enter draft-only reservation set | Prevent allocation while deletion is staged | Broad/subnet route or missing address evidence rejects deletion without draft mutation | `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` |
| Commit deletion | Mandatory backup and saved install succeed | Deleted saved addresses enter persisted reservation set; committed client snapshot advances | Allocator excludes current draft/saved and reserved addresses | Backup/install failure does not publish release or new saved state | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::state_file_round_trip_keeps_pending_addresses` |
| Restore archive removing current peers | Preview is valid and confirmation accepted; pre-restore backup succeeds | Rebuilt internal state carries archived/current persisted reservations, addresses from current manageable clients, and exact host routes from unsupported current peers; reservations are excluded for addresses still assigned by restored peers | Full saved replacement only; runtime is untouched | Cancel leaves saved state unchanged; backup/install failure leaves recoverable prior state and drafts | `commit_restore::restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes`; `app::restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `app::partial_restore_failure_preserves_session_and_pre_restore_backup_then_reopens_to_old_set` |
| Verified restart publication | Confirmed attempt still matches installed generation; completed service job; complete interface/peer match; internal-state install succeeds | Reservations for removed clients clear | Persist verified timestamp/state, then publish current applied knowledge | Mismatch/read/service/timeout/persistence failure retains reservations and conservative apply knowledge | `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` |
### Current and historical verification
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Open/reopen | Saved configuration and internal state parse | Current state Unknown; optional management applied snapshot absent; historical timestamp remains separately available | Restore reservations from persisted metadata only | Invalid internal state rejects session; historical success is not live proof | `persistence::persisted_history_never_marks_the_runtime_verified`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` |
| Periodic statistics read | Worker has an interface name; bounded read returns observation | UI statistics update only | No domain apply transition, no saved write, no reservation release | Unavailable/unknown data remains so; stale-generation result is discarded | `tui::events::statistics_started_before_a_mutation_are_discarded_as_stale`; `tui::state::statistics_refresh_is_generation_bound_and_never_changes_applied_state` |
| Saved state changes | Commit/restore changes installed generation | Current apply knowledge becomes Unknown; old historical timestamp may remain or be cleared according to existing successful restore bookkeeping | No runtime operation | Failed write leaves in-memory published state conservative | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it` |
| Restart comparison matches complete saved technical peer set | Interface identity and complete peer dump match; malformed/duplicate/unverifiable records absent | Still not published until durable verification bookkeeping succeeds | Prepare persisted reservations/timestamp update | Persistence failure leaves in-memory apply knowledge/reservations unchanged | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified` |
| Full technical match with peer outside management projection | Peer record is valid/verifiable even if not a `Client`, e.g. subnet routes | Knowledge may be Verified with no complete `applied_snapshot` | Persist verified result; do not invent partial management snapshot | Any incomplete runtime dump still cannot pass | `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot`; `runtime::projection_keeps_valid_unsupported_routes_and_ipv4_only_peers` |
### First delivery and password reissue
| Trigger / stage | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Create before Commit | Successful coordinated create in this session | Pending draft issue | Hold generated private key in session and bind peer directives/ciphertext | Any failed create publishes no new issue authorization | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue` |
| Commit succeeds | Created peer and encrypted record appear in installed state and match session context | Awaiting first file | Promote one-use capability for current session | Commit failure keeps retryable draft capability but does not permit output | `app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it` |
| Prepare destination | Eligible committed issue or saved reissue selected; output path validated | Prepared destination is bound to identity, mode, QR request, attempt and generation | No key disclosure or file write yet | Unrelated command/cancel invalidates the prepared context; stale generation requires reselection | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `client_delivery::overwrite_confirmation_is_bound_to_the_selected_destination` |
| Confirm first file | Same session, context still matches saved record, overwrite confirmation if required | File is published; first issue is consumed only after saved bookkeeping | Secure file publication; optional QR derives from the same payload and happens after mandatory file | Before-publication failure can retry; uncertain publication requires overwrite confirmation; cleanup failure requires manual cleanup; QR failure does not undo file | `app::failed_file_publication_can_retry_in_a_different_directory`; `app::uncertain_first_file_publication_retains_warning_and_skips_qr`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry`; `app::file_and_qr_share_payload_and_qr_failure_still_consumes_first_issue` |
| Saved bookkeeping after file | File publication succeeded | Consumed if bookkeeping succeeds; otherwise explicit published-but-uncertain result, no automatic retry capability | Report file exists; preserve activation warning | Do not misreport as failed/unpublished or auto-delete file | `app::published_file_is_reported_when_saved_store_bookkeeping_fails` |
| Reissue after session/reopen | Selected client is in saved state; password supplied for this action | No first-issue session eligibility is involved | Unlock matching saved vault record, render/write selected client file; optional QR | Wrong password/record mismatch/file failure is reported without secret leakage; each client requires fresh password | `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen`; `vault::every_retrieval_requires_the_correct_password`; `client_delivery::imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue` |
### Restore preview and confirmation
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Select archive for preview | Safe archive name and valid archive format | Opaque preview holds validated bytes plus metadata; UI receives redacted summary/change list and generation | Read-only archive validation; no password and no runtime call | Invalid/traversal archive rejected; cancel drops preview | `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `commit_restore::archive_parser_rejects_traversal_links_duplicates_and_oversized_members` |
| Confirm matching preview | Preview token still belongs to the operation generation; installation paths remain compatible | Selected archive replaces saved settings, server configuration and vault as a whole after mandatory pre-restore backup; running VPN remains unchanged | Rebuilt internal state carries archived and current persisted reservations, addresses of current manageable clients and unsupported peers' exact host routes that the restore removes; addresses still assigned by the restored configuration are excluded. Draft-only exact routes are included. The rebuilt state has no historical verification timestamp; current apply knowledge becomes Unknown. No restart occurs. The pre-restore backup retains the prior saved bytes and history. | Stale/cancelled preview cannot write; backup/install failure preserves prior saved state or requires journal recovery | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `app::restore_accepts_changed_non_path_settings_and_rejects_changed_paths`; `commit_restore::restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes`; app `restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes` |
| Cancel preview/confirmation | No restore install has begun | Existing saved state and draft remain | Clear app-executor preview context | No backup/install/runtime side effect | `tui_workflow::worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operations`; app preview read-only test above |
### Restart attempt identity and uncertainty
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Preview restart | Installed bytes equal session saved generation; interface/service settings and complete saved peer projection are safe | Read-only preview captures saved bytes/settings/projection and draft presence | No backup/job/runtime operation | Unsafe or ambiguous peer state blocks before backup; stale saved bytes reject confirmation | `app::unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location`; `app::restart_confirmation_rejects_a_generation_changed_after_preview` |
| Cancel choice | Preview still valid | Cancelled report; draft remains | No backup, service request, runtime read, or persistence | No operation was submitted | `app::restart_cancel_preserves_draft_and_never_calls_adapters` |
| Confirm restart | Preview still current; explicit choice; backup succeeds | Attempt identity bound to saved generation, then service job submitted | Optional draft discard at specified safe point; complete backup; one shared deadline | Backup failure submits no job; before submission cancellation is definite | `app::restart_backup_failure_preserves_draft_and_submits_no_job`; `app::confirmed_restart_orders_backup_job_read_and_durable_publication` |
| Service job submitted | Request accepted | Await job completion under same deadline | Job may still complete even if caller times out/disconnects | Timeout report retains submitted/may-still-complete distinction; never claim cancellation or rollback | `app::service_timeout_reports_submission_and_may_still_complete`; `app::submitted_terminal_job_failures_cannot_still_complete` |
| Job complete, observe and compare | Job successful; remaining shared deadline; expected configured interface and complete runtime peer set | Matching result becomes candidate for durable verification | Bounded observation and pure comparison; no direct state publish yet | Read error/timeout/missing interface/incomplete dump/mismatch stops publication and retains reservations | `app::shared_deadline_expired_after_successful_job_stops_before_runtime_read`; `app::shared_deadline_expired_after_runtime_read_reports_timeout_before_comparison`; `runtime::partial_duplicate_and_unavailable_records_never_pass` |
| Durable verification succeeds | Attempt token and both current/attempt saved bytes still equal; full technical match; internal state install succeeds | Publish current verified knowledge and clear retained addresses | Persist timestamp/reservations first; update in-memory session after successful install | Failed persistence leaves conservative state; no second restart or automatic rollback | `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` |
## M20–M26 migration disposition
This register resolves the destinations proposed by M19 against the integrated
source. The milestone labels are historical; the owner column names the current
code. Public facade calls remain where the caller audit below documents an
active integration-fixture dependency. Existing low-level adapters and
persisted formats remain in place.
| Milestone | Production surface and destination | Compatibility / preserved boundary | Existing baseline and exact focused commands |
| --- | --- | --- | --- |
| M20 — vocabulary and named contexts | Domain/apply vocabulary is in `src/domain.rs`; observations/comparison in `src/runtime.rs`; presentation mapping in `src/tui/state.rs`; `PreparedDestinationContext` and `RestorePreviewContext` are private named structs in `src/tui/events/executor.rs`. | Semantics remain unchanged; no domain child split was justified. | Historical focused checks recorded in M20 evidence; current generation-bound proof is `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`. |
| M21 — reservations and apply knowledge | `src/domain.rs` owns `AddressReservations`, allocation and narrow apply transitions; `src/persistence.rs` encodes durable state; `src/runtime.rs` compares technical peer state; `src/app/restart.rs` coordinates verified publication. | No `domain/reservations.rs` or `domain/apply.rs` split was warranted; unrestricted draft mutation was removed. Persisted schema and semantics remain in persistence. | `domain::exact_host_routes_stay_pending_until_commit_and_discard_drops_them`; `persistence::persisted_history_never_marks_the_runtime_verified`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`. |
| M22 — coordinated draft and create/edit/import | `src/app/draft.rs` coordinates document/client/vault/reservation drafts; `src/app.rs` remains the facade; `src/vault/source.rs` reads sources and `src/vault/import.rs` reconciles/encrypts. | No separate `app/clients.rs` or `app/import.rs` was justified. `Application::open`/`add_client` remain for injected-state and fixture setup, documented in the caller audit. | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; config preservation, delivery, import and workflow integration suites. |
| M23 — saved Commit, backups and restore | `src/app/commit.rs`, `src/app/restore.rs`, and `src/app/backups.rs` own scenarios; `src/persistence.rs` owns journaling/path safety and `src/backup.rs` owns archive encoding/parsing. | Preview is byte-bound and saved-only. M23 added restoration of exact routes from unsupported current peers; this closed the earlier noted reservation gap. | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`. |
| M24 — delivery scenarios | `src/app/first_delivery.rs` owns first-issue capability/retry state; `src/app/first_file.rs` runs first-file delivery; `src/app/reissue.rs` runs password reissue. `src/delivery.rs`, `src/delivery/files.rs`, `src/delivery/qr.rs`, and `src/vault.rs` are focused adapters/value owners. | No `app/delivery.rs` was created because session capability, file delivery and saved-record reissue have separate cohesive ownership. | `app::failed_file_publication_can_retry_in_a_different_directory`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry`; `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen`. |
| M25 — restart scenario | `src/app/restart.rs` owns preview, attempt token, backup/job/read/compare/durable-publication order; `src/runtime.rs` owns complete peer projection/comparison; `src/service.rs` owns systemd adapter; persistence owns durable state. | Fixture apply is compiled only under `#[cfg(test)]`. No host service calls occur in ordinary fixture tests. | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::service_timeout_reports_submission_and_may_still_complete`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`. |
| M26 — read model and TUI command adapter | `src/app/read_model.rs` owns secret-free peer/state/issue/reservation/apply facts and per-action eligibility. `src/tui/state.rs` owns UI navigation/generation; `src/tui/events.rs` and `src/tui/events/executor.rs` own worker lifecycle and command adaptation; forms own transient inputs; renderers own visual output. | TUI does not parse saved bytes or duplicate business authorization. Split of broader terminal/render modules was deferred; their shared lifecycle/layout helpers did not justify a larger move. | `tui::`; `tui_workflow`; `client_delivery`; M26 evidence records focused/full host results and limits. |
### Test inventory and verification notes
- There is no `tests/client_lifecycle.rs` target. Lifecycle coverage is
distributed across `app::`, `client_delivery`, `commit_restore`,
`vault_import`, and `tui_workflow`; the M22–M26 work used existing
targets, and a new target is needed only if acceptance finds a real behavioral
gap.
- Application projection and eligibility are covered by colocated `app::`
tests plus `tui::` and `tui_workflow::` tests; no separate integration target
was needed.
- Restore reservation coverage includes manageable saved clients, exact routes
from unprojectable current peers, persisted reservations, and exact routes
retained by the restored document. `app/restore.rs` excludes routes still
owned by restored peers. The previous M19/M23 gap note is superseded by
`restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`.
- `ClientStates::draft_mut` and `Application::vault_draft_mut` no longer exist.
`Application::add_client` and injected-state `Application::open` remain
public because integration fixtures use them; the caller audit above records
those exceptions. They are not the production create/startup path.
- Existing test-only container driver is `src/container_driver.rs` included only
by `src/lib.rs` under `#[cfg(test)]`. Its normal fixture tests are synthetic;
ignored `container_driver::alpine_scenario_driver` is opt-in and is not an
ordinary host test or a systemd acceptance test. Do not run it as part of
this documentation task.
- M19.2 and the subsequent migrations use only approved dependencies already
present. No dependency, manifest, lockfile, configuration, or product behavior
change is implied.
+78
View File
@@ -0,0 +1,78 @@
# Milestone 8 diagnostic record
This is a development investigation record for disposable Alpine fixtures. It
contains no production observations or secret fixture payloads. Current task
state and acceptance remain in [PLANS.md](../../PLANS.md).
## M8.5 restore fixture reservation mismatch
- Recorded: 2026-10-24T23:04:57+03:00.
- Symptom: the second password-free restore confirmed through `Application`,
but its test driver failed a saved-state assertion. The first restore,
explicit fixture Apply and renewed IPv4 traffic had already passed.
- Bounded reproduction: pinned musl driver built with
`cargo test --locked --offline --lib --no-run --target x86_64-unknown-linux-musl`
in the approved Rust Alpine builder; selected-daemon ignored test invoked as
`cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1`
with the explicitly selected local Docker daemon and musl driver path.
- Discriminating observations: fixed redacted phase markers reached
`application_confirmed` and `precautionary_contents_verified`. Settings,
server bytes, vault bytes and target-client presence matched. The actual
reservation count was two, the fixture expected one, and both contained the
target IPv4 address.
- Cause: the fixture expected only the target IPv4 reservation when restoring
an archive that removed a saved client with more than one address. Application
retained both saved addresses as required while the live peer remained.
- Repair: derive the exact expected reservation set from every address on the
removed saved client, preserve prior reservations, and filter addresses still
saved in the restored set. Keep the IPv4 target membership assertion. This is
saved-state bookkeeping; no IPv6 capability or traffic test was run.
- Verification: focused reservation-helper regression passed; rebuilt pinned
musl driver; selected-daemon scenario passed 1/1 in 72.66s, including both
restores and explicit applies. Exact-name test containers and network were
absent afterward. Independent code review found no implementation defect;
its plan-status finding closed on re-review. Final integrated gates remain.
Earlier failed diagnostic runs and superseded status are preserved in
[the pre-cleanup record](../history/milestone-8-before-workflow-cleanup.md).
## M8.7 transient execution prerequisites
- Recorded: 2026-10-25T20:40:51+03:00. The first post-review opt-in run failed
before scenario startup because the sandbox denied Docker API access.
Running the selected-daemon command with approved sandbox escalation then
failed prerequisite inspection because the musl driver artifact under `/tmp`
was absent. Exact-name test container/network listings were empty.
- A pinned builder rebuild with an empty `/tmp` Cargo cache and `--offline`
failed dependency resolution for the already-approved `age` crate. The host's
cached public Cargo registry contained it. Mounting that cache read-only into
the pinned builder produced the same driver SHA256 as the prior passing run;
no dependency or source change was needed.
- Three subsequent selected-daemon runs passed with fresh mutable fixtures.
Their exact commands, source fingerprint, outcomes, and limits are recorded
in [the evidence report](../evidence/milestone-8.md). These initial
prerequisite failures are not counted as scenario passes.
## M8.5 failure fixture legacy-peer deletion
- Recorded: 2026-10-24T23:27:52+03:00.
- Symptom: the selected-daemon scenario passed real reader `NoDevice`/`Timeout`
and deterministic Commit backup-failure checks, then stopped before restart
backup-failure assertions.
- Bounded check: fixed secret-free driver markers localized the stop after
parsing the baseline client's identity and before deletion completion. The
selected-daemon command is the one recorded in
[the evidence report](../evidence/milestone-8.md), with the current pinned
musl driver rebuilt for each diagnostic revision.
- Cause: the fixture treated `Application::delete_client`'s Boolean as whether
the peer was removed. It reports whether an encrypted vault record was
removed. The synthetic legacy baseline peer had no such record, so `false`
was correct even though the draft peer was deleted.
- Repair: assert the baseline identity is absent from the draft after deletion
and report the vault-record outcome separately. A focused regression covers
deletion of a legacy peer with no vault record.
- Verification: focused regression, formatting and Clippy passed; source
fingerprint `656707eac2af28f2f3747687e73bb355c4df421663c142d4a7e91409763179b0`
remained stable through a pinned musl build and selected-daemon run. That
run passed 1/1 in 81.14s; exact-name containers and network were absent.
Independent review and final integrated gates are still pending.
+68
View File
@@ -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-<UUID>-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.
+49
View File
@@ -0,0 +1,49 @@
# Milestone 10 acceptance evidence
Run: 2026-10-26T21:07:24Z, local development workspace. This report covers the
styled TUI keyboard footer only. Milestone 9 remains retired and unaccepted.
## Source and environment
- HEAD: `e141c03c2c401b09ad0fa78557cb9403640ea80a`; working tree was dirty
with `PLANS.md`, `README.md`, `src/delivery/qr.rs`, and `src/tui/render.rs`.
The following SHA-256 values identify the relevant integrated inputs at the
gates: `render.rs` `40e2abc891cc562a628eb661dcdcd3e052818c4332c25f3ae555e184d9adb65d`;
`forms.rs` `8ecbd614c1ecc7f1ab4cbae9223f9820c5d8315c62a54cffbc6d5c2e640f6c31`;
`qr.rs` `b8d30e6b24370fd1e3d666586481af74b12f279043cb1e0a7a1e61923588c4b8`;
`README.md` `fa4ef797565895dbd78f920e92263df718720bdb84dba48da88e611020638741`;
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
- `rustc 1.97.1 (8bab26f4f 2026-07-14)` and `cargo 1.97.1
(c980f4866 2026-06-30)`. The release build used the locked local target;
no container, VM, production service or VPN was accessed.
## Checks and scope
| Exact command | Result |
| --- | --- |
| `cargo test --locked tui::` | Exit 0; 38 TUI unit tests passed. |
| `cargo test --locked --test tui_workflow` | Exit 0; 6 integration tests passed, including isolated PTY smoke. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0 after M10.3-R1 repair. |
| `cargo test --locked` | Exit 0; 271 library units passed, 2 opt-in container drivers ignored; all integration suites and 2 compile-fail doctests passed. The Testcontainers target reported 25 passed and 1 ignored; no opt-in Alpine run occurred. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
The TestBackend checks cover 120×30, 80×24, 40×8, 34×8 and below-minimum
rendering. They assert visible footer interior, styled key labels, applicable
80×24 list actions, compact navigation/Help and overflow, context-specific
forms/progress, and QR Esc guidance without fixture payload disclosure. These
are synthetic buffer checks; no physical terminal QR scan or systemd behavior
is claimed.
## Review and decision
Independent M10.1 review found R1/R2 context/form hint issues; repair and
re-review closed both on the integrated implementation. A separate fresh
integrated M10.3 review found R1 (Clippy failure) and R2 (README context
overstatement). The repairer fixed both; the same independent reviewer
re-reviewed the repaired state and reported no remaining actionable finding.
The reviewer supported M10-A1–A5 within synthetic/PTY scope; the coordinator's
full gates above satisfy M10-A6. Compact QR may show a too-large condition at
34×8 while retaining Esc clearing guidance. M10 acceptance is scoped to TUI
presentation and does not upgrade M9 systemd/IPv6 evidence.
+62
View File
@@ -0,0 +1,62 @@
# Milestone 11 acceptance evidence
Run: 2026-10-26T21:20:07Z, local development workspace. This report covers
presentation of valid but unsupported peer `AllowedIPs` shapes. It does not
establish runtime WireGuard, Alpine or systemd behavior.
## Source and environment
- HEAD `18f28eef02d6a9f94a1d34ad94e25079ebf125a2`; the working tree was
dirty with `PLANS.md`, `src/app.rs`, `src/tui/render.rs`,
`src/tui/terminal.rs`, and `tests/config_preservation.rs`. An external
workspace actor created HEAD during implementation; this team created no
commit. The integrated inputs at the gates have SHA-256 values:
`app.rs` `75935d694d85cad11511ffbcff093cc7ebded08f02eb26589cb552fdbe1c7720`;
`document.rs` `af4fed6c25fe4b801112d19fe4867ed809c6a05e68492c3a53cc4829ac3b766b`;
`render.rs` `2f673febaba9e6f0ada1c274f33c0db02fe78fd2bca50310b1215d5a22dd8c9f`;
`terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`;
`config_preservation.rs` `0cdac39ac6ac370aaa85ea13610cb00f778a7efc559a39896212d96c6147f0b0`;
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
- `rustc 1.97.1 (8bab26f4f 2026-07-14)`; `cargo 1.97.1
(c980f4866 2026-06-30)`. Synthetic fixtures and Ratatui TestBackend only;
no production, Docker, VM or live VPN action.
## Checks and scope
| Exact command | Result |
| --- | --- |
| `cargo test --locked valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety` | Exit 0; focused config preservation test passed. |
| `cargo test --locked tui::render::tests::client_rows_show_draft_state_and_escape_anomalous_unsupported_peers` | Exit 0; focused list/details test passed. |
| `cargo test --locked --lib issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Exit 0; focused issue-screen test passed after repair. |
| `cargo test --locked --test config_preservation` | Exit 0; 10 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; 272 library tests passed, 2 opt-in drivers ignored; integration and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
The parser preserves the valid example's original source and all three routes.
The application leaves its raw peer issues and unsupported projection intact;
the TUI presents parsed-valid address-shape limitations separately from
malformed input. The list and issue screen omit capability-only diagnostics;
details show the exact subnet explanation. Tests also cover a normal supported
client, extra host routes, IPv6-only shape, mixed malformed route, and existing
broad-route deletion safety. Alias-only rename of an unprojectable peer retains
its existing behavior and preserves routes. The TUI still blocks normal client
mutations for unprojectable peers.
## Review and decision
Fresh independent review initially raised M11-R1, treating a second distinct
`/32` host route as a configuration error. After checking the WireGuard
[`wg(8)` AllowedIPs format](https://man7.org/linux/man-pages/man8/wg.8.html),
the reviewer withdrew R1: a valid additional host route is another model
capability limitation. The coordinator raised M11-R2 because non-subnet valid
address shapes were still displayed as anomalies. A repairer classified the
two parsed-valid shape variants consistently for presentation, kept all raw
issues for safety, and added regressions. Independent re-review reported no
remaining actionable finding. A1–A4 passed within source/fixture scope; the
integrated gates above satisfy A5.
No real WireGuard or systemd behavior was exercised. Milestone 9 remains
retired and unaccepted; real systemd/D-Bus and IPv6 capability are unverified.
+56
View File
@@ -0,0 +1,56 @@
# Milestone 12 acceptance evidence
Run: 2026-10-26T21:39:45Z, local development workspace. This report covers
the responsive client table presentation. It does not establish real terminal
emulator, Alpine WireGuard, systemd/VM or production behavior.
## Source and environment
- HEAD `ecc5c83d78a4428b8fa2c1826fe74ead144cf72b`; the working tree at
validation was dirty with `PLANS.md` and `src/tui/render.rs`. Relevant
integrated SHA-256 inputs: `render.rs`
`78be59f8550f94c573db9eab6b43968a9e0621bcabd7dd268b5ba7f9b9aba3ad`;
`state.rs` `7e893758b623b42f955358dcaba8100fdb6b54ca48c9dd9a1dea15090b84f371`;
`terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`;
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
- Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97; native
`x86_64-unknown-linux-gnu` host. No dependency or lockfile change.
## Validation
| Exact command | Actual result |
| --- | --- |
| `cargo test --locked tui::render` | Exit 0; 20 render tests passed. |
| `cargo test --locked tui::terminal` | Exit 0; 8 terminal tests passed. |
| `cargo test --locked --test tui_workflow` | Exit 0; 6 workflow tests passed, including isolated PTY smoke. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; 279 library unit tests passed, 2 opt-in drivers ignored. All integration suites and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
Ratatui TestBackend coverage checks five full headers and column boundaries at
ordinary widths, State/Name priority across 72/78/79/80 widths, display-cell
Unicode truncation, long routes, mixed client modes, empty/draft/document rows,
no false non-peer selection, and visible selected peers with the Keys footer at
40×8 and 34×8. Below-minimum resize fallback remains covered. Existing
terminal tests cover Up/Down highlight and Enter-to-details by peer index.
These tests use synthetic buffers and fixtures; physical terminal display
differences were not tested.
## Independent review
Fresh read-only review found M12-R1 (compact table hid all peer rows), R2
(empty/document-only views falsely selected row zero), and R3 (selector width
clipped the last column). A targeted repair added compact body visibility,
unselected non-peer views and marker-aware width budgeting; independent
re-review closed R1–R3. That re-review found R4: five-column mode began before
the `Allowed IPs` header fit at widths 72–78. A second targeted repair moved
the threshold to the full-header minimum and added boundary-buffer checks;
independent re-review closed R4 with no remaining actionable finding.
M12-A1–A4 are supported by the inspected implementation and synthetic
render/navigation evidence. The integrated gates above satisfy M12-A5.
Configuration parsing, validation, persistence, allocation and VPN behavior
were outside scope and unchanged. Milestone 9 remains retired and unaccepted;
real systemd/D-Bus and IPv6 behavior remain unverified.
+59
View File
@@ -0,0 +1,59 @@
# Milestone 13 acceptance evidence: compact terminal QR disclosure
Validated 2026-10-27T21:13:30+02:00 on the native
`x86_64-unknown-linux-gnu` development host. This is synthetic terminal
rendering evidence, not a manual scan or production integration result.
## Behavior and review
The QR renderer packs two vertical modules into one cell using space, `▀`,
`▄`, and `█`, with explicit black foreground and white background. It preserves
the encoded four-module quiet zone on all four edges and treats the absent
partner of an odd final row as light. The QR is rendered in a separate,
unwrapped region. The fit check accounts for its bordered area and actual
wrapped delivery notice. A deterministic nonsecret 73-module fixture uses
73 columns and 37 QR rows; with a one-row notice, the exact tested terminal
layout is 75×44. Longer notices require more terminal height.
When the complete QR does not fit, a compact fallback shows required and
available dimensions, actual terminal dimensions, saved-file guidance, delivery
status, and Esc. Tests inspect rendered buffers at 74×45, 34×12 and 20×8;
resize redraws the held QR and Esc clears it. The compact notice preserves
uncertain publication and destination-inspection guidance. File delivery,
password authorization, and draft/saved/applied transitions were not modified.
`QrCodeOutput` retains redacted Debug and the fixture matrix is nonsecret.
An independent GPT-6-Luna/high reviewer examined the actual changes and
closed M13-R1–R5 after targeted repairs: short fallback clipping, hidden
saved/applied summary, incomplete quiet-zone edge coverage, inaccurate
available-area dimensions, and misleading uncertain-delivery compaction. No
actionable scoped findings remain.
## Commands and source identity
Pinned rustc/Cargo 1.97.1, rustfmt 1.9.0, and Clippy 0.1.97 were verified.
The following commands ran from the repository root and exited 0:
- `cargo test --locked tui::render::tests::`: 27 passed.
- `cargo test --locked delivery::qr::tests::`: 2 passed.
- `cargo fmt --all -- --check`.
- `cargo clippy --locked --all-targets -- -D warnings`.
- `cargo test --locked`: 352 passed, 3 explicitly ignored, 0 failed,
including 2 documentation tests.
- `cargo build --locked --release`.
- `git diff --check`.
Base commit: `af2d061880a8532314f57d4f1ec6eb5265520091`. Dirty Rust inputs
at validation were `src/tui/render.rs` and `src/delivery/qr.rs`; `PLANS.md`
was also dirty with this milestone's execution record. SHA256 inputs:
- `src/tui/render.rs`: `98ffa4d396194c201cfe20b6c6dcbd80b6689886fc625c8cb83efa6c12564e8a`
- `src/delivery/qr.rs`: `1f2012c6d1583178ca964df1d444c8afeae35607dfafac05bc538911be480748`
Native release artifact `target/release/wireguard-admin` SHA256:
`0c2fc010138c7db27c3126d0eca6bd83da3be600c1c4e0f8c8beeef77d11e43a`.
No physical terminal, phone/decoder scan, Alpine integration rerun, disposable
systemd VM, or production operation was performed. The 80×24 size remains a
dimensioned fallback for a 73-module QR. Previous integration artifacts do not
identify this revision; M9 systemd acceptance remains retired and unverified.
+68
View File
@@ -0,0 +1,68 @@
# Milestone 14 acceptance evidence: restart with valid legacy peers
Validated 2026-10-27T21:55:11+02:00 on the native
`x86_64-unknown-linux-gnu` development host. Results below use synthetic
saved files, fake service/runtime adapters, and existing pure runtime comparison
tests. They do not establish a real systemd restart.
## Behavior and review
Restart preview and confirmation now use the complete `SavedPeerProjection`
derived from the authoritative saved configuration. It includes every valid
peer's public key and complete normalized `AllowedIPs`, independently of
optional managed alias metadata and the narrower management `Client` shape.
The existing runtime comparator still requires a complete observed peer set
and checks all saved peers and routes before verification can be recorded.
The management applied snapshot remains absent when it cannot represent the
whole saved peer set; no partial `Client` snapshot is marked applied.
Synthetic fixtures cover a saved managed client alongside metadata-free legacy
peers, IPv4-only host routes, and valid subnet routes outside the management
model. Preview preserves server bytes and creates no backup. Fake successful
service completion plus a full runtime match creates the required pre-restart
backup, records verified historical status, and releases deleted-address
reservations only after durable installation. Reopening retains that history,
starts with runtime `Unknown`, and does not regain a partial applied snapshot.
A missing legacy runtime peer leaves reservations durable and runtime unknown.
Malformed AllowedIPs, duplicate public identity, and malformed section headers
block preview before backup/service mutation with a secret-free public identity
or section line and condition. Existing tests also cover backup failure,
service timeout/failure, stale saved generation, runtime mismatch, persistence
failure, draft cancellation and `SaveConfig` rejection.
An independent GPT-6-Luna/high reviewer found no actionable behavior finding.
The first coordinator Clippy gate found M14-R1 (P3), a collapsible diagnostic
guard in `src/app.rs`. A separate GPT-6-Luna/high repairer fixed it; the
reviewer confirmed equivalent logic and closed R1. The final integrated gates
below ran after that repair.
## Commands and source identity
Pinned rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
The following commands ran from the repository root and exited 0 on the
integrated revision:
- `cargo test --locked app::tests::`: 57 passed.
- `cargo test --locked --test runtime_restart`: 3 passed.
- `cargo fmt --all -- --check`.
- `cargo clippy --locked --all-targets -- -D warnings`.
- `cargo test --locked`: 357 passed, 3 explicitly ignored, 0 failed,
including 2 documentation tests.
- `cargo build --locked --release`.
- `git diff --check`.
Base commit: `4bb975f122abfdeabbd04fe6b737eec5706596f5`. Dirty Rust inputs
at validation were `src/app.rs`, `src/domain.rs`, and `tests/runtime_restart.rs`;
`PLANS.md` was dirty with this milestone's execution record. SHA256 inputs:
- `src/app.rs`: `01cf281f5b8ae62877dcf63ece7d51b5cca5522f6bc5c9e99dbc0e718a4121f4`
- `src/domain.rs`: `179ecbe66ad7b6e022233a6da3b62a6ce57d92d26d32b0ae6c750ccd2edc5f01`
- `tests/runtime_restart.rs`: `4cff0de16a18cccc846ace86808840567165fc110d121fd7bdc459481379e05f`
Native release artifact `target/release/wireguard-admin` SHA256:
`a7c946297d2a0140ddcca1c15bb3f651fe6ddc494c2eb7e8e713fdfc700e6b5a`.
No Alpine rerun, disposable systemd VM, or production operation was performed.
The accepted M8 Alpine artifact identity predates this revision; it does not
validate this build. M9's real systemd/D-Bus acceptance remains retired and
unverified.
+69
View File
@@ -0,0 +1,69 @@
# Milestone 15 evidence: diagnosable systemd restart
Validated 2026-10-27T22:19:58+02:00 on the native
`x86_64-unknown-linux-gnu` development host. All behavioral checks below use
synthetic fixtures or fake service/unit APIs. No system D-Bus or host service
was invoked.
## Cause, change, and limits
The source-level diagnostic loss was confirmed: `Application` mapped every
unsuccessful service outcome to `ServiceFailed`. The report now retains the
typed `RestartFailure`, an explicit operation phase, and the configured unit;
the terminal renders a readable category and phase with the unit escaped for
terminal safety. Raw D-Bus error messages and service output are not included.
The [official systemd Manager D-Bus API](https://freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html)
confirms `GetUnit` fails if the named unit has not yet been loaded and `LoadUnit`
loads it from disk if possible. The previous adapter called `GetUnit`; it now
calls `LoadUnit`, requires `LoadState == loaded`, and reads canonical `Id` for
job-event matching while requesting restart with the configured name. This is
a confirmed API mismatch for existing unloaded units, but its role in the
user's specific host failure is a hypothesis until a disposable-VM run provides
real systemd/D-Bus evidence. Missing or nonloaded units stop at resolution.
The preexisting service adapter could understate uncertainty if the manager
owner changed during a pending restart request. That path now reports possible
submission, possible later completion and manual recovery. Synthetic tests
cover connection, subscription, permission, resolution, request and job
failures; alias identity; unloaded and missing unit load flows; and readable
TUI status for every typed failure. They also assert failure does not mark the
state applied. Existing tests cover mandatory backup, confirmation, generation,
complete runtime match, and durable applied-state boundaries. No shell restart
path was introduced.
Independent GPT-6-Luna/high review found M15-R1 (P2, request owner-change
uncertainty) and M15-R2 (P2, unloaded-unit adapter test gap). A separate
GPT-6-Luna/high repairer addressed both. The reviewer rechecked the actual
repair and closed both findings with no further scoped issue.
## Commands and source identity
Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
Commands ran from the repository root on the repaired revision:
- `cargo test --locked service::dbus::tests::`: 13 passed.
- `cargo test --locked app::tests::application_report_preserves_every_service_failure_and_operation_phase`: 1 passed.
- `cargo fmt --all -- --check`: exit 0.
- `cargo clippy --locked --all-targets -- -D warnings`: exit 0.
- `cargo test --locked`: 364 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests. The selected Docker daemon test was not run.
- `cargo build --locked --release`: exit 0.
- `git diff --check`: exit 0.
Base commit: `86c28a35b559f70cb08365a7cdf0d5961e5570ea`. Dirty Rust inputs
at validation and their SHA256 values:
- `src/service.rs`: `83a6089a4db019918178d7d97346e104180d63eb3ccc8a025bda91939720e02e`
- `src/service/dbus.rs`: `79ebc8c91db6dd08163ee0a6484359c2a8e7623e33081c9088c4d97d507d1816`
- `src/app.rs`: `2c3f6822a48dd5ec92faad9fbfd05a5be8723057d23aaba7f92e7899891cbefb`
- `src/tui/terminal.rs`: `48835132a30b1d5243a6076abfe06f6e98fb9419b0c4433c540406767715bae3`
- `src/container_driver.rs`: `9f6b27bff93d4face72ef8980a5d8e2db418bdb079c501a885d2c419fcfd6964`
Native release artifact `target/release/wireguard-admin` SHA256:
`18dd2ce2dcc9f93272440d45491b5f83da82a7b9ed392986a8eaf059b76bf593`.
No Alpine rerun, disposable systemd VM, or production operation was performed.
The actual systemd/D-Bus acceptance required by M9 remains incomplete and
unclaimed. An operator can now use the specific safe category and phase to
distinguish a unit-resolution problem from connection, subscription, access,
request, or job failure without exposing raw service details.
+75
View File
@@ -0,0 +1,75 @@
# Milestone 16 evidence: private systemd peer transport
Recorded 2026-10-27T22:42:48+02:00 on the native
`x86_64-unknown-linux-gnu` development host. This is **implementation and
synthetic evidence, not systemd acceptance**. On 2026-10-28, the user reported a
successful manual check and explicitly directed that M16 be considered done.
The check's environment, commands, source identity, and transport coverage were
not supplied. PLANS.md records user-directed acceptance; this report does not
claim a passing disposable-VM run or independently close M16-R2.
## Source and protocol basis
The existing adapter connected only with `zbus::Connection::system()`. That
cannot reach systemd where no system D-Bus daemon exists. Official systemd
[source](https://github.com/systemd/systemd/blob/main/src/shared/bus-util.c)
shows root `systemctl` connecting to `unix:path=/run/systemd/private` and
checking peer credentials. Its [manual](https://www.freedesktop.org/software/systemd/man/latest/systemd.html)
calls this an internal interface; compatibility across systemd releases is
not guaranteed. Pinned [zbus 5.19.0](https://docs.rs/zbus/5.19.0/zbus/connection/struct.Builder.html)
provides `p2p` and EXTERNAL-authentication support.
The adapter now selects the system bus first and falls back to the private
socket only for a missing/refused/disconnected bus endpoint during connection
setup, before any restart request. Permission, handshake and timeout failures
do not trigger fallback. The private path checks peer UID 0; method calls omit
the bus destination; its `JobRemoved` stream uses a local peer match without
`org.freedesktop.DBus` owner monitoring. The system-bus path retains its
destination and owner-change watch. Both preserve `Subscribe` before
`RestartUnit`, canonical unit identity, the shared deadline, job matching and
conservative submission uncertainty. No retry follows a possibly submitted
restart. Application backup/confirmation/runtime/applied logic is unchanged.
## Checks, review, and gap
An independent GPT-6-Luna/high reviewer found M16-R1 (P2): private socket
permission errors were reduced to `RequestFailed`. A separate GPT-6-Luna/high
repairer changed the production builder error mapping to preserve
`PermissionDenied` and added a focused synthetic assertion. The reviewer
rechecked and closed R1. M16-R2 (P2) remains open: synthetic tests do not
execute the production private peer branch end to end. An attempted local zbus
UnixStream peer fixture failed at peer credential access with `EPERM` in the
sandbox; later fixture variants did not establish a successful protocol path.
Those experiments were removed from the test tree. They are not evidence that
real systemd rejects the implementation. No host private socket was accessed.
Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0, and Clippy 0.1.97 were verified.
On the repaired inputs, from the repository root:
- `cargo test --locked --lib service::dbus::tests -- --test-threads=1`: 16 passed.
- `cargo fmt --all -- --check`: exit 0.
- `cargo clippy --locked --all-targets -- -D warnings`: exit 0.
- `cargo test --locked`: 367 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests. Selected Docker/VM suites were not run.
- `cargo build --locked --release`: exit 0.
- `git diff --check`: exit 0.
Base commit: `fe7e3d61c22108acba5c4a86480254a12e089a53`. Dirty inputs and
SHA256 at validation:
- `Cargo.toml`: `807d08cc43b4c3f986134b7aa5b051fd2f0966fab95ac01d861c65f173c77caa`
- `Cargo.lock`: `6a929bb72d90da16b5ca7d209566e31a611d1ddcd5817ee778d7e9fe25b10c2e`
- `src/service/dbus.rs`: `d1ed34dd8a46d2495299c776176d7d5f6cfa9b6abb29ec84b6045e74bdffeb26`
- `docs/DEPENDENCIES.md`: `1db85ecd3f892e3f42f1220eb7627cfd423406e46e5024bdf6599a647f07be1f`
- `docs/TESTING.md`: `7ecab091999b50964b1641772a4cfc370639507b9c6621a058829a54b0f71325`
Native release artifact `target/release/wireguard-admin` SHA256:
`64c2851431ec406901e76dc866a05abd630a0e71adfd4efc16d3b44ea3c72b73`.
The prior disposable Debian VM was stopped before guest installation and has
no verified guest marker or running systemd acceptance environment, per
[`VM_ACCEPTANCE.md`](../VM_ACCEPTANCE.md). No private-socket or system-bus
restart was run in a disposable VM as part of the recorded checks. This gap
remains despite the later user-directed acceptance. A future independent check
would use a disposable VM with synthetic WireGuard configuration to cover both
transport paths, job completion, and full runtime matching, with exact
source/artifact identity. Production must not be used to fill the gap.
+77
View File
@@ -0,0 +1,77 @@
# Milestone 17 evidence: adaptive client list and state presentation
Recorded 2026-10-28T23:09:37+02:00 on `x86_64-unknown-linux-gnu` with
`rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. Base commit:
`d49721ff8e6a40c1016e59b2648a6a622f84a4c4`. This is a dirty working tree;
the M17 Rust/README inputs at validation have SHA256:
| Input | SHA256 |
| --- | --- |
| `src/tui/render.rs` | `19fad8a9edfa571060c94fe34a1a484369f3601a3ffc52148081c935be2404f2` |
| `src/tui/state.rs` | `b533427001c67fb2c1d8527fb8a2cc1d6bd7e824be8985fd8b773b09a144d0f9` |
| `src/tui/terminal.rs` | `591ad7498fb4bb8cb825dfba0bad26e28bd8432ef26d5527950863a7534689f6` |
| `tests/tui_workflow.rs` | `f57d058d8538d5d1afdab7ddb7e88d23259804a9927ee4ac52982903dc1a6928` |
| `README.md` | `7c5eb0b58e81aaecbddae230211099db59560dfc5fe30991e7022d841d6b04a8` |
`PLANS.md` and `docs/evidence/milestone-16.md` are also dirty documentation
inputs. The latter records user-directed M16 acceptance and an undocumented
manual check; it is not M17 test evidence. No M17 dependency manifest or
application adapter changed. The release binary
`target/release/wireguard-admin` has SHA256
`336103d76bb78225735411a76abdbf3f6d5dc49dd6ce0b96537dfd3dea46a578`.
`file` identified an x86-64 dynamically linked GNU/Linux PIE.
## Implementation and acceptance mapping
- M17-A1: the table allocates the actual list-pane width across wide, medium
and compact columns. Synthetic buffers cover growing names, Latin/Cyrillic/
wide-character alignment, separate host IPv4/IPv6 cells, IPv4-only peers,
and route markers. Full routes and public identity remain in details.
- M17-A2/A3: shared visible records and selection drive the list, persistent
pane and Enter details. Synthetic state/navigation/buffer checks cover first
selection, duplicate and malformed peers, draft removals, document-only
issues, refresh, dimension breakpoints and stable geometry. A loading-state
regression covers side and stacked panes. Breakpoints use the content area:
side-by-side at 140×13 or larger, stacked at 80×17 or larger (eight rows for
details), compact summary at 60×12 or larger, otherwise list-only. These
dimensions are independent of selected content and action availability.
- M17-A4/A5: explicit Draft, Saved and Applied labels distinguish pending
drafts from saved peers and runtime verification. The saved peer count comes
from installed server bytes and includes nonmanaged peers; managed counts
remain separately scoped. Tests cover state combinations, issue versus
capability-limit counts, complete details and available observations.
- M17-A6: focused TUI/workflow checks include navigation, forms, footer, QR and
synthetic PTY lifecycle coverage. No Commit/restore/restart behavior changed.
## Review and checks
One GPT-6-Luna/high implementer completed M17.1, M17.2 and M17.3 in order.
A separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated
diff and ran `cargo test --locked tui::` (70 passed) and
`cargo test --locked --test tui_workflow` (8 passed). It found M17-R1 (P2):
loading list and persistent detail messages conflicted. A separate
GPT-6-Luna/high repairer fixed the pane and added a 140×20/80×24 buffer test;
the reviewer rechecked and closed R1 (`cargo test --locked tui::render::tests`:
39 passed). The integrated Clippy gate then found M17-R2 (P2), two redundant
closures. The repairer replaced them with function items; the reviewer checked
semantics, reran exact Clippy, and closed R2. No actionable review finding
remains.
Final integrated checks on the input hashes above:
| Command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo test --locked tui::` | 71 passed, 0 failed |
| `cargo test --locked --test tui_workflow` | 8 passed, 0 failed; includes synthetic PTY smoke |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after R2 repair |
| `cargo test --locked` | 384 passed, 0 failed, 3 explicitly ignored; 2 doctests passed |
| `cargo build --locked --release` | exit 0; binary hash above |
| `git diff --check` | exit 0 |
All tests used synthetic configuration and keys. The PTY smoke checks emitted
terminal control behavior through an isolated `script` session; it is not a
manual terminal-emulator usability test. Alpine Testcontainers, disposable-VM
systemd paths, live WireGuard, production configuration, and phone QR scanning
were not run for this presentation milestone. Those checks are not claimed by
the fixture results. No host service was restarted and no Git commit was made.
+90
View File
@@ -0,0 +1,90 @@
# Milestone 18 evidence: in-app Help
Recorded 2026-10-28T23:59:51+02:00 on `x86_64-unknown-linux-gnu` with
`rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. Base commit:
`4960c65f6511da50bac9d35e2b4a625adbaf86c3`. The working tree is dirty;
the M18 Rust/README inputs at final validation have SHA256:
| Input | SHA256 |
| --- | --- |
| `src/tui/render.rs` | `dbf00298f1bf466f99688f790be5ae34e9a7434aa374bc61c7df31e41c8d842b` |
| `src/tui/terminal.rs` | `4658404e856c1acced9c115f1cfb4c9f87e714b2343c3996df7ee63504368b81` |
| `tests/tui_workflow.rs` | `d7bc72c20eb9b4d3ba160e599d83f83111e418a9c3056b4e430aa17a555baacd` |
| `README.md` | `7352da8e13850b1b13e11bf2f828c8368dbd1b6b95320d6838ac26b7fa0532d6` |
`PLANS.md` is also dirty, recording the prior M18 plan and this milestone's
progress. No dependency manifests or application adapters changed. Release
`target/release/wireguard-admin` SHA256 is
`d179b2934565e12e0d90ad716730418a40b215c5ca68058fd76566c267e98488`;
`file` identified an x86-64 dynamically linked GNU/Linux PIE.
## Shortcut audit and content basis
The implementer audited current `src/tui/terminal.rs::handle_key` and
`handle_navigation_key`, `src/tui/forms.rs::handle_key` and
`src/tui/events.rs` before writing Help. The guide's shortcut families map to
the existing handlers and guards:
| Keys | Handler and actual availability |
| --- | --- |
| `?`, `Up/Down`, `PageUp/PageDown`, `Enter`, `Esc` | `terminal.rs`: Help resets to top; Help scrolls by visual row/page; list arrows select and Enter opens details; Esc returns to list or cancels an active form/clears QR. |
| `q`, handled Ctrl-C | `terminal.rs`: outside forms, exit is immediate without a draft or asks about draft discard; Ctrl-C is translated to `q` and is form-specific while a form is active. |
| `n`, `r`, `d` | `terminal.rs` and `forms.rs`: create requires a loaded view; rename/delete require a supported unambiguous selected peer and confirmation. Blank paired addresses select automatic allocation. |
| `p`, `c`, `x`, `R` | `terminal.rs`, `forms.rs`, `events.rs`: preview draft; confirmed Commit saves after backup; confirmed discard drops draft; uppercase restart previews saved state and requires confirmation, backup, service completion and verification. |
| `o`, `O` | `terminal.rs`, `forms.rs`, `events.rs`: supported selected saved client only; first delivery after Commit is session-bound and retryable after file failure, while uppercase reissue requires a vault password per action; file precedes optional QR. |
| `b`, `B` | `terminal.rs` and `forms.rs`: list/select backup and preview confirmed full restore versus create manual backup. Restore changes saved state only. |
| `s`, `i`, `!` | `terminal.rs` and `forms.rs`: setup only before vault initialization; import submits one configured-directory filename and preserves its source; issue summary is available after view load. |
Actions remain guarded by the existing handlers. The Help navigation keys
themselves do not submit application work. Existing action letters can still
dispatch while Help is visible; the milestone did not change action dispatch.
The guide says how to return to the list and accurately distinguishes lowercase
and uppercase keys.
## Implementation, review and acceptance mapping
The Help screen is one ordered document with an introduction and nine sections:
quick start; list/details; state/observations; client changes; save/restart;
delivery/reissue; backups/restore; vault/import/issues; navigation/exit. It
uses styled headings, key labels, workflows and warnings, with display-cell
wrapping at the actual inner viewport width. A visible row-range title and the
fixed-height footer show navigation and overflow. Renderer and terminal share
the same wrapped-row metrics: one-row arrows, viewport-page keys, bounded
scrolling, resize clamping, `?` reset and Esc return. These changes cover
M18-A1–A5; synthetic buffers cover 34×8, 40×9, 60×16, 80×24, 100×30 and
180×50, including reachability of the last section. README Help instructions
were updated.
One GPT-6-Luna/high implementer completed M18.1–M18.3 sequentially. A separate
fresh GPT-6-Luna/high read-only reviewer compared content with the handlers and
tested Help rendering/navigation. It found M18-R1 (P2): at 34×8, the Help
footer omitted Esc return. A separate GPT-6-Luna/high repairer changed only
Help's narrow hint packing and added 34×8/40×9 assertions. The reviewer
rechecked the actual repair, ran the focused test and closed R1. No actionable
finding remained in that pass. A coordinator content audit then found M18-R2:
four missing explanations about Applied status, current observations, the
first-delivery activation warning and masked password entry. The reviewer
independently confirmed those gaps. The separate repairer amended Help and
added a 34×8 semantic/reachability regression; independent re-review checked
the claims against status/form code and SPEC.md and closed R2. No actionable
finding remains, satisfying M18-A6. A proposed change to action-key dispatch
was withdrawn after checking the explicit preserve-actions boundary.
## Final checks and limits
| Command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo test --locked tui::` | 79 passed, 0 failed |
| `cargo test --locked --test tui_workflow` | 8 passed, 0 failed; includes synthetic PTY Help/resize flow |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | 392 passed, 0 failed, 3 explicitly ignored; 2 doctests passed |
| `cargo build --locked --release` | exit 0; binary hash above |
| `git diff --check` | exit 0 |
Tests use synthetic fixtures and an isolated PTY. That PTY run checks emitted
terminal behavior, not manual readability in a specific terminal emulator.
Alpine Testcontainers, disposable-VM systemd, live WireGuard and production
configuration were not used for this presentation milestone. The user-directed
M16 manual check has no recorded environment/transport detail and is not M18
evidence. No host service was restarted and no Git commit was made.
+132
View File
@@ -0,0 +1,132 @@
# Milestone 19 evidence: refactoring contract
Recorded 2026-10-29T22:23:28+02:00. Base commit:
`23d6180f2478f636c185d67b8bb3788d21656ce3`. The working tree is dirty:
`PLANS.md` already held the user-approved M19–M27 plan before execution, and
this milestone changed `PLANS.md` and `CONTEXT.md` and added
`docs/architecture/domain-refactoring.md` and this report. No Rust source,
manifest, lockfile, production configuration, or generated secret was changed.
The reviewed substantive document inputs have these SHA256 fingerprints:
| Input | SHA256 |
| --- | --- |
| `PLANS.md` (accepted snapshot) | `724bb710c2f8db7ceca7a059425be6a868e23ab9dd41cb1ff5ae5c32986a4b8e` |
| `CONTEXT.md` | `06efa3f8bb91dbd3b7fb6a00f0035dde7c3be1c01d0a6fe9c096460d6d28deb8` |
| `docs/architecture/domain-refactoring.md` | `7dbb01cfa85dd89a3d122ecf8a564dd204c805bea8a33cb09c4268d0308329c8` |
## Scope and acceptance mapping
M19.1 read-only inventory traced the domain/application, WireGuard document,
runtime, persistence/backup, vault/import/source, delivery, startup, TUI,
library, test-only container driver, and integration tests. M19.2 added a
domain-only glossary and a source-referenced migration contract. The contract
maps field/function authority, readers/writers, actual test names and M20–M27
owners, and describes draft, reservation, verification, delivery, restore and
restart transitions. It keeps the server document authoritative for malformed,
metadata-free and unsupported peers, including valid subnet-route peers that
can be verified technically without becoming manageable clients. Historical
verification is distinct from current apply knowledge. This supports M19-A1–A3.
The caller audit found `ClientStates::draft_mut` used by app production code and
colocated tests; `Application::vault_draft_mut` by one colocated test;
`Application::add_client` and `Application::open` by integration/test fixtures;
and `open_from_saved_store` by production startup and the test-only container
driver. The contract retains compatibility until actual callers migrate.
M20 tuple arity is six; M22–M26 planned focused commands name present targets.
No dependency, schema, archive, UI or behavior change is implied.
The remaining behavior gap is explicit in the contract: current restore
reservation coverage proves manageable saved clients, while
`preview_restore` does not collect exact host routes from unprojectable current
peers or `draft_unsupported_reservations`. M23 must add focused coverage and
repair any uncovered preservation under the existing reserved-address policy.
M19 does not claim that this behavior is already correct. M9 remains retired
and unaccepted; M19 depends on accepted M18, not M9.
## Independent review and repair
A GPT-6-Luna/high implementer completed M19.1 and M19.2. A separate, fresh
GPT-6-Luna/high reviewer inspected the actual source, callers, tests and
documents. A GPT-6-Luna/high repairer corrected architecture wording; the
coordinator alone corrected plan wording and later task commands. The reviewer
rechecked each repair and reported no remaining actionable findings:
| Finding | Final disposition |
| --- | --- |
| M19-R1 | Closed: restore rewrites internal state, retains conservative reservations and clears historical verification. |
| M19-R2 | Closed: historical M18 handoff no longer contradicts active M19. |
| M19-R3 | Closed for this documentation milestone: current unprojectable-peer restore reservation gap and M23 focused test/repair are recorded. |
| M19-R4 | Closed: programme and historical M17 handoff wording reflect the current plan. |
| M19-R5 | Closed: M19.3 task row reflects the current finding disposition. |
| M19-R6 | Closed: tuple arity and later focused test targets match the actual repository. |
This independent review supports M19-A4 subject to the final DOC checks below.
## Validation and limits
Final integrated DOC checks ran in the repository root on the accepted plan
snapshot. The exact link/whitespace command was:
```sh
python3 - <<'PY'
from pathlib import Path
import re, sys
files = [Path(p) for p in ('PLANS.md', 'CONTEXT.md', 'docs/architecture/domain-refactoring.md', 'docs/evidence/milestone-19.md')]
errors = []
links = anchors = 0
for file in files:
body = file.read_text()
if not body.endswith('\n'): errors.append(f'{file}: final newline')
for number, line in enumerate(body.splitlines(), 1):
if line.rstrip() != line: errors.append(f'{file}:{number}: trailing whitespace')
for target in re.findall(r'(?<!!)\[[^]]+\]\(([^)]+)\)', line):
if '://' in target or target.startswith(('mailto:', 'plugin:')): continue
path, _, anchor = target.partition('#')
dest = (file.parent / path).resolve() if path else file.resolve()
links += 1
if not dest.exists(): errors.append(f'{file}:{number}: missing {target}'); continue
if anchor:
anchors += 1
headings = [re.sub(r'[^\w\- ]', '', h.lower()).strip().replace(' ', '-') for h in re.findall(r'^#{1,6}\s+(.+)$', dest.read_text(), re.M)]
if anchor not in headings: errors.append(f'{file}:{number}: missing anchor {target}')
print(f'{len(files)} files, {links} local links, {anchors} anchors, {len(errors)} errors')
for error in errors: print(error)
sys.exit(bool(errors))
PY
```
It exited 0: four files, 72 local links, zero anchors and zero errors. The
new untracked documents were included in the final-newline and trailing-space
check. The exact plan-consistency command was:
```sh
python3 - <<'PY'
from pathlib import Path
import re, sys
plan = Path('PLANS.md').read_text()
rows = [(int(i), state) for i, state in re.findall(r'^\|\s*(\d+)\s*\|[^\n]*?\|\s*(accepted|retired|pending|in_progress|in_review|needs_changes|blocked)\s*\|$', plan, re.M)]
errors = []
if [i for i, _ in rows] != list(range(1, 28)): errors.append('register numbering')
states = dict(rows)
if states.get(18) != 'accepted' or states.get(19) != 'accepted' or any(states.get(i) != 'pending' for i in range(20, 28)): errors.append('register states')
record = plan.split('### Milestone 19 —', 1)[1].split('### Milestone 20 —', 1)[0]
if 'Status: accepted' not in record or any(f'M19.{i} |' not in record for i in (1, 2, 3)): errors.append('M19 record/tasks')
resume = plan.split('## Current progress and resume point', 1)[1].split('## Plan maintenance', 1)[0]
if 'M19 is accepted' not in resume or 'Stop after M19' not in resume: errors.append('resume point')
for target in set(re.findall(r'cargo test --locked --test ([a-z_]+)', plan.split('### Milestone 20 —', 1)[1].split('## Current progress and resume point', 1)[0])):
if not Path(f'tests/{target}.rs').exists(): errors.append(f'missing test target {target}')
if "prepared_destination's six-element tuple" not in plan: errors.append('tuple arity')
print(f'{len(rows)} register IDs, M19 tasks/status/resume, future test targets and tuple: {len(errors)} errors')
for error in errors: print(error)
sys.exit(bool(errors))
PY
```
It exited 0: 27 ordered register IDs and no status, task, target or tuple
errors. `git diff --check` exited 0. The coordinator also inspected factual
claims and the final changed-file set; the independent reviewer compared
the migration contract with production and test source. Rust formatting,
Clippy, tests, release build, Alpine Testcontainers and disposable-VM checks
were not required for documentation-only M19 and were not run. No production
or live WireGuard access occurred.
+80
View File
@@ -0,0 +1,80 @@
# Milestone 20 evidence: vocabulary and prepared contexts
Recorded 2026-10-29T22:38:53+02:00. Base commit
`ede45d224a9ad255a919900e556a03d7e575430f` had a clean working tree.
The final tree is dirty only for this milestone's `PLANS.md`, Rust source and
test changes, and this evidence report. No `Cargo.toml` or `Cargo.lock` diff,
new dependency, persisted-format change, production configuration, or secret
fixture was introduced. The final Rust source/test patch fingerprint is
SHA256 `cba97207f926f1927c70643754db2743603e0f72892115ef8d959696d769992a`,
produced by:
```sh
git diff -- src/app.rs src/domain.rs src/persistence.rs src/container_driver.rs src/tui/events.rs src/tui/render.rs src/tui/state.rs src/tui/terminal.rs tests/client_delivery.rs tests/commit_restore.rs tests/runtime_restart.rs tests/tui_workflow.rs | sha256sum
```
The release artifact `target/release/wireguard-admin` built from those inputs
has SHA256 `7a6ee46b1d9c39bac9a7b3627b0b8ba30e70321126ebd16b1948b42b8658ac98`.
`file target/release/wireguard-admin` identified an x86-64 dynamically linked
GNU/Linux PIE with interpreter `/lib64/ld-linux-x86-64.so.2`.
## Implementation and acceptance mapping
- M20.1 renamed the domain's current knowledge enum to `ApplyStatus`, retained
`runtime::RuntimeObservation` for measured interface data, and migrated actual
application, persistence, TUI and integration callers. The exact duplicate
TUI `AppliedState` enum/mapping and now unnecessary `Statistics`,
`TechnicalObservation` and `ObservedRuntime` aliases were removed. UI labels
and current-versus-historical state transitions were preserved (M20-A1).
- M20.2 replaced the six-value prepared delivery tuple and three-value restore
tuple with private named contexts. A private `DeliveryMode` distinguishes
first issue and reissue without changing public `reissue` flags. Neither
context derives `Clone` or `Debug`; identity, attempt, generation, overwrite,
cancellation, unrelated-command clearing, password ownership and QR/file
order remain unchanged. A behavioral test covers cancel/unrelated-command
clearing (M20-A2).
- All affected production and test callers compile; no manifests, formats or
transport behavior changed. Focused tests, final full RUST gates and
independent review passed (M20-A3).
## Independent review and repair
A GPT-6-Luna/high implementer completed M20.1 and M20.2 sequentially. A
separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated
diff, production paths, callers, tests and secret-bearing contexts and found
no behavior issue. The coordinator's first Clippy gate found M20-R1 (P2): two
test/test-only `.clone()` calls on the now-`Copy` status. A separate
GPT-6-Luna/high repairer changed only those two calls to dereferences in
`src/app.rs` and `src/container_driver.rs`. The reviewer independently
rechecked the repair and affected tests, closed R1 and found no new issue.
No actionable finding remains.
## Checks and limits
Environment at execution: `rustc`/Cargo 1.97.1 on
`x86_64-unknown-linux-gnu`, rustfmt 1.9.0 and Clippy 0.1.97, matching the
pinned compiler. Focused implementer/reviewer runs covered `domain::` (13),
`runtime::` (25), `tui::state::` (7), `tui::events::` (7),
`--test commit_restore` (9), `--test runtime_restart` (3),
`--test tui_workflow` (8), and `--test client_delivery` (5). After R1, the
repairer ran `cargo test --locked app::` (58 passed) and
`cargo test --locked --lib container_driver::` (23 passed, 2 ignored). The
reviewer reran affected app/event tests. These are host synthetic/PTY checks;
ignored Alpine selectors were not run.
| Final coordinator command | Actual result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after M20-R1 repair |
| `cargo test --locked` | exit 0: 391 unit/integration tests passed, 3 ignored; 2 doctests passed; 0 failed |
| `cargo build --locked --release` | exit 0; artifact SHA256 above |
| `git diff --check` | exit 0 |
The initial Clippy run exited 101 on M20-R1 before repair and is not the final
gate result. Default tests did not execute the three explicitly ignored
Alpine/selected-daemon tests. No Alpine Testcontainers or disposable systemd VM
run was required for this naming/context refactor or performed. Prior Alpine
results are historical for earlier source and do not validate this artifact;
real systemd/D-Bus and IPv6 limitations remain as previously recorded. No
production, live VPN, host service or host network operation was performed.
+25
View File
@@ -0,0 +1,25 @@
# Milestone 21 evidence — domain reservations and apply knowledge
Run: 2026-10-29T23:00:09+02:00. Base commit: `8413b382b178991d2e1e597f1422d147f3454581` (clean at start). Final dirty source inputs: `src/domain.rs`, `src/app.rs`, and `docs/PROJECT_LAYOUT.md`; `PLANS.md` and this evidence record are coordinator documentation. `git diff -- src/domain.rs src/app.rs docs/PROJECT_LAYOUT.md | sha256sum` returned `d9056e1fd05db2425a127ac57f906333ae311bdb0f3cce13b9153f39c041c982`. `target/release/wireguard-admin` SHA-256: `60b3ad8b9c1bf2750239d3500b319cf42e5581fe027e5b6e39c8ff4115feb024`. `Cargo.toml` and `Cargo.lock` have no diff. Rust and Cargo: 1.97.1, pinned toolchain.
## Implementation and invariant evidence
- M21.1: `AddressReservations` owns private committed and draft-only pending host-address sets. Staging validates the entire exact `/32` or `/128` route list before mutation. The allocator sees both sets. Draft discard drops pending addresses; successful Commit promotes pending and retains addresses removed from the prior manageable saved snapshot. Persisted restoration is crate-scoped and rejects saved-client conflicts. No serialization field or version changed.
- M21.2: private `ApplyKnowledge` owns current runtime status and optional complete last-verified management snapshot. Historical verification remains separate in persisted application history. Session open resets current knowledge to `Unknown`; technical verification may publish `Verified` with no complete management snapshot. The restart scenario prepares a cloned verified candidate, then publishes it only after internal-state installation succeeds. Only that verified transition releases committed reservations. Backup, service, read, mismatch and persistence failure paths retain reservations.
- M21-R1: independent review found unconditional Commit demotion of `Verified` and `Unavailable` on unchanged saved client snapshots. The repair removed the extra application call; the domain Commit transition still demotes `Verified` when the saved management snapshot changes. Three behavioral app tests cover unchanged `Verified`, unchanged `Unavailable`, and changed `Verified`. The same independent reviewer re-reviewed the repair and full affected reservation/apply paths, closed M21-R1, and reported no remaining actionable finding.
## Validation on integrated source
| Check | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 399 passed, 3 ignored (opt-in Alpine drivers), 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
Focused implementation, repair and review runs covered `domain::`, `persistence::`, `app::`, `--test commit_restore`, and `--test runtime_restart`; the final full test command supersedes those focused source results. The independent reviewer specifically inspected reservation mutation/release callers, pending/committed timing, exact-host policy, unchanged/changed Commit behavior, reopen/current versus historical state, legacy technical matching without a management snapshot, and failed verified publication.
## Scope and acceptance
M21-A1: passed by reviewed transition callers and synthetic failure-path tests. M21-A2: passed by legacy/reopen/history tests and the M21-R1 behavioral repair. M21-A3: passed by allocator, reservation, persistence and application tests, independent re-review and all required Rust gates. Evidence is host/synthetic. The opt-in Alpine suites and disposable systemd VM were not run for M21, and no real systemd, live VPN, production or IPv6 integration result is claimed. M9 remains retired/unaccepted. The previously recorded unprojectable-peer restore reservation gap belongs to M23 and is not represented as resolved here.
+37
View File
@@ -0,0 +1,37 @@
# Milestone 22 evidence — coordinated draft editing
Run: 2026-10-29T23:14:00+02:00. Base commit: `2766c62` (clean at start). The integrated dirty source inputs are `src/app.rs`, new `src/app/draft.rs`, `src/domain.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce source identity with `sha256sum`:
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `a2ae18af4a5755397d86f36da3938baba01bc434da1b024aad64720267cebb7d` |
| `src/app/draft.rs` | `4b4c7b4ef9f8d673dad44d017badad8edbe1c73c19a74c1454008229e8fb1cbc` |
| `src/domain.rs` | `47740c902eee54053c1a4e46c7dacaf18204dc6366cb32d1189d6a04b293a516` |
| `docs/PROJECT_LAYOUT.md` | `500fd91848e5896153fa39a330131103a50a2f442e58ecfd6ad2bafc6ba9ba07` |
| `target/release/wireguard-admin` | `1b955bc2858924811043f48a66bfc7e2180ff0ecbc6d93b8b0339b791a9646e5` |
The native host toolchain was rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, persistence and archive code have no diff.
## Implementation and independent review
M22.1 added private `DraftChanges` to own the lossless document, narrower client states with pending address reservations, and encrypted vault draft. Its explicit staged replacement publishes selected prepared components without saved-file or runtime effects. M22.2 moved create/add/rename/delete, discard, initial vault setup and selected-file import through that boundary. Fallible document parsing/projection, state insertion, vault serialization/encryption/import and saved-state reading finish before publication. Vault copies are made through encrypted TOML only when a vault-changing operation needs one; document-only edits keep the current vault. A new client's session-first-delivery material is registered after successful draft publication.
The unrestricted `ClientStates::draft_mut` and test-only public `Application::vault_draft_mut` methods were removed after caller audit. `Application` facade entry points remain. Three behavioral app tests cover a late missing-vault creation failure, selected import failure, and discard parse failure without partial draft or issue-state mutation. Layout documentation now names the actual draft module and existing integration targets.
Fresh GPT-6-Luna/high reviewer `/root/m22_review`, separate from the implementer, inspected the base-to-current diff and relevant startup, TUI, integration and container-driver callers. The reviewer found no actionable issue. Review covered authority, secret lifetime, all publication callers, unsupported peers, source-file preservation, public API, fixture restart scope and unchanged formats/dependencies. No repair cycle was needed.
## Validation on integrated source
| Exact command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 401 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
The implementer and reviewer also ran the required focused suites: `cargo test --locked app::` (64), `cargo test --locked --test config_preservation` (10), `cargo test --locked --test client_delivery` (5), `cargo test --locked --test vault_import` (5), and `cargo test --locked --test tui_workflow` (8), all passed. The implementer additionally ran `cargo test --locked domain::` (15), passed. The final full test command supersedes the focused runs as integrated host evidence.
## Acceptance scope
M22-A1 is supported by staged publication and failure tests. M22-A2 is supported by byte-preserving document and import tests plus source review. M22-A3 is supported by synthetic app/TUI creation, Commit, first delivery, explicit fixture restart, deletion and discard coverage; draft and Commit flows make no live runtime call. M22-A4 is supported by the independent review and completion gates. This is host/synthetic evidence. The opt-in Alpine driver and disposable systemd VM were not run for M22; no real systemd, live VPN, production or IPv6 integration result is claimed. The M23 unprojectable-peer restore reservation gap remains assigned to M23 and is not claimed resolved here.
+37
View File
@@ -0,0 +1,37 @@
# Milestone 23 evidence — saved-state Commit, backup, and restore scenarios
Run: 2026-10-29T23:32:25+02:00. Base commit: `c1d4f93` (clean at start). Final dirty source inputs are `src/app.rs`, new `src/app/commit.rs`, `src/app/restore.rs`, `src/app/backups.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce identity with `sha256sum`:
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `9fe74372a68182dc24d7061b4409d3dd6970bc77e3528560da2cacd9d70876b2` |
| `src/app/commit.rs` | `e8af86c36613d91605c81c3dca82fb3d1b3b0dd9b41fdbfeaba9e84984fc091b` |
| `src/app/restore.rs` | `5dd96ee9650ce66e17378eebea2adf18da746264814f0f0d6258a6ebbda651e4` |
| `src/app/backups.rs` | `d837fb0dd29cd3affd9ae12233ecb0337e9a308d730b24a4c892c7bc2605e6ef` |
| `docs/PROJECT_LAYOUT.md` | `c9d873caf3c46d48392e6352b5aed036ce3fdaa471c66b4df8d92994c1a2f5ef` |
| `target/release/wireguard-admin` | `44444967d552bbb7bac2d36423cc943cde2ced783190946aae67f12e0283541e` |
Native host toolchain: rustc/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, `src/persistence.rs`, and `src/backup.rs` have no diff.
## Implementation and review
- M23.1 extracted Commit into a private scenario: prepare and validate proposed bytes, parsed document, and next client state; complete a pre-Commit archive of the installed generation; perform journaled install; then publish the prepared in-memory state. A failed install reads back conservatively to retain or revoke session-first-delivery rights. The public `Application` facade and narrow test callback remain. No service or runtime call is made by Commit.
- M23.2 extracted manual/pre-restart backup listing/creation and previewed restore. Preview is read-only and holds the exact installed generation and selected archive bytes. Confirmation rejects a stale generation before backup, prepares path-compatible settings/document/vault/state, completes a pre-restore archive, performs journaled full replacement, then publishes. An install failure clears session-only delivery rights and reports recovery uncertainty. Absent optional components and archive/historical labels retain their documented semantics. No vault password or runtime call is used.
- The M19 exact-host restore gap is repaired: removed unprojectable peers' exact IPv4 `/32` and IPv6 `/128` routes are retained as reservations through reopen and allocation. Broad routes are not expanded into guessed addresses.
- Fresh GPT-6-Luna/high reviewer `/root/m23_review`, separate from implementer `/root/m23_commit`, found M23-R1: newly carried routes were reserved even when still present in the restored document. GPT-6-Luna/high repairer `/root/m23_repair` separated archived reservations from carried current/pending addresses and excluded carried exact routes still owned by the restored document. Focused tests cover retained, removed, pending-deletion and archived-reservation cases. Independent re-review closed M23-R1 with no remaining code finding. M23-R2 identified that this evidence file was pending during the first review; it is now present for final verification.
## Integrated validation
| Exact command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 404 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
Focused implementer/repairer/reviewer runs covered `cargo test --locked app::` (final 67), `cargo test --locked persistence::` (15), `cargo test --locked backup::` (8), `cargo test --locked --test commit_restore` (9), and `cargo test --locked --test client_delivery` (5), all passed on the final code. The full test command supersedes those focused runs as integrated host evidence. The independent review inspected backup-before-install order, saved-only dependencies, path/generation binding, optional components, secret handling, recovery and preserved formats.
## Acceptance scope and limits
M23-A1 is supported by reviewed operation ordering and backup/install/cancel failure tests. M23-A2 is supported by restored settings/path, optional-component, recovery, historical-status and old-archive tests. M23-A3 is supported by fixture old-state backups, compatibility tests, the saved-only dependency audit, independent re-review and required Rust gates. This is host/synthetic evidence. No opt-in Alpine or disposable systemd VM run was made for M23; no real systemd, live VPN, production or IPv6 integration behavior is claimed. M9 remains retired/unaccepted.
+43
View File
@@ -0,0 +1,43 @@
# Milestone 24 evidence — session-owned delivery scenarios
Run date: 2026-10-30T21:23:54+02:00. Baseline commit: `ed602f0`; the working tree contains the uncommitted M24 source and documentation changes listed below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, host native target. Checks used synthetic fixtures. No production configuration, keys, VPN, or host service was accessed.
## Integrated inputs
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `a337a6c25db64f0a1422e0efa1bf67ecae06f7c59e74f6144d2964345d06117d` |
| `src/app/first_delivery.rs` | `0814de038423a0b8886d216aa3cf2b0913337595fbde4624da93bdb610f1df62` |
| `src/app/first_file.rs` | `770f833c99c42ac2b138a071bf4df26bbcaffcc431733380b846d600d8be8b6b` |
| `src/app/reissue.rs` | `2bf5b2f967c55f08d53b6d1e5d3d727d2774d5ad97454b2c4eb2425fdacdd563` |
| `src/app/commit.rs` | `eac859338dd11b8cb2cac8db2dc621d6862215998ad89701f6eadd6a6c2609d8` |
| `src/app/restore.rs` | `1ac76556d31d3f1502c644195c3f479fa092c321ebb53e671911a632ecc91f35` |
| `docs/PROJECT_LAYOUT.md` | `302f2a7eb16def7e2c0810780a3e9805caea416915737ce4ab4033900a379306` |
| `target/release/wireguard-admin` | `3aa6c56f32ed3da82c25c6da1f209c6dc55293998e497f57de0a2bd2c6c7a39a` |
No dependency, manifest, or lockfile changes. The release artifact hash identifies the binary built from the current integrated source; it is not a production deployment.
## Checks on integrated tree
| Command | Result |
| --- | --- |
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
| `cargo test --locked delivery::` | Exit 0; 22 delivery tests passed. |
| `cargo test --locked vault::` | Exit 0; 24 vault tests passed. |
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
| `cargo test --locked --test tui_workflow` | Exit 0; 8 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc suites passed. Ignored Alpine suites were not selected. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
## Acceptance mapping and review
- **M24-A1:** `FirstDeliverySession` privately owns keys, bound context, stages and tokens. Existing app and client-delivery tests pass for pre-Commit, stale, consumed and reopened rights, Commit promotion, discard/delete, and independent rights across rename/restart.
- **M24-A2:** App tests pass for retry to a different directory, uncertain publication and explicit overwrite/manual cleanup acknowledgement, post-publication bookkeeping uncertainty, activation warnings, same-payload file/QR, and independent QR failure. The new owner and authorization have no ordinary `Debug`, `Clone`, or serialization; public errors and projections remain redacted.
- **M24-A3:** Reissue reads installed saved state, unlocks the selected record with the password supplied for that action, renders authoritative server values, writes the mandatory file before optional QR, and retains imported-record mismatch checks. `client_delivery` and vault tests pass.
Fresh independent reviewer `/root/m24_review` examined the combined implementation and ran the focused suites, formatting, Clippy, full tests, release build and diff check. No source correctness or security finding remained. **M24-R1** was a missing evidence record named by `PLANS.md`; the coordinator created this file after independently rerunning the required checks. Independent re-review of this record and its source hashes remains the next action before acceptance.
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; Alpine evidence would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M24.
+39
View File
@@ -0,0 +1,39 @@
# Milestone 25 evidence — explicit restart verification scenario
Run date: 2026-10-30T21:35:10+02:00. Baseline commit: `7cef400`; the working tree has uncommitted M25 changes in `src/app.rs`, new `src/app/restart.rs`, and `docs/PROJECT_LAYOUT.md`, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures/adapters only. No production configuration, keys, VPN, host service, Docker environment, or VM was accessed.
## Integrated inputs
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `784001013f90d9bbdb1579cf3c6672a27dd34dc0e295d3e6ea3f70d53d05c586` |
| `src/app/restart.rs` | `fb724054faec6adf1b58a31800a2a636307b2620ac3d980309638ad508a00fa3` |
| `docs/PROJECT_LAYOUT.md` | `8f156088800e469e6bbf7ac0f012afdb4bf1f190ab4c9161fb3c41d871e04df2` |
| `target/release/wireguard-admin` | `b383a1f30e776dcce983bb123af6f4e474e71d6b3d28866f3a06ba3c423228b7` |
No dependency, manifest, lockfile, service-adapter or runtime-transport files changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
## Checks on integrated tree
| Command | Result |
| --- | --- |
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
| `cargo test --locked runtime::` | Exit 0; 25 runtime tests passed. |
| `cargo test --locked service::` | Exit 0; 26 service tests passed. |
| `cargo test --locked --test commit_restore` | Exit 0; 9 tests passed. |
| `cargo test --locked --test runtime_restart` | Exit 0; 3 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc tests passed. The selected Alpine tests stayed ignored. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
## Acceptance mapping and review
- **M25-A1:** `restart.rs` contains the preview, validated preparation, completed pre-restart backup, one-use attempt, optional draft discard, service job, bounded runtime read, full comparison and durable publication in the original order. Cancel preserves draft and performs no backup/job/read. The service deadline is created after backup and shared with observation; both saved generation and attempt ID are checked before persistence.
- **M25-A2:** Existing app/runtime/service tests cover legacy metadata-free, IPv4-only, subnet-route and missing-IPv4 valid peers; malformed or duplicate identities, incomplete observations, mismatches, service and transport distinctions, shared deadline exhaustion, and post-match persistence failure. `SavedPeerProjection` remains the complete technical target while a management `Snapshot` is optional. Reservations release only after durable verified success.
- **M25-A3:** `Application::restart` still calls the existing `service::restart_systemd_unit_report` and `runtime::transport::observe_interface` adapters. The fixture apply method and container driver remain `#[cfg(test)]`; no rollback or second restart was introduced. `app` public restart types remain re-exported.
Fresh independent reviewer `/root/m25_review` examined the integrated source and original behavior, verified all five focused suites and Rust gates, and found no actionable source defect. **M25-R1** was the missing specified evidence artifact; the coordinator created this report after independently running all required commands. The reviewer independently rechecked the record and all four hashes and closed M25-R1; no finding remains.
This is host/fixture evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run, so real systemd/D-Bus and live IPv6 behavior remain unverified. Alpine would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M25.
+46
View File
@@ -0,0 +1,46 @@
# Milestone 26 evidence — application read model and TUI command adapter
Run date: 2026-10-30T22:59:07+02:00. Baseline commit: `9a143e9`; the working tree contains uncommitted M26 source, test and layout changes below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures, buffer rendering and PTY workflows only. No production configuration, keys, VPN, host service, Docker environment or VM was accessed.
## Integrated inputs
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `5aeab061f45a75f3b4f4ec41b078cbecce3cbdd5a49ec2c47c0a98f062747023` |
| `src/app/read_model.rs` | `92a1945ca0ad0d4f6dc7aad255643d3d515712a1750c466738d29655f69d282d` |
| `src/app/first_delivery.rs` | `b9e80c7802b083f0ffa6e1e99898e9be9459e9eb9f3ea415b00e5592020a6ef3` |
| `src/tui/state.rs` | `da429e61bf918bf8524b7d131bc553460eb260f76f39d3763824e399764dcfac` |
| `src/tui/terminal.rs` | `9131ef9ae1e4d6f8a84715cdcdfcdd528262930dc95834991916484ff5ce4e21` |
| `src/tui/render.rs` | `3fa3b6496b792f3d7d3db16405a847c91736f2e975e5dba892d6921ca367df19` |
| `src/tui/events.rs` | `114c9505e69a1e9ec0782b149daf2ff9dcdc1a29af99abdb28df91fee70b758c` |
| `src/tui/events/executor.rs` | `4a46a8b03b1f708ab89d2e50ceceb60674c30e3bc1bcbb4c87af93b57815cd57` |
| `tests/tui_workflow.rs` | `8050c2e7887366ca6145c62fa8ded25b9dcd36f01062afa85ce85518ce64733f` |
| `docs/PROJECT_LAYOUT.md` | `06b83f3c0a26de4a7c3f8ccbaf1d37a697afb17cf67c297b69326018d144262c` |
| `target/release/wireguard-admin` | `70b45b51e2e345e1d914b1196355fde9f5688f123bed47332690d56752ca5cbe` |
No dependency, manifest or lockfile changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
## Checks on integrated tree
| Command | Result |
| --- | --- |
| `cargo test --locked app::` | Exit 0; 74 app tests passed. |
| `cargo test --locked tui::` | Exit 0; 88 TUI tests passed. |
| `cargo test --locked --test tui_workflow` | Exit 0; 9 tests passed. |
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and doc tests passed. Opt-in Alpine scenarios stayed ignored. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
## Acceptance mapping and review
- **M26-A1:** `Application::read_model` owns the secret-free peer rows, saved peer count, draft/saved public client changes, issue classes, reservations, applied knowledge, vault/draft flags and per-action eligibility. `SessionView::from_application` consumes those facts and maps them into compatibility UI fields without accessing saved server bytes or `ClientStates`. Malformed, duplicate and no-key draft peer rows remain available for inspection, and removed saved peers outside manageable `ClientStates` remain visible.
- **M26-A2:** Selection binds action presentation to public identity and peer index. App eligibility separates rename, deletion, first delivery, reissue and restart; execution still checks its own preconditions. Exact-host-route deletion remains available even for a peer outside the manageable client projection, and the removed row is inspection-only. First-delivery session availability is queried without secret disclosure. Unrelated saved-document issues do not suppress a valid selected reissue; selected-peer issues and vault conflicts do.
- **M26-A3:** TUI, workflow and delivery suites cover worker generations, stale continuations and statistics, shutdown, QR, selection, list/help/footer buffers and synthetic PTY behavior. Existing dimensioned tests include 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50 cases. No assertions were weakened to pass the refactor.
- **M26-A4:** `src/tui/events/executor.rs` owns command adaptation and prepared destination/restore contexts; `events.rs` keeps the single-owner worker queue and generation lifecycle. `docs/PROJECT_LAYOUT.md` matches the integrated modules. Broader terminal/render splitting was deferred because it would broaden visibility across shared lifecycle and layout helpers without a cohesive ownership gain in M26.
Fresh independent reviewer `/root/m26_review` reviewed the integrated diff. M26-R1 (removed unprojectable peer omitted) and M26-R2 (unrelated issue overblocked reissue) were fixed by repairer `/root/m26_repair`. Coordinator found M26-R3 (false Removed row for unchanged saved client) in the repair; it was fixed with a one-row regression. Reviewer found M26-R4 (reissue shown for selected capability-issue peer); the repair aligned eligibility with delivery validation and added a failing-before/passing-after regression. The reviewer independently re-reviewed and closed R1–R4 with no remaining source finding, then verified all 11 evidence hashes, reran the full test suite, app tests and release build, and found no evidence issue.
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; prior Alpine results do not identify this M26 source, and Alpine does not prove systemd behavior. Real systemd/D-Bus and live IPv6 behavior remain unverified. The retired M9 real-system limitation remains outside M26.
+54
View File
@@ -0,0 +1,54 @@
# Milestone 27 evidence — integrated refactoring handoff
Recorded 2026-10-30T23:18:12+02:00. Baseline HEAD: `7123197ec4a3fad74b71f5c7b598f1d6fd74d3a1`, initially clean. Final working tree is uncommitted and changes `CONTEXT.md`, `PLANS.md`, `docs/PROJECT_LAYOUT.md`, `docs/architecture/domain-refactoring.md`, `src/app.rs`, `src/app/backups.rs`, `src/app/first_file.rs`, `src/app/restart.rs`, `src/runtime/netlink.rs`, and `src/service.rs`. No Cargo manifest, lockfile, dependency, feature, persisted codec, archive, vault, settings-default, WireGuard editor, or fixture file changed in M27. Rust/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97, native host target. All tests below used synthetic fixtures/PTY only; no production configuration, keys, VPN, host service, Docker daemon, or VM was accessed.
## Final inputs
| Input | SHA-256 |
| --- | --- |
| `CONTEXT.md` | `7dcfa4d86f70fb850f3416d6659275ee28237b5075c5beed82d2a33ad44150a4` |
| `docs/PROJECT_LAYOUT.md` | `f973207e71f20c6c912105c16546a51150acf63551a7f196ecd38d2101e9a44b` |
| `docs/architecture/domain-refactoring.md` | `45ad18a43b224f318b0a7e55548d47f7ccea8e2e719e3378ef35f419e3ec8391` |
| `src/app.rs` | `e235ff788c1b5f1ac0ac9951a9c4bb578205dfc65903d3ae80ed9f3142fb38da` |
| `src/app/backups.rs` | `b3b01289b20e262c24cfce79c507fe4d3761102df4863a11b3a90b2398b0692f` |
| `src/app/first_file.rs` | `d41fa3e2d2150c401ceee9caaeb7268b741a970105e5266123334cd78d977073` |
| `src/app/restart.rs` | `5d26cb877fd8a8211549e15868ccd450729ce3ecb54ea954efd15b379f5a2ce0` |
| `src/runtime/netlink.rs` | `396f4726880c7cf54764d6ff0c67aa1ab2d6c66cd36af7b1be4ca0e04862bab3` |
| `src/service.rs` | `e7c7a4367e2a0dc8a87113e3083d8998e7aebb56f6d4c32d5c763b9f239fa8eb` |
| `target/release/wireguard-admin` | `cb6e995d64a7cb7537c5fb17e86d609ca62fff150f519a970febd0800b0d4177` |
Source-input fingerprint from `rg --files src tests/support tests/containers tests/testcontainers_wireguard.rs Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum`: `1b746e31e2e8c96f511c34b51e417b486d65fa0cc17983fa15e12882dd9c6a02`. This includes the final test-only backup visibility repair and identifies the host-tested source independently of the dirty plan/evidence files.
## Architecture and preservation audit
M19's baseline source was recorded against `23d6180f2478f636c185d67b8bb3788d21656ce3`. The coordinator inspected `git diff --stat` from that commit through M26 HEAD and the changed production/format-sensitive file list; that diff spans the M20–26 application/domain/TUI moves and changes only persistence test references, not its codec. `Cargo.toml`, `Cargo.lock`, settings defaults, backup/vault/byte-preserving editor implementation, and synthetic fixture files did not change in that baseline-to-M26 diff. The independent reviewer inspected the integrated refactor against the M19 map, current source and shared preservation matrix, including authority, public identity, unsupported/ambiguous peers, original imports, retained plaintext discipline, short-lived delivery secrets, saved/runtime separation, restart uncertainty, address release, TUI eligibility/generation and public visibility. No unresolved behavior, authorization, or secret-handling regression was found. Historical M19 restore-reservation gap was closed by M23 and is now mapped to actual tests and owner.
The architecture document now gives the three requested end-to-end reading paths with entry point, rule owner, side-effect boundary and failure tests, and resolves each M19 owner/transition to actual M20–26 modules/tests. The glossary remains domain-only and the layout lists present files. `ClientStates::draft_mut` and `Application::vault_draft_mut` were already removed. `Application::open`, `add_client`, and `display_projection` have active unit/integration or test-only container callers and remain with explicit caller/retention rationale; production startup/create uses the intended methods. A fixture-only pre-restart backup facade and its helper were restricted to private `#[cfg(test)]` code. Stale comments describing implemented confirmation, D-Bus and netlink flows as future work were corrected. No test assertion was weakened.
Existing preserved-format and behavior coverage includes the focused `config_preservation`, `commit_restore`, `client_delivery`, `runtime_restart`, `vault_import`, and `tui_workflow` suites below, plus persistence, archive, vault, domain, service and application tests in the full suite. Old fixture inputs still load with unchanged meaning under these host checks; this is synthetic compatibility evidence, not a new real-system run.
## Integrated checks
All commands ran in the repository root after the final source repair. Exit 0 unless specified.
| Command | Actual result |
| --- | --- |
| `cargo test --locked --test config_preservation` | 10 passed, 0 failed |
| `cargo test --locked --test commit_restore` | 9 passed, 0 failed |
| `cargo test --locked --test client_delivery` | 5 passed, 0 failed |
| `cargo test --locked --test runtime_restart` | 3 passed, 0 failed |
| `cargo test --locked --test vault_import` | 5 passed, 0 failed |
| `cargo test --locked --test tui_workflow` | 9 passed, 0 failed |
| `cargo fmt --all -- --check` | Exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0 |
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and 2 doc tests passed; opt-in Alpine tests stayed ignored |
| `cargo build --locked --release` | Exit 0; artifact hash above |
| `git diff --check` | Exit 0 |
The first final-gate attempt after making the fixture facade test-only found Clippy dead code in its child helper. The repair made the child helper test-only too; the entire table above was rerun on that final source. The initial failed attempt is diagnostic history, not a passing gate.
## Independent review and limits
Fresh reviewer `/root/m27_review` was separate from implementer `/root/m27_implement`; repairer `/root/m27_repair` made bounded fixes. R1 (missing `display_projection` caller audit), R2 (stale plan resume point), R3 (future-work source comments), and R4 (fixture-only public backup API) were repaired and re-reviewed. R4's first repair exposed the Clippy dead-code helper; the second repair and final gates closed it. The reviewer independently verified the final source, fingerprint and binary hashes, reran formatting, Clippy, full tests, release build and diff check, and confirmed M27-A4 plan/evidence consistency after the register correction. No scoped review finding remains.
M8's last Alpine result used fingerprint `b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`; today's final source fingerprint differs. The M8 run is historical and **does not validate this source**. No explicitly selected current disposable daemon/driver was provided for M27, so Alpine revalidation is incomplete and no Alpine pass is claimed. The retired M9 disposable-VM/systemd checks remain unverified; Alpine would not prove actual systemd/D-Bus behavior. Real IPv6 capability/traffic and release readiness are also unverified. M27 acceptance is limited to host/synthetic scope.
+134
View File
@@ -0,0 +1,134 @@
# Milestone 8 reproducible evidence
This report records disposable Alpine fixture results. It does not claim
production or systemd/D-Bus behavior. Current acceptance state is in
[PLANS.md](../../PLANS.md); superseded attempts are in the
[history](../history/milestone-8-before-workflow-cleanup.md) and focused
[diagnostics](../diagnostics/milestone-8.md).
## M8.5 import/reissue and preceding scenarios
- Recorded: 2026-10-24T23:14:00+03:00. Run occurred earlier on 2026-10-24;
an exact start timestamp was not captured.
- Source identity: Git HEAD
`2ccd67d64cd9ef4210ac3dc369473b3849319649` with dirty tracked and
untracked M8 files. The reproducible input fingerprint is SHA256
`e7aeaf13444ac6350db66cd6c34fb3a46f4ee0723eb18b8ebe0b0054e1672fd0`.
It was identical before the pinned driver build and after the real run. The
fingerprint was generated by:
```sh
rg --files src tests/support tests/containers tests/testcontainers_wireguard.rs Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum
```
- Builder: `rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`,
target `x86_64-unknown-linux-musl`; Rust 1.97.1. The locked, offline driver
build passed:
```sh
docker run --rm --platform linux/amd64 --mount type=bind,src=/home/sasa/share/rust/wireguard-admin,dst=/src,readonly --mount type=bind,src=/tmp,dst=/out -e CARGO_TARGET_DIR=/out/wga-m8-build-target -e CARGO_HOME=/out/wga-m8-cargo-home -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --offline --lib --no-run --target x86_64-unknown-linux-musl
```
- Driver: `/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031`,
SHA256 `e250d8eb60db0ad262b404624fa7f9d984f807d8688c055e9f8629178c00b151`.
- Runtime image: `wireguard-admin-alpine:3.22.7`, image ID
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`,
Linux/amd64. Selected disposable local Docker daemon:
`unix:///var/run/docker.sock`, ID
`1baad80e-d79d-49ab-b19a-fb00fd3662d7`, Docker Server 29.1.3,
host Linux `6.18.18-gentoo` x86_64.
- Exact selected-daemon command:
```sh
DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1
```
- Result: exit 0, 1 passed, 0 failed, 0 ignored, 25 filtered out, 82.06s.
The integrated scenario exercised existing and new IPv4 peers, Commit and
delivered-file traffic, saved deletion and explicit fixture Apply, two
previewed password-free restores with saved/runtime separation, and import
of matched/conflicting/invalid private sources followed by password-gated
reissue and traffic from the reissued bytes. It used the real Rustix reader
for peer/routes/handshake/counter assertions. Exact-name Docker container
and network listings were empty after the run.
- Independent disposition: M8.5-IMP-01 closed on re-review after checking all
three source-file byte comparisons and the regression for a changed invalid
candidate. Earlier traffic, deletion and restore slice findings were closed
independently as recorded in the plan.
- Limit: this is one Alpine fixture result for the fingerprint above. Later
source edits invalidate it as final integrated evidence. User-directed IPv6
capability/traffic tests were skipped. No real systemd job was exercised.
## M8.5 mandatory-backup failure
- Source-input fingerprint: SHA256
`b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`, stable
before the pinned driver build and after the real run. Driver SHA256:
`ad45dc3f6b0987b075ac522e171c82c6651124c3d01c65c14df90595fe552815`.
- Builder: `rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`.
Runtime image: `wireguard-admin-alpine:3.22.7`, ID
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`.
Selected disposable daemon: `unix:///var/run/docker.sock`, ID
`1baad80e-d79d-49ab-b19a-fb00fd3662d7`.
- Exact selected-daemon command:
```sh
DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1
```
- Result: exit 0, 1 passed, 0 failed in 81.45s. The scenario required a typed
backup error and verified unchanged saved/runtime state, no fixture apply,
retained address reservation, and the expected reader missing-interface and
deadline errors. Exact-name checks found no remaining test containers or
networks.
- Limit: this was a completed M8.5 slice result before the final gates and
repeated full scenario runs recorded below. IPv6 capability/traffic was
excluded by user direction; no real systemd job was exercised.
## M8.7 integrated gates and repeated Alpine scenario
- Recorded: 2026-10-25T20:40:51+03:00. Git HEAD remained
`2ccd67d64cd9ef4210ac3dc369473b3849319649` with dirty milestone files.
The source-input fingerprint command above returned
`b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`
before the rebuilt driver and after all three successful real runs.
- The pinned Alpine runtime image rebuilt entirely from cached layers (ID
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`,
linux/amd64). Networkless, read-only, capability-dropped readiness exited 0;
`apk info -v` listed the 33 constrained packages. The pinned Rust 1.97.1
Alpine builder produced the x86_64 musl test driver in 1m38s using the host's
cached public Cargo registry mounted read-only after the earlier `/tmp` cache
disappeared. The static PIE driver's SHA256 was
`ad45dc3f6b0987b075ac522e171c82c6651124c3d01c65c14df90595fe552815`.
The exact rebuild command was:
```sh
docker run --rm --platform linux/amd64 --mount type=bind,src=/home/sasa/share/rust/wireguard-admin,dst=/src,readonly --mount type=bind,src=/tmp,dst=/out --mount type=bind,src=/home/sasa/.cargo/registry,dst=/out/wga-m8-cargo-home/registry,readonly -e CARGO_TARGET_DIR=/out/wga-m8-build-target -e CARGO_HOME=/out/wga-m8-cargo-home -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --offline --lib --no-run --target x86_64-unknown-linux-musl
```
- Integrated host gates on this source revision: `cargo fmt --all -- --check`,
`cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`,
`cargo build --locked --release`, release `--help` and `--version`, and
`git diff --check` all exited 0. Default tests included 258 passing library
tests with two explicitly ignored driver tests, five delivery, nine
Commit/restore, nine configuration, two runtime, 25 host harness with one
ignored container scenario, six TUI, five vault import, and two doctests.
Default tests did not execute the real-container scenario.
- The selected-daemon command in the preceding section ran twice against this
fingerprint with fresh mutable resources: exit 0, 1/1 passed each time,
80.77s and 80.77s. The documented unfiltered
`cargo test --locked --test testcontainers_wireguard -- --ignored --test-threads=1`
form then ran with the same `DOCKER_HOST` and `WIREGUARD_ADMIN_TEST_*` values:
exit 0, 1/1 passed in 81.87s; 25 nonignored host tests were filtered out.
Each run exercised the full M8.5 scenario, including real IPv4 traffic and
Rustix observations, saved/runtime separation, deletion and verified
reservation release, both restores, import/reissue, and mandatory-backup
failures. Exact-name container and network listings were empty after each.
- Independent integrated review found no code defect. Its R1 stale testing and
evidence statements, R2 general IPv6 gate, and R3 stale milestone summary
were corrected and each closed on independent re-review. M8 acceptance state
is recorded in PLANS.md.
- Scope: fixture Apply is test-only and does not prove a systemd/D-Bus job.
IPv6 capability and traffic were deliberately not tested by user direction;
production dual-stack behavior is still specified and unverified here. No
production configuration, key, or service was accessed.
+47
View File
@@ -0,0 +1,47 @@
# TUI new-client creation timestamp repair
Validated on 2026-10-27 at 20:39 +02:00. Bounded maintenance task
TUI-TS-001 repairs the new-client form path from the accepted M7 workflow.
No production, Alpine, or VM execution was performed.
## Behavior and review
The form now clears nanoseconds on its UTC instant before RFC3339 formatting.
The metadata renderer and its exact `YYYY-MM-DDTHH:MM:SSZ` validation are
unchanged. A deterministic test supplies a fixed UTC instant with nonzero
nanoseconds, checks its exact whole-second output, and sends that output to
the metadata renderer. A synthetic workflow enters an alias and blank IPv4 and
IPv6 form fields, submits the resulting command through the application worker,
and observes a dual-stack draft client. It checks that saved settings, server
configuration, internal state, and vault bytes are unchanged. The test submits
no Commit or restart command. Independent GPT-6-Luna/high reviewer found no
actionable scoped findings after examining the actual diff and running both
focused regressions.
## Integrated checks and identity
Native `x86_64-unknown-linux-gnu` host; rustc/Cargo 1.97.1, rustfmt 1.9.0,
Clippy 0.1.97. Commands below ran from the repository root and exited 0:
- `cargo test --locked creation_timestamp_discards_fractional_seconds_and_renders_as_valid_metadata`: 1 passed.
- `cargo test --locked --test tui_workflow create_form_submits_automatic_dual_stack_client_without_changing_saved_state`: 1 passed.
- `cargo fmt --all -- --check`.
- `cargo clippy --locked --all-targets -- -D warnings`.
- `cargo test --locked`: 345 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests.
- `cargo build --locked --release`.
- `git diff --check`.
Base commit: `6d8d6d572df73a534b9a8dc7c5af769f2447bcfa`. Dirty Rust
inputs are the two files below; documentation changes record this repair.
SHA256 of changed Rust files:
- `src/tui/forms.rs`: `322153ae33e85b8dc59d887ff7810751f15e2326ce77e7f1105c9aab947fe9be`
- `tests/tui_workflow.rs`: `9f14c4bf9b80e65c3f6f5ccae93192015d738590b672f11d26aa824feb103438`
Native artifact `target/release/wireguard-admin` SHA256:
`d59801b0de32038ae11bbfed3d4bd01f4c36fe4119e79f3b332fec5462793687`.
These fixture results do not verify a physical terminal, Alpine networking,
or real systemd/D-Bus behavior. The previously retired M9 acceptance remains
unverified. Previous Alpine and VM source/artifact identities do not identify
this revision.
+55
View File
@@ -0,0 +1,55 @@
# TUI-FL-001 evidence: stable keyboard footer
Validated 2026-10-27T22:59:34+02:00 on the native
`x86_64-unknown-linux-gnu` development host with synthetic
Ratatui/TestBackend fixtures. No production configuration, host service, or
systemd VM was used.
## Behavior and review
The standard TUI layout now reserves four footer rows from the first draw:
two hint rows and their borders. The client-list region therefore keeps the
same height as selection and available actions change. Hints use a stable
priority order and fit into two bounded rows. A first packing pass shows every
hint when they fit; only actual overflow triggers a second pass reserving a
visible `… more` marker on the last row. The existing `? Help` route remains
visible in narrow list layouts. The source of context-sensitive hints and the
keyboard action handlers were not changed. Terminals with 8 or 9 rows use the
compact layout so the client pane retains visible content.
Buffer-level tests cover empty/populated/selected list geometry at 80×24,
narrow widths 34/36/40, the compact 80×9 case, and widths 102/103 where all
actions fit without a false omission marker. Tests check complete rendered
labels and the marker row, not just source-side packing. Existing TUI workflow
tests cover the unchanged action behavior.
An independent GPT-6-Luna/high reviewer found TUI-FL-001.2-R1 (P2, clipped
marker on the first row) and R2 (P2, border-only list at height 9). A separate
GPT-6-Luna/high repairer fixed both; re-review closed them. Re-review then found
R3 (P3, unnecessary omission from reserving marker space too early). The
repairer added a two-pass packer and a 102/103-column regression. Independent
re-review closed R3 with no further actionable finding.
## Checks and identity
Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
The final integrated revision passed from the repository root:
- `cargo test --locked tui::render::tests`: 31 passed.
- `cargo fmt --all -- --check`: exit 0.
- `cargo clippy --locked --all-targets -- -D warnings`: exit 0.
- `cargo test --locked`: 370 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests.
- `cargo build --locked --release`: exit 0.
- `git diff --check`: exit 0.
Base commit: `4b53aad20cff6cec5d23f779702b58f0de599d44`.
Dirty Rust input `src/tui/render.rs` SHA256:
`ceae0bec7add49304a38f38987fdac554fc83e9d7d23695cf148f2f8404d3016`.
Native release artifact `target/release/wireguard-admin` SHA256:
`a16214c426b0cc9b29ec03124e965352e4f64f07c82d3ac23e4caf8e45d6273b`.
`PLANS.md` was also dirty with this maintenance record. The M16 private-systemd
validation gap remains separate and unverified; no Alpine or VM suite was rerun.
Very narrow layouts intentionally show only priority hints plus the Help
affordance; additional shortcuts remain available through the existing Help
screen. A physical-terminal visual check was not performed.
+42
View File
@@ -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.
@@ -0,0 +1,702 @@
# Milestone 8 historical execution record
Archived on 2026-10-24 during documentation workflow cleanup. This is a
historical snapshot, including superseded and contradictory status statements.
Use [PLANS.md](../../PLANS.md) for current state. Evidence below is preserved
as previously reported; no checks were rerun by this archival operation.
### Milestone 8 — Alpine Testcontainers integration
Status: in progress (2026-10-22; user removed IPv6 capability and traffic
testing from M8. The selected disposable daemon remains the test environment;
M8.4–M8.5 and real-container acceptance remain unrun).
Prerequisites: milestones 1–7 accepted; pinned Rust toolchain; an explicitly
selected disposable Docker-compatible Linux environment with kernel WireGuard,
scoped NET_ADMIN and IPv4 support; an Alpine-compatible test executable.
Missing prerequisites are blockers/incomplete evidence, never passing tests.
#### Outcome and scope
Add an opt-in Rust Testcontainers suite using small Alpine server/client images.
Exercise the actual application, encrypted vault/persistence/delivery APIs, and
M5's real rustix Generic Netlink reader inside the server container against real
kernel WireGuard interfaces. Establish real tunnels, generate IPv4 traffic,
and prove that saved changes remain separate from runtime application.
The user explicitly excluded IPv6 capability and traffic tests on 2026-10-22.
Production dual-stack behavior in SPEC.md is unchanged; real IPv6 behavior is
therefore unverified by this milestone.
Read SPEC.md Operations and states, Connection delivery, Backups and restoration,
Client screen and statistics, and acceptance criteria; ADRs 0001–0003; TESTING.md,
ENVIRONMENT.md, RUNTIME.md, DELIVERY.md, VAULT_FORMAT.md, PERSISTENCE.md,
BACKUP_FORMAT.md, DEPENDENCIES.md and current application/worker code.
This is real-WireGuard integration, not real-systemd acceptance. Alpine fixtures
have no systemd; a test-only apply operation may configure the interface explicitly.
Actual zbus service job behavior, production restart and VM acceptance remain M9.
M7's synthetic TUI/PTY evidence remains separate; M8 does not require live terminal
screens, phone QR decoding, public Internet access, external DNS, production
configuration, new product commands, or a production apply mode.
#### Existing code and integration constraints
- M1–M7 are accepted. The normal executable now starts the management TUI; do not
invoke it with host/production settings as a shortcut for a test driver.
- Application has open-from-store, setup/import, creation, Commit, delivery/reissue,
deletion, backup/list/restore and confirmed restart flows. Use these real APIs,
not a parallel shell-only client manager that would leave application behavior
untested. Public-key identity and source preservation remain authoritative.
- Real observation is `runtime::transport::observe_interface`, currently crate-
private, with the validated decoder and bounded rustix transport. Do not use
only `wg show` output or a fake observation as evidence that this reader works.
- `Application::restart` always invokes the actual zbus path, while injected
restart functions are private. Plan an in-crate test seam so a test-only fixture
service operation can apply peers and the real observation/persistence path can
verify them. Preserve backup ordering and per-attempt authority from M5.
- Prefer cfg(test) in-container test scenarios/driver code, compiled for musl and
selected explicitly, to reach private adapters without exposing an unauthenticated
public apply API. If another arrangement is needed, document its compile-time
isolation before implementation. No environment variable may switch production
restart to a fake service or bypass password/backup/root checks.
- The TUI ApplicationWorker already has synthetic workflow tests. An optional
in-container worker scenario may test its real statistics reader, but network
acceptance must not depend on automating every terminal key sequence again.
- No Testcontainers crate, container image recipe or integration driver is present.
Add approved `testcontainers` as a dev-dependency with compatible pinned version
and minimal features. Prefer its supported synchronous facade if it avoids a
direct executor dependency. Other direct helpers, Docker client crates or
Tokio still require prior agreement; transitive dependencies are not new direct
approvals. Use Cargo.lock and inspect official APIs for the selected version.
- Native tools use system Rust 1.97.1; musl readiness was previously unverified.
Recheck rather than assuming rust-toolchain.toml installed targets or a linker.
#### Context and implementation steps
Use GPT-6-Sol/medium coordination and GPT-6-Luna/high implementation, fresh review
and repairs. Execute bounded tasks sequentially with one writer and explicit file
ownership. The coordinator alone updates milestone acceptance and evidence.
| Task | Owner | Files and deliverable |
| --- | --- | --- |
| M8.1 | implementer | `docs/TESTING.md`/ENVIRONMENT additions and test support: exact opt-in architecture, prerequisites, image/build and driver contracts |
| M8.2 | implementer | `tests/containers/alpine/Dockerfile`, fixture-only setup scripts, musl build recipe: small reproducible cached image and executable compatibility checks |
| M8.3 | implementer | `tests/testcontainers_wireguard.rs`, `tests/support/` modules and Cargo files: host orchestration, private network/container ownership, prerequisite checks, bounded execution and cleanup |
| M8.4 | implementer | in-crate cfg(test) scenarios/private adapters, test driver coordination: real netlink reads, fixture apply and application state verification inside server |
| M8.5 | implementer | container scenarios and TESTING/README documentation: required lifecycle, restore, delivery/import/failure assertions and executed evidence |
| M8.6 | reviewer, repairer | Independent isolation, real-adapter coverage and negative-test review; bounded fixes and real-suite re-review |
| M8.7 | coordinator | Default and explicit container gates, redacted results, prerequisites/limitations, acceptance and M9 handoff |
M8.1 defines two execution locations: host harness uses Testcontainers to manage
containers; server-side code uses the real application and kernel reader in that
container's namespaces. Document how driver sessions persist across staged create,
Commit, first delivery and apply phases, and how the client is configured from the
actual delivered file. Do not lose the creation session between Commit and first
issue or reconstruct password-free authority on reopen.
Choose a named ignored integration target/scenarios or another explicit opt-in
mechanism that default cargo test never starts. The host suite must also opt into
all in-container scenario processes. Ensure --ignored does not select unrelated
privileged tests. Fail an explicitly requested suite nonzero on missing required
prerequisites; never return early with a successful test result labelled skipped.
M8.2 pins an Alpine base release/digest and records the resolved image identity
and package versions. Install only wireguard-tools, iproute2 and minimal fixture
requirements. Use BusyBox capabilities when sufficient; justify any extra package
in the test recipe. Runtime images contain no Cargo registry/compiler or systemd.
Build/cache image layers and compatible driver artifacts before repeated tests;
use fresh mutable containers/volumes for each scenario, not reusable dirty servers.
Keep build context small with explicit copies/ignore rules, no .git, host Backup,
credentials or private configuration. Never bake generated keys into image layers.
Build server/client driver code for x86_64-unknown-linux-musl or in a pinned
Alpine builder matching the toolchain. Inspect dynamic requirements and actually
execute a harmless readiness command inside Alpine. Do not rely on a glibc host
binary, target name or successful host compilation as runtime compatibility proof.
Only build required test targets; avoid pulling compiler/debug layers into the
small runtime fixture. No global package installation or toolchain update is
implicitly authorized by a missing cross-build prerequisite.
M8.3 checks Docker API access and explicit disposable-environment selection.
Do not use a configured remote daemon without establishing that it is the selected
non-production test environment. Probe WireGuard interface creation inside
a disposable probe container, never by creating host interfaces or loading modules.
Container tools alone do not supply the host kernel module. Refuse to repair
prerequisites via SYS_MODULE, privileged mode or host sysctl changes automatically.
Create a dedicated uniquely named IPv4 network with a test subnet that does
not overlap the tunnel ranges. Run server/client as container root with scoped
NET_ADMIN in their own network/PID namespaces; no host networking, namespace joins
to host, host D-Bus, host /etc/wireguard, Docker socket mount, or production binds.
No host UDP publication is needed for container-to-container endpoints. Use
container-internal reachable endpoint/port values, not localhost mapped ports.
All settings/server/vault/output paths are private fixture directories created
with the application's required permissions.
Use Testcontainers copy/exec APIs or an explicitly safe bounded transport for
fixture data. Secret-bearing configs/passwords never enter command arguments,
image metadata, public environment variables or raw stdout/stderr logs. Any
necessary copied plaintext exists only in private temporary/container storage.
Generate keys per scenario. Reports carry public keys, phase/status and redacted
categories, not full configurations, QR matrices or `wg show all dump` output.
Bound image/start/driver/readiness/traffic/teardown operations. Poll explicit
conditions with monotonic deadlines, not fixed sleeps. Use unique ownership and
RAII/explicit cleanup for containers, networks and volumes on failures as well as
success; cleanup must not mask the original failure or delete unrelated resources.
Do not default to retain-failed-containers with secrets. Report leftover owned
resources if daemon failure prevents cleanup.
M8.4 keeps fixture `ip`/`wg` calls solely in setup or test-only apply code. It may
use wg-quick strip or equivalent explicit fixture setup, but must not execute
arbitrary imported hooks. Handle optional DNS/MTU fields in delivered configs via
a documented fixture setup, without mutating the delivered payload just to pass.
Avoid an unneeded resolvconf dependency by configuring networking explicitly when
appropriate. No subprocess apply path is added to production.
Configure endpoint reachability outside the tunnel before full-tunnel policy so
an encrypted UDP endpoint is not recursively routed into its own VPN. Set full
IPv4 routes within the client's isolated namespace only. Exercise traffic
to server tunnel addresses; an external Internet gateway/DNS is not required and
successful tunnel reachability does not claim public Internet egress works.
Ensure test success cannot come from the Docker underlay: target tunnel-only
addresses, use an explicit interface/route assertion, and verify public counters
through the actual rustix reader. Test-only service apply reports must be labelled
fixture results, never actual systemd job evidence.
If fixture restart orchestration verifies and releases application reservations,
use the existing private adapter seam with a real backup, fixture apply, actual
netlink read/comparison and journaled verification. Do not simply call
mark_applied or clear reservations to satisfy assertions. A direct fixture apply
outside that orchestration proves only kernel state change; it cannot by itself
prove the production successful-restart state transition.
M8.5 implements these required scenarios, splitting tests where isolation helps:
1. **Baseline and reader interoperability.** Bring up a server plus an existing
client with synthetic configuration, establish real IPv4 tunnel traffic,
and observe the configured interface's complete peer set, routes, nonzero fresh
handshake and increasing counters through the actual Rust reader. Capture
before/after counters, not exact packet-byte totals. Empty/not-yet-handshaken
observations must not be misreported as a reader failure or online heuristic.
2. **Create, Commit, deliver, then apply.** Use Application to prepare a new client,
Commit and save the actual first-delivery file (QR optional). Assert saved
server/vault change, mandatory pre-Commit archive, unchanged runtime peers and
explicit activation warning. Configure a fresh client from delivered bytes;
prove it cannot reach tunnel-only targets before apply. Apply through the
test-only boundary, then require real handshake and IPv4 traffic.
3. **Saved deletion versus live access.** Delete and Commit an active client;
assert current vault record removal, existing backup retention, and unchanged
runtime peer/access before apply. Reopen saved state to verify the IP remains
reserved. After fixture restart plus real verification/persistence, assert
peer removal, failed fresh tunnel traffic and address release. Use a finite
negative-check window and establish a working baseline so unrelated setup
failure cannot masquerade as successful revocation.
4. **Backup/restore separation.** Save a known client set, mutate/Commit, preview
and restore the archive without providing a vault password. Assert restored
settings/server/vault/state and precautionary backup, plus unchanged kernel
state until explicit fixture apply. Then verify the restored peer set and
renewed tunnel traffic. Restoration alone never releases pending addresses.
5. **Import, reissue and file safety.** Import a valid generated existing client
plus invalid/conflicting candidates from private source files. Assert original
bytes unchanged, anomalies visible and conflicts blocked. Reopen and reissue
a matched client through password-authorized output; verify wrong password
fails and a valid delivered file can configure a real tunnel. Preserve first-
issue/password boundaries and do not add plaintext source fallback.
6. **Failure prevents application.** Produce a deterministic backup failure in the
container (not chmod-only when root can bypass it) and assert Commit/fixture
restart does not install/apply prepared changes or release addresses. Cover
default reader deadline/missing-interface behavior inside its namespace without
changing host state. Keep already exhaustive malformed/archive/failure-unit
cases in ordinary tests rather than duplicating them in every costly container.
For all negative reachability assertions, stop/restart client traffic as needed
and use new requests; stale timestamps/counters or buffered packets cannot prove
continued/revoked access. Use a fixture endpoint/traffic generator with its own
bounded error classification. Record that IPv6 traffic is intentionally untested.
#### Acceptance and validation
- [ ] M8-A1: documented opt-in commands run an actual Testcontainers suite with
pinned/cached Alpine runtime images and compatible in-container driver; default
tests/help/version do not start Docker, require root or query host interfaces.
- [ ] M8-A2: explicit prerequisite checks reject unavailable daemon/kernel/capability
or incompatible artifacts as incomplete/failure; no production or automatic
privileged/module/sysctl workaround is used.
- [ ] M8-A3: isolated real peers establish IPv4 traffic, and the actual
rustix reader reports complete public peer/routes, fresh handshake and traffic
increments without relying solely on wg text or fake decoded observations.
- [ ] M8-A4: new client creation/Commit/file delivery use real Application and vault
APIs; the delivered configuration works only after explicit fixture application,
and Commit itself never changes runtime state.
- [ ] M8-A5: saved deletion removes only current secret, keeps archives and retains
reservation/live peer until explicit apply; actual observation and journaled
verification precede reservation release. Fresh traffic is revoked afterward.
- [ ] M8-A6: password-free restore replaces the saved set but does not apply it;
fixture apply restores real tunnel behavior. Source files remain unchanged and
imported conflict/wrong-password outputs remain blocked.
- [ ] M8-A7: deterministic mandatory-backup failure prevents protected write/apply;
negative traffic and missing-interface results cannot pass due to unrelated
broken setup or a silent empty/partial observation.
- [ ] M8-A8: fixture adapter cannot be selected in production or bypass root/password/
backup controls; no public alternate restart API or runtime environment bypass
is introduced. Alpine results explicitly exclude actual zbus/systemd coverage.
- [ ] M8-A9: secret-free diagnostics, private copied files, limits/deadlines and
cleanup work on both success and failure; only test-owned resources are removed.
- [ ] M8-A10: integrated default and real-container gates pass; a clean repeated run
reuses image layers while starting fresh mutable scenarios and produces the same
behavioral outcomes. Independent review closes actionable isolation/coverage issues.
Planned host commands once the proposed test target and documented image/driver
build steps exist, from repository root:
```sh
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --locked --release
cargo test --locked --test testcontainers_wireguard -- --ignored --test-threads=1
./target/release/wireguard-admin --help
./target/release/wireguard-admin --version
git diff --check
```
Document and execute exact image-build and in-container driver-build commands in
TESTING.md before the explicit suite command; include prerequisites and output
artifact paths. The in-container target must exist before claiming those commands
work. A musl cargo test --no-run build is one possible cfg(test) driver mechanism;
resolve its actual compilation/link/dependency implications rather than listing
an unverified artifact name. Network downloads/build setup are distinct from
locked test execution, and new dependencies require the normal approval policy.
Record image identities, kernel/runtime/toolchain versions, target architecture,
scenario names and actual outcomes/durations/counts. All required scenarios must
execute and pass before M8 acceptance; compile-only/default-test success with
unavailable Docker is not scoped acceptance of M8. Do not use a successful
ignored-test summary as evidence that ignored tests ran. Real systemd remains M9.
#### Progress
- [x] Description prepared against accepted M1–M7 and approved Alpine methodology.
- [x] M8.1 — implementer `/root/m8_1` prepared the host/container contract in
`docs/TESTING.md` and `docs/ENVIRONMENT.md`; documentation link and diff checks
passed. Fresh independent reviewer `/root/m8_1_review` raised M8.1-R1, which
`/root/m8_1_repair` corrected and the reviewer closed on re-review. Exact
driver transport remains an M8.4 decision against the pinned Testcontainers API.
- [x] M8.2 — implementer `/root/m8_2` added a pinned Alpine runtime recipe,
allowlisted context, package record and harmless readiness script, plus
documentation. Fresh reviewer `/root/m8_2_review` raised M8.2-R1/R2;
`/root/m8_2_repair` repaired both and independent re-review closed them.
Selected-daemon image build and rebuild with 33 exact package constraints,
image inspection (`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`,
linux/amd64), read-only/networkless/capability-dropped readiness and full
`apk info -v` lock match passed. Rust driver execution awaits M8.4.
- [x] M8.3 — implementer `/root/m8_3` added the approved Testcontainers 0.28.0
blocking development dependency and named ignored host probe. Focused fmt,
Clippy and default locked tests passed; explicit selected-daemon run failed
nonzero before network creation because Docker lacks IPv6 iptables support.
Independent reviewer `/root/m8_3_review` raised M8.3-R1/R2/R3; targeted
repairs by `/root/m8_3_repair` and independent re-reviews closed all three.
This completes the bounded host-probe implementation, not the blocked
real-container M8 acceptance. No tunnel scenario has run.
- [x] M8.3 scope revision — `/root/m8_ipv4_revision` changed only the
test harness and guidance to use isolated IPv4 networking as requested by the
user. Default gates pass; the selected-daemon probe passed 1/1 after R4–R7
repairs. Independent `/root/m8_ipv4_review` re-reviews closed all findings.
- [x] M8.4 — implementer `/root/m8_4_driver` added the in-crate test-only driver
and private runtime/application seams; musl builder prerequisite research is
complete from `/root/m8_musl_explore`. The exact ignored readiness and
Application smoke run in the pinned Alpine image. Independent
`/root/m8_4_review` and `/root/m8_4_host_review` raised R1–R4; targeted
repairers closed all findings on re-review. The coordinator rebuilt the final
musl test binary, reran the selected-daemon smoke (1/1, 4.05s), and found no
leftover named resources. M8.5 scenarios remain pending.
- [ ] M8.5 — `/root/m8_5_traffic` owns the first bounded scenario set:
delivered-file client transfer, baseline real peer observation, pre-Apply
nonreachability and post-Apply IPv4 tunnel/handshake/counter proof. Deletion,
restore, import/reissue and backup-failure scenarios follow sequentially;
independent review and full real-suite evidence are pending. Local focused
support tests currently pass 18/18 with one ignored privileged target, and
default `cargo test --locked` passed 250 library tests plus other suites;
the current musl driver built in the pinned Alpine builder (5m06s). The first
selected-daemon traffic run reached the scenario but failed a stale pre-Apply
assertion that expected zero peers even though the new required baseline peer
remained applied. The assertion is being changed to require exactly that
baseline and exclude the new peer; no traffic acceptance is claimed yet.
A later bounded run reached fixture Apply but `preview_restart` rejected the
raw baseline peer because it lacked managed alias metadata. The synthetic
fixture is being corrected through `ConfigDocument::insert_legacy_metadata`
before saved state opens. Cleanup completed after that failed run; real
traffic acceptance is still pending.
The next real run passed baseline handshake/counters, new-client Create,
Commit and delivery, pre-Apply peer exclusion, verified fixture Apply and
IPv4 ping. It failed only an overly exact host route-string assertion:
actual new-peer routes include product-required `0.0.0.0/0` and `::/0`.
The assertion is being made IPv4-specific; no IPv6 capability or traffic
test is being added. A final successful traffic suite is still pending.
A subsequent reader sample after IPv4 ping showed the new peer's fresh
handshake and nonzero received/transmitted counters (916/732 bytes in that
run). The host assertion still used the wrong route perspective: on the
server, the peer's AllowedIPs are its assigned tunnel hosts, not the client's
full-tunnel routes. The assertion is being corrected to the assigned IPv4
`/32`; the delivered client file is where `0.0.0.0/0` belongs. No passing
end-to-end traffic summary is claimed yet.
The host then added an immediate post-Apply/pre-ping reader sample and
requires both received and transmitted counters to rise after a new IPv4
ping. The worker's final turn stalled after implementing this assertion;
coordinator stopped that turn, ran the exact selected-daemon test once and
observed 1/1 pass in 16.25s. Exact-name Docker container/network listings
were empty afterward. Fresh independent `/root/m8_5_traffic_review` is
inspecting the integrated scenario. That reviewer raised M8.5-R1 (P2):
the fresh client used a host-derived setconf copy rather than consuming the
intact copied first-delivery file, so traffic did not prove the artifact is
usable. M8.5-R2 (P2): route assertions accepted unintended extra IPv4
prefixes. `/root/m8_5_traffic_repair` owns targeted fixes and real-suite
rerun. Exact route assertions apply to IPv4 only per user direction; no IPv6
capability or route check is added. This bounded slice is not yet accepted;
remaining M8.5 scenarios have not started.
The repair added private client-copy byte comparison, in-container
`wg-quick strip` consumption of the copied file, and exact IPv4 route-set
assertions. The first sandboxed selected-daemon attempt was denied Docker
access; the elevated rerun reached the scenario and failed nonzero at the
fixed category `copied client configuration could not be safely stripped`.
The strip prerequisite/fixture invocation is being diagnosed with synthetic
inputs; no secret config was logged and no repaired traffic pass is claimed.
Synthetic diagnostics isolated the cause to a copied `.conf` basename longer
than wg-quick's 15-character interface-name limit. The repair uses short
fixed private names without changing file bytes. The worker turn stalled
after editing; coordinator stopped it and ran the repaired selected-daemon
test once: 1/1 pass in 14.23s, with no named container/network afterward.
Focused host target passed 21 tests with one ignored privileged test; fmt and
diff checks passed. Independent R1/R2 re-review was interrupted by an agent
usage limit before a finding report, so `/root/m8_5_traffic_review` resumed
on 2026-10-24. That independent re-review closed both findings with no new
issue: the copied first-delivery bytes are compared privately and consumed
by wg-quick in the client namespace; exact IPv4 route sets reject extra
prefixes. The bounded baseline/new-client traffic slice is complete as
Alpine fixture evidence. `/root/m8_5_delete` now owns saved deletion versus
live access. Its local fmt, full locked tests (251 library passed, two
ignored), all-target Clippy, release build and focused saved-deletion test
passed; pinned musl rebuild passed. The coordinator's exact selected-daemon
run failed 1/1 after saved deletion: the baseline client's fresh IPv4 ping
stopped before explicit fixture Apply. This is a real-scenario failure, so
deletion is not accepted. `/root/m8_5_delete_repair` owns diagnosis and a
targeted repair. A fresh continuity probe moved immediately after the prior
new-client fixture Apply failed 1/1 on the same selected daemon in 14.25s;
the baseline disruption therefore precedes deletion Commit. A separate
sandbox-only attempt could not access Docker and supplies no scenario evidence.
A bounded selected-daemon diagnostic run then failed 1/1 in 15.52s and
reported the baseline peer still present with exact IPv4 route but no fresh
handshake, zero reset counters, no learned endpoint and three lost pings
after full fixture `wg setconf`. The repair is testing explicit reconnect of
the existing client after that fixture apply, then requiring new traffic and
reader evidence before continuing the deletion assertions. That reconnect
passed far enough to reach deletion Apply. A pinned musl rebuild and exact
selected-daemon run reported `RestartStage::Complete`, followed by fixture
phase failure; inspection identified the release assertion parsing the
client's CIDR as bare `IpAddr`. A targeted typed-address repair and rerun are
complete. The next exact selected-daemon run reached post-deletion observation
and exposed that full fixture `wg setconf` resets the baseline handshake and
counters again; the harness now proves the exact retained/deleted peer set
immediately, then reconnects the retained client and requires fresh traffic
and reader counter deltas while fresh traffic from the deleted client fails.
The coordinator's integrated exact run passed 1/1 in 24.88s, host support
tests passed 22 with one privileged test ignored, focused CIDR validation
passed, fmt/diff checks passed, and exact-name Docker container/network
listings were empty. Fresh independent `/root/m8_5_delete_review` raised
M8.5-R3 (P2): the negative ping helper treats any Docker CLI nonzero exit,
including failed `docker exec`, as lost tunnel traffic. `/root/m8_5_delete_repair`
is changing it to require a bounded ping summary with transmitted packets and
zero received packets. The repaired host support target passed 23 tests with
one ignored, the coordinator's exact selected-daemon rerun passed 1/1 in
42.96s, and exact-name container/network listings were empty. Independent
`/root/m8_5_delete_review` closed R3 on re-review with no new finding:
completed zero-reply traffic is distinguished from Docker execution and
timeout errors. The saved-deletion bounded slice is accepted as Alpine IPv4
fixture evidence. `/root/m8_5_restore` implemented the two-restore scenario:
pre-delete archive re-adds a saved client while runtime remains baseline-only;
an earlier baseline archive later removes a saved live client and retains its
address reservation until verified fixture Apply. Local fmt, focused tests,
locked full tests, all-target Clippy, release build and diff check passed.
Pinned musl rebuild passed, but the coordinator's first selected-daemon run
failed 1/1 after 7.47s because the host archive-name parser selected the
driver's `status=started` line rather than its completed result line. The
targeted parser repair passed focused tests and the next real run advanced
through the first password-free restore, explicit Apply and renewed restored
client traffic. It failed 1/1 after 58.28s at the second restore of the
baseline archive (`phase=restore status=started` only). A separate repairer
is diagnosing that in-container phase with bounded diagnostics before another
pinned musl rebuild, selected-daemon rerun and independent review.
Import/reissue and backup-failure slices remain pending.
- [ ] M8.6 — independent implementation reviews and repairs complete for
M8.1–M8.3 only; M8.4–M8.5 and integrated real-scenario review pending.
- [ ] M8.7 — prior default coordinator gates pass; the revised explicit
real-container gate and remaining scenarios are pending. Acceptance incomplete.
#### Review and repair
Record stable finding IDs, iteration, location/impact,
owner, fix evidence and disposition. Review actual namespace/daemon targeting,
secret transport/logs, capability/mount boundaries, endpoint/full-route setup,
real-reader invocation and state-persistence proof. Re-review negative tests so
underlay connectivity or an unrelated failed setup cannot produce false success.
The user-authorized IPv6 testing exclusion applies only to M8 integration tests;
do not replace real IPv4 observations with mocks.
M8.1 R1 review dispatched after coordinator inspected the two-file documentation
diff. M8.1-R1 (low): `docs/TESTING.md` linked `SyncRunner` under the wrong
Testcontainers module; repair changed the link to `runners/trait.SyncRunner.html`.
Independent re-review closed the finding. Both rounds reported `git diff --check`
passing on the M8.1 docs; link/consistency checks passed. No Docker evidence.
M8.2 R1 review of the actual image recipe/docs raised two P2 findings:
M8.2-R1 (`runtime-check.sh`): planned libtest artifact cannot accept the
script's custom `--readiness` argument. M8.2-R2 (Dockerfile/package record):
direct package pins still resolve dependencies from a mutable Alpine repository,
and the record/inspection omits the installed closure. Targeted repair
`/root/m8_2_repair` addressed both. Reviewer confirmed the pinned direct package
versions and Alpine platform digest from primary indexes, plus passing shell
syntax, documentation link and diff checks. R1 re-review closed both recipe
findings after the repair. `runtime-check.sh` now uses a libtest-compatible
exact ignored selector; the test itself remains pending M8.4. The full installed
closure was recorded as 33 exact constraints; a second build succeeded with the
lock, and read-only shell readiness and inspection passed on the selected daemon.
The reviewer did not independently rerun the Docker commands. Coordinator also
ran a disposable `--network none --cap-add NET_ADMIN` Alpine probe that created
and removed `wg-m8-probe` and added an IPv6 address inside that namespace
(exit 0); no host interface, module, or sysctl operation was performed.
M8.3 R1 review raised M8.3-R1 (P2, `tests/testcontainers_wireguard.rs`): the
outer timeout detaches a worker that may later create/hold test resources, so
timeout cleanup is not guaranteed. First repair used a killed child and
label-verified cleanup, but independent re-review kept R1 open because a
Testcontainers request can run for 120 seconds and finish after the 75-second
kill/cleanup pass. Second repair made the probe synchronous, retained finite
per-request limits, added conservative aggregate documentation and explicitly
reports uncertain late daemon completion. Independent re-review closed R1.
The same re-review raised M8.3-R2 (P2, `container_harness.rs`): fixture exec
stream reads can block indefinitely if a command hangs, invalidating the
documented aggregate bound. First R2 repair wrapped commands in BusyBox
`timeout`, capped retained output and drained streams, but independent re-review
kept R2 open because the host attach read can still block after the stream is
established. Coordinator separately verified BusyBox timeout in the cached
Alpine image with a harmless delayed command (one-second elapsed inside,
status 137); this does not prove the host stream is bounded. Second R2 repair
uses a bounded Docker CLI exec transport and independent re-review closed R2.
That re-review raised M8.3-R3 (P2, `container_process.rs`): the CLI exit code
for an in-container timeout was discarded, so a timed-out command could be
misclassified as a generic capability failure. Targeted repair mapped CLI exit
codes 124/137/143 to the fixed timeout category, and independent re-review
closed R3. No open M8.1–M8.3 review finding remains. The host-side bounded CLI
exec path is not runtime-tested because network creation fails first.
The selected-daemon explicit harness failed before network/container creation
with `Docker host lacks required IPv6 iptables support`; separate disposable
regular, internal and routed dual-stack network attempts failed on missing
IPv6 NAT, `DOCKER-INTERNAL` filter and `DOCKER` filter chains respectively.
No host networking, firewall, module, or sysctl remediation was attempted.
M8.4 R1 independent review of `src/container_driver.rs` raised M8.4-R1 (P1):
the only runnable ignored driver test is harmless readiness; no bounded
in-container phase protocol retains an Application across create, Commit,
delivery and fixture apply, so the new seam cannot yet reach real Rustix
observation or lifecycle assertions. M8.4-R2 (P2): `SetWireGuard` lexical path
validation allows a symlink under the fixture root to escape. Focused 7 driver
argument tests passed, but no live fixture command ran. Targeted repair is
pending; no M8.4 acceptance is claimed.
M8.4 R1/R2 first repair by `/root/m8_4_repair` added an ignored
`alpine_scenario_driver`, a bounded typed stdin/stdout phase protocol retaining
one Application through Create, Commit, first delivery and fixture Apply, plus
real Rustix verification through the test-only restart seam. It also added
symlink component rejection and a regular-file/confined-path check for
`wg setconf`; 10 focused driver tests, default tests (244 library tests, two
ignored), fmt, all-target Clippy, release build and diff check passed. One
initial default run had a transient TUI `Busy` failure; immediate rerun passed.
Independent `/root/m8_4_review` re-review closed R2. It kept R1 open (P2):
the existing host harness has no bounded stdin channel to invoke the driver,
and the protocol does not yet cover M8.5's remaining lifecycle operations.
The next bounded repair is host protocol transport and a real in-container
Create/Commit/Deliver/Apply smoke path; M8.5 owns the additional scenarios.
After the 2026-10-23 daemon/session interruption, coordinator inspection found
no host-transport edits from the interrupted worker. `/root/m8_4_host_repair`
was dispatched with ownership of the host harness and test-only driver to
resume R1; no scenario acceptance is inferred from the prior musl readiness.
The worker added bounded `docker exec -i` phase exchange and a private synthetic
fixture bootstrap. Its current musl driver rebuilt in the pinned Alpine builder
in 3m04s; focused host/driver checks and all-target Clippy passed. On the
selected daemon, a first smoke attempt failed before application phases with a
generic Docker category and left no owned resources. A diagnostic rerun passed
link-up, IPv4 address setup, binary copy and chmod, then failed inside the
application driver protocol. The failure stage is being isolated with fixed
redacted phase markers; neither run is scenario acceptance. The worker also
changed generated settings/server secret files to mode 0600 at creation and
discovers the fixture endpoint inside the server namespace.
The phase markers subsequently localized the error to Create: the synthetic
server document lacked `[Interface] Address`, which the actual application
requires for server address ownership. The fixture now includes IPv4/IPv6
server addresses in saved configuration while removing the wg-quick-only
`Address` directive from a private 0600 copy passed to `wg setconf`. The
authoritative saved file is unchanged. This is fixture setup, not an IPv6
capability or traffic check. A unique per-apply copy and exact cleanup are
being added for repeated M8.5 apply phases; the corrected real smoke rerun
is pending. One subsequent scenario process exited zero, but libtest captured
its success output because the host invocation omitted `--nocapture`, so the
host could not verify the expected phase sequence. This is not accepted as
scenario evidence; the host invocation is being repaired and rerun.
The completed host repair then passed the explicit selected-daemon smoke
(1/1, 3.80s), and the coordinator independently reran it (1/1, 3.60s) with
no `wga-m83` container/network on follow-up listing. It exercised one exact
ignored driver test and observed Create, Commit, first delivery activation
warning, and fixture Apply with real Rustix verification. Final worker default
gates passed: fmt, all-target Clippy, `cargo test --locked` (247 library tests,
two ignored), release build, focused driver/harness checks and diff check.
Fresh independent `/root/m8_4_host_review` found M8.4-R3 (P2: a successful
`wg setconf` followed by cleanup failure was reported as not submitted) and
M8.4-R4 (P2: preview or partial-write failure could leave a secret-bearing
setconf copy). `/root/m8_4_cleanup_repair` separated setconf submission from
copy-cleanup outcome and added an armed cleanup guard; focused 15 driver tests,
default tests (250 library tests, two ignored), fmt, all-target Clippy, release
build and diff check passed. Independent `/root/m8_4_host_review` re-review
closed R3/R4 with no new finding. The current test-only driver is being rebuilt
for a fresh Alpine smoke; M8.4 is not yet accepted and M8.5 has not started.
2026-10-23 resume: the prior `/root/m8_4_repair` turn hit an agent usage limit
before changing files; the same GPT-6-Luna/high repairer was resumed for R1/R2.
The official `rust:1.97.1-alpine3.22` linux/amd64 builder manifest was resolved
to `sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`,
pulled onto the selected disposable daemon, and inspected as image ID
`sha256:bc2b74a0f3f942897f678aa466c3cce54048cf1e094893af3596cf43766153c2`.
A read-only/networkless/capability-dropped container reported rustc/Cargo
1.97.1 and installed `x86_64-unknown-linux-musl`; no application harness has
yet been used for a scenario. A subsequent read-only source-mounted builder
`cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl`
completed in 4m25s and produced a 114 MiB static PIE test binary under
`/tmp/wga-m8-build-target/.../wireguard_admin-dcc9c812241bb031`.
`file`/`readelf` showed no dynamic interpreter. A networkless, read-only,
capability-dropped run in the pinned Alpine runtime selected only
`container_driver::alpine_readiness` and passed 1/1. This proves the binary
loads in Alpine; no real `wg`, application scenario, or Rustix observation ran.
After R3/R4 repair, coordinator rebuilt the current musl harness in the pinned
builder (`cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl`,
3m22s), then ran the exact ignored host smoke with matching selected daemon ID
and `WIREGUARD_ADMIN_TEST_DRIVER` pointing at that artifact. It passed 1/1 in
4.05s and verified the test-only Create/Commit/first delivery/fixture Apply
phase sequence through the real Application and Rustix observation path.
`docker ps -a --filter name=wga-m83` and `docker network ls --filter name=wga-m83`
both returned empty afterward. This is Alpine fixture evidence; no client
tunnel traffic, restore/delete/import, or actual systemd job ran yet.
After the user excluded IPv6 testing, `/root/m8_ipv4_revision` changed the
probe and M8 test guidance to isolated IPv4 while keeping SPEC.md untouched.
The revised default suite, fmt, all-target Clippy, release build and diff check
passed. The elevated selected-daemon probe created its IPv4 network, but its
first Testcontainers container start failed with a generic redacted category;
exact-name inspection found no owned resources afterward. A coordinator-only
disposable `docker run` on a separately named internal IPv4 network succeeded
with `NET_ADMIN`, and that network was removed. Independent reviewer
`/root/m8_ipv4_review` raised M8.3-R4 (P2: swallowed Testcontainers error
prevents diagnosing this start failure) and M8.3-R5 (P2: route read did not
exercise IPv4 route mutation claimed by the guidance). `/root/m8_ipv4_repair`
added fixed typed error categories and route add/assert/delete; independent
re-review closed R4/R5 but the real route commands remain unrun because the
probe still fails at container creation. It raised M8.3-R6 (P2: the `PullImage`
category conceals that any Docker create 404 triggers Testcontainers' image-pull
fallback). `/root/m8_ipv4_repair_r6` owns the category repair and diagnosis.
The repair changed it to a fixed `container creation not-found image fallback
failed` category with redaction regression; independent `/root/m8_ipv4_review`
re-review closed R6. The explicit probe still fails there, before runtime route
commands. No open scoped review finding remains, but actual M8.3 probe
acceptance has not passed.
The selected daemon is Docker 29.1.3, API 1.52; a direct Docker CLI create/start
using the fixture image, internal network, `NET_ADMIN`, label, entrypoint and
command succeeded and exact resources were removed. Testcontainers 0.28.0 uses
Bollard 0.21.1 (default API 1.53); this was investigated, but the source-level
image-name override below explains the observed create 404 without an API
version change. No real tunnel or application scenario has run.
Coordinator inspection of pinned Testcontainers 0.28.0 identified M8.3-R7
(P1, startup blocker): the host harness used `ImageExt::with_name` for unique
container names, but that method overrides the image name. The nonexistent
image explains the create 404 and pull fallback. `with_container_name` is the
intended API. `/root/m8_name_repair` owns a narrow repair and live-probe
regression; `with_container_name` and descriptor/name/owner assertions are in
place. Independent `/root/m8_ipv4_review` re-review closed R7. The repairer's
elevated selected-daemon probe passed (1/1); a concurrent
coordinator probe collided on the fixed subnet, then a separate coordinator
rerun passed (1/1, 1.46 seconds) with cleanup. M8.3 prerequisite acceptance
is satisfied for the user-scoped IPv4 test path; no application scenario ran.
#### Evidence and handoff
Planning inspected current Cargo/module/test layout, Application public/private
restart and delivery boundaries, runtime reader visibility, M7 acceptance record,
and current TESTING/ENVIRONMENT/runtime documentation. Existing M7 evidence is
synthetic workflow/PTY and ordinary library integration; it is not a real tunnel.
This planning task did not rerun Cargo tests, contact Docker, probe kernel support,
start containers, install toolchains/packages, add dependencies, or inspect keys.
2026-10-22 execution preparation: the active coordinator session `turn_context`
reported `gpt-6-sol`/`medium`; project role files and explicit dispatch select
`gpt-6-luna`/`high`. M1–M7 are accepted in this plan. Native rustc/Cargo are
1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. The host lacks rustup, a musl linker,
and the musl standard-library directory. The default local Docker daemon is
reachable with approved access and identifies as Gentoo Linux, ID
`1baad80e-d79d-49ab-b19a-fb00fd3662d7`; the user explicitly selected it as
the disposable M8 environment. The scoped container WireGuard/IPv6 capability
probe described above passed. No host WireGuard probe ran. The pre-existing
expanded M8 description edit is preserved.
2026-10-22 integrated coordinator gates on the final M8.1–M8.3 revision:
`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`,
`cargo test --locked`, `cargo build --locked --release`, release `--help` and
`--version`, documentation local-link check, and `git diff --check` all exited
zero. The default test run passed 235 library tests plus integration/doc tests;
the Testcontainers target had 8 passing local support tests and one ignored
privileged probe. The ignored count is not container evidence. The exact named
probe was then explicitly selected with matching local Unix endpoint and daemon
ID; it ran one test and exited 101 with `Docker host lacks required IPv6 iptables
support`. The first sandboxed attempt could not access the socket, so the
coordinator reran it with approved elevated access; only the latter establishes
the daemon result. An elevated `docker network ls` and `docker ps -a` afterward
showed no `wga-m83` network or container. Existing unrelated resources were
untouched. No real peer traffic, netlink observation, application lifecycle,
in-container Rust driver, or systemd behavior was verified.
2026-10-22 user scope change: skip IPv6 capability and traffic tests. The prior
dual-stack network failure is historical evidence, no longer an M8 blocker.
Use the already selected local disposable daemon with a unique isolated IPv4
bridge and scoped `NET_ADMIN`; run the revised named probe. An elevated
coordinator run reached container creation but failed after 5.9 seconds with
`Testcontainers could not start an owned fixture container`; the fresh IPv4
network and container were absent on follow-up inspection. Investigate this
failure through scoped implementation/review before claiming the probe passes.
Do not modify this
host's firewall/modules/sysctls. Then settle the Alpine-compatible in-crate driver
build/runtime path (host musl standard library/linker remain absent, so a pinned
Alpine builder may be needed), execute M8.4–M8.5 sequentially, independently
review/repair the complete scenarios, and rerun all M8 acceptance gates.
#### Decisions and discoveries
The production reader is bounded rustix netlink, not wireguard-uapi, and the
restart adapter is private zbus. M8 must test that actual reader and preserve
production API boundaries rather than resurrect a discarded library path or
expose a fake-systemd switch. A test-only service apply plus real comparison is
useful integration evidence but does not validate the systemd adapter itself.
Image layers may be cached for speed/space while secret-bearing container state
must be fresh. The container host shares its kernel with Alpine, so the fixture
cannot manufacture missing kernel support by installing wireguard-tools.
Primary implementation references (check selected versions at dispatch):
[Testcontainers requirements](https://rust.testcontainers.org/system_requirements/docker/),
[Testcontainers image options](https://docs.rs/testcontainers/latest/testcontainers/core/trait.ImageExt.html),
and [WireGuard namespaces](https://www.wireguard.com/netns/).
#### Outcome
M8.1–M8.3 were implemented and independently reviewed, with all original
findings closed. The user removed IPv6 testing from M8, and an IPv4-only harness
revision is in progress. M8.4–M8.5 and real-container acceptance remain pending;
M8 is not accepted. Milestone 9 must not start until M8 acceptance; actual
systemd/VM validation remains separate and pending.
+3506 -352
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
//! Application-owned backup listing and creation scenarios.
use crate::backup::{self, ArchiveSummary, BackupReason};
use super::{AppError, Application, historical_apply_from_saved};
pub(super) fn create_backup(
application: &Application,
reason: BackupReason,
) -> Result<std::path::PathBuf, AppError> {
let installed = application.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
Ok(backup::create_archive(
application.store.archives_directory(),
reason,
historical_apply,
&installed,
)?)
}
pub(super) fn list_backups(application: &Application) -> Result<Vec<ArchiveSummary>, AppError> {
match backup::list_archives(application.store.archives_directory()) {
Ok(archives) => Ok(archives),
Err(crate::backup::BackupError::Io(error))
if error.kind() == std::io::ErrorKind::NotFound =>
{
Ok(Vec::new())
}
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
pub(super) fn create_pre_restart_backup(
application: &mut Application,
) -> Result<std::path::PathBuf, AppError> {
application.active_restart_attempt = None;
create_backup(application, BackupReason::PreRestart)
}
+141
View File
@@ -0,0 +1,141 @@
//! Saved-state Commit scenario.
//!
//! The ordering is deliberately explicit: prepare and validate the complete
//! candidate, archive the installed generation, install it through the
//! journaled store, then publish only already-prepared in-memory values.
use std::net::IpAddr;
use crate::backup::{BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
use crate::domain::ClientStates;
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
use super::{AppError, Application, historical_apply_from_saved};
struct PreparedCommit {
proposed: SavedBytes,
document: ConfigDocument,
states: ClientStates,
}
impl Application {
/// Prepare a candidate without changing the live session or installed
/// files. All parsing, serialization and store validation finish here.
fn prepare_commit(
&self,
installed: &SavedBytes,
) -> Result<(SavedBytes, ConfigDocument, ClientStates), AppError> {
if let Some(existing_bytes) = &installed.vault {
let existing_text =
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
let existing = VaultBundle::from_toml(existing_text)?;
if self
.draft
.vault
.as_ref()
.is_none_or(|vault| existing.recipient() != vault.recipient())
{
return Err(AppError::VaultIdentityChange);
}
}
let mut states = self.draft.states.clone();
states.commit_draft();
let reservations = states
.reserved_addresses()
.iter()
.map(IpAddr::to_string)
.collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault = self
.draft
.vault
.as_ref()
.map(VaultBundle::to_toml)
.transpose()?
.map(String::into_bytes);
let proposed = SavedBytes {
settings: installed.settings.clone(),
server_config: self.draft.document.render().to_vec(),
internal_state,
vault,
};
self.store.validate_saved(&proposed)?;
// Reuse this parsed document after install; publication must not have a
// fallible parse step after the filesystem has already advanced.
let document = ConfigDocument::parse(&proposed.server_config)?;
Ok((proposed, document, states))
}
}
pub(super) fn commit(
application: &mut Application,
create_backup: impl FnOnce(
&std::path::Path,
BackupReason,
HistoricalApplyStatus,
&SavedBytes,
) -> Result<std::path::PathBuf, BackupError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
let installed = application.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
let (proposed, document, states) = application.prepare_commit(&installed)?;
let prepared = PreparedCommit {
proposed,
document,
states,
};
// Passing BackupComplete is the only path to installation, and this point
// is reached only after the full archive has been published successfully.
let archive = create_backup(
application.store.archives_directory(),
BackupReason::PreCommit,
historical_apply,
&installed,
)?;
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
retain_first_issue_rights_after_failed_install(application);
return Err(error.into());
}
application.saved = prepared.proposed;
application.draft.states = prepared.states;
application.draft.document = prepared.document;
application
.first_delivery
.invalidate_active_authorizations();
application.first_delivery.promote_matching(
&application.draft.document,
application.draft.vault.as_ref(),
);
application.historical_apply = HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None;
application.active_restart_attempt = None;
Ok(archive)
}
fn retain_first_issue_rights_after_failed_install(application: &mut Application) {
match application.store.read_saved() {
Ok(current) => {
let document = ConfigDocument::parse(&current.server_config).ok();
let vault = current
.vault
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document {
application
.first_delivery
.retain_matching(&document, vault.as_ref());
} else {
application.first_delivery.clear();
}
}
Err(_) => application.first_delivery.clear(),
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Coordinated in-memory draft state for one application session.
//!
//! This owner groups the authoritative lossless document with its restricted
//! public-client projection, encrypted vault draft, and draft-only address
//! reservations (held inside `ClientStates`). It performs no I/O or
//! cryptographic operations itself.
use crate::domain::ClientStates;
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
pub(super) struct DraftChanges {
pub(super) document: ConfigDocument,
pub(super) states: ClientStates,
pub(super) vault: Option<VaultBundle>,
}
/// Fully prepared replacement for selected coordinated in-memory draft parts.
///
/// Callers build this only after all fallible editing, projection, and vault
/// work has succeeded. Publishing it is a narrow, infallible operation and
/// never writes saved files or changes runtime state.
pub(super) struct StagedDraftReplacement {
document: Option<ConfigDocument>,
states: Option<ClientStates>,
vault: VaultChange,
}
enum VaultChange {
Keep,
Replace(Option<VaultBundle>),
}
impl DraftChanges {
pub(super) fn new(
document: ConfigDocument,
states: ClientStates,
vault: Option<VaultBundle>,
) -> Self {
Self {
document,
states,
vault,
}
}
pub(super) fn stage_clients(
document: ConfigDocument,
states: ClientStates,
) -> StagedDraftReplacement {
StagedDraftReplacement {
document: Some(document),
states: Some(states),
vault: VaultChange::Keep,
}
}
pub(super) fn stage_clients_and_vault(
document: ConfigDocument,
states: ClientStates,
vault: Option<VaultBundle>,
) -> StagedDraftReplacement {
StagedDraftReplacement {
document: Some(document),
states: Some(states),
vault: VaultChange::Replace(vault),
}
}
pub(super) fn stage_vault(vault: Option<VaultBundle>) -> StagedDraftReplacement {
StagedDraftReplacement {
document: None,
states: None,
vault: VaultChange::Replace(vault),
}
}
pub(super) fn publish_replacement(&mut self, replacement: StagedDraftReplacement) {
if let Some(document) = replacement.document {
self.document = document;
}
if let Some(states) = replacement.states {
self.states = states;
}
if let VaultChange::Replace(vault) = replacement.vault {
self.vault = vault;
}
}
}
+359
View File
@@ -0,0 +1,359 @@
//! Session-local ownership of password-free first-delivery eligibility.
//!
//! The session keeps each generated private key beside the saved peer and
//! encrypted-record context that authorizes its single first-file publication.
use std::collections::BTreeMap;
use crate::delivery::ClientConfigPayload;
use crate::domain::PublicKey;
use crate::vault::{VaultBundle, WireGuardPrivateKey};
use crate::wireguard::ConfigDocument;
use super::{AppError, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueRetry};
#[derive(Clone, Eq, PartialEq)]
pub(super) struct IssueContext {
peer_directives: String,
encrypted_record: String,
}
pub(super) struct FirstDeliverySession {
next_authorization: u64,
issues: BTreeMap<PublicKey, InitialIssue>,
}
struct InitialIssue {
private_key: WireGuardPrivateKey,
context: IssueContext,
stage: IssueStage,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum IssueStage {
PendingDraft,
AwaitingFirstFile,
RetryAvailable,
Authorizing(u64),
RetryNeedsOverwriteConfirmation,
RetryNeedsManualCleanup,
}
/// Read-only first-file availability with no access to the held private key,
/// issue context, or payload.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SessionFirstDeliveryAvailability {
InitialFile,
Retry,
RetryNeedsOverwriteConfirmation,
RetryNeedsManualCleanup,
Unavailable,
}
pub(super) struct FirstIssueAuthorization {
identity: PublicKey,
context: IssueContext,
token: u64,
payload: ClientConfigPayload,
}
impl FirstIssueAuthorization {
pub(super) fn payload(&self) -> &ClientConfigPayload {
&self.payload
}
}
impl FirstDeliverySession {
pub(super) fn new() -> Self {
Self {
next_authorization: 1,
issues: BTreeMap::new(),
}
}
pub(super) fn len(&self) -> usize {
self.issues.len()
}
pub(super) fn read_model_availability(
&self,
identity: PublicKey,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> SessionFirstDeliveryAvailability {
let Some(issue) = self.issues.get(&identity) else {
return SessionFirstDeliveryAvailability::Unavailable;
};
let matching = vault
.and_then(|vault| context_for(document, vault, identity).ok())
.is_some_and(|actual| actual == issue.context);
if !matching {
return SessionFirstDeliveryAvailability::Unavailable;
}
match issue.stage {
IssueStage::AwaitingFirstFile => SessionFirstDeliveryAvailability::InitialFile,
IssueStage::RetryAvailable => SessionFirstDeliveryAvailability::Retry,
IssueStage::RetryNeedsOverwriteConfirmation => {
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation
}
IssueStage::RetryNeedsManualCleanup => {
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup
}
IssueStage::PendingDraft | IssueStage::Authorizing(_) => {
SessionFirstDeliveryAvailability::Unavailable
}
}
}
#[cfg(test)]
pub(super) fn is_empty(&self) -> bool {
self.issues.is_empty()
}
#[cfg(test)]
pub(super) fn contains(&self, identity: &PublicKey) -> bool {
self.issues.contains_key(identity)
}
pub(super) fn remember_pending(
&mut self,
identity: PublicKey,
private_key: WireGuardPrivateKey,
context: IssueContext,
) {
self.issues.insert(
identity,
InitialIssue {
private_key,
context,
stage: IssueStage::PendingDraft,
},
);
}
pub(super) fn remove(&mut self, identity: &PublicKey) {
self.issues.remove(identity);
}
pub(super) fn clear(&mut self) {
self.issues.clear();
}
/// In-flight output can no longer be retried silently after a draft or
/// runtime transition. Keep unrelated clients' rights intact.
pub(super) fn invalidate_active_authorizations(&mut self) {
for issue in self.issues.values_mut() {
if matches!(issue.stage, IssueStage::Authorizing(_)) {
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
}
}
}
/// Keep only already committed first-file rights when a draft is dropped.
pub(super) fn discard_draft(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) {
self.issues
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
self.retain_matching(document, vault);
}
pub(super) fn retain_matching(
&mut self,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) {
self.issues.retain(|identity, issue| {
if issue.stage == IssueStage::PendingDraft {
return true;
}
vault
.and_then(|vault| context_for(document, vault, *identity).ok())
.is_some_and(|actual| actual == issue.context)
});
}
pub(super) fn promote_matching(
&mut self,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) {
self.issues.retain(|identity, issue| {
let matches = vault
.and_then(|vault| context_for(document, vault, *identity).ok())
.is_some_and(|actual| actual == issue.context);
if matches && issue.stage == IssueStage::PendingDraft {
issue.stage = IssueStage::AwaitingFirstFile;
}
matches
});
}
pub(super) fn begin_attempt(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
render: impl FnOnce(&WireGuardPrivateKey) -> Result<ClientConfigPayload, AppError>,
) -> Result<FirstIssueAuthorization, AppError> {
self.retain_matching(document, vault);
let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt {
FirstIssueAttempt::Initial => (false, false, false),
FirstIssueAttempt::Retry {
confirm_possible_overwrite,
confirm_manual_cleanup,
} => (true, confirm_possible_overwrite, confirm_manual_cleanup),
};
let issue = self
.issues
.get(&identity)
.ok_or(AppError::FirstIssueUnavailable)?;
let allowed = match issue.stage {
IssueStage::AwaitingFirstFile => !is_retry,
IssueStage::RetryAvailable => is_retry,
IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite,
IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup,
IssueStage::PendingDraft | IssueStage::Authorizing(_) => false,
};
if !allowed {
return Err(AppError::FirstIssueUnavailable);
}
let current_context = context_for(
document,
vault.ok_or(AppError::FirstIssueUnavailable)?,
identity,
)?;
if current_context != issue.context {
self.issues.remove(&identity);
return Err(AppError::FirstIssueUnavailable);
}
let payload = render(&issue.private_key)?;
let context = issue.context.clone();
let token = self.next_authorization;
let next_token = token
.checked_add(1)
.ok_or(AppError::IssueAuthorizationExhausted)?;
let issue = self
.issues
.get_mut(&identity)
.ok_or(AppError::FirstIssueUnavailable)?;
self.next_authorization = next_token;
issue.stage = IssueStage::Authorizing(token);
Ok(FirstIssueAuthorization {
identity,
context,
token,
payload,
})
}
pub(super) fn validate_authorization(
&mut self,
authorization: &FirstIssueAuthorization,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> Result<(), AppError> {
self.retain_matching(document, vault);
let current = context_for(
document,
vault.ok_or(AppError::FirstIssueUnavailable)?,
authorization.identity,
)?;
if current != authorization.context
|| !matches!(
self.issues.get(&authorization.identity),
Some(issue)
if issue.stage == IssueStage::Authorizing(authorization.token)
&& issue.context == authorization.context
)
{
return Err(AppError::FirstIssueUnavailable);
}
Ok(())
}
pub(super) fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
if let Some(issue) = self.issues.get_mut(&identity)
&& matches!(issue.stage, IssueStage::Authorizing(_))
{
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
}
}
pub(super) fn complete_publication(
&mut self,
authorization: FirstIssueAuthorization,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> Result<(), AppError> {
self.validate_authorization(&authorization, document, vault)?;
self.issues.remove(&authorization.identity);
Ok(())
}
pub(super) fn fail_publication(
&mut self,
authorization: FirstIssueAuthorization,
failure: FirstIssueDeliveryFailure,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> Result<FirstIssueRetry, AppError> {
self.validate_authorization(&authorization, document, vault)?;
let issue = self
.issues
.get_mut(&authorization.identity)
.ok_or(AppError::FirstIssueUnavailable)?;
let retry = match failure {
FirstIssueDeliveryFailure::BeforePublication => {
issue.stage = IssueStage::RetryAvailable;
FirstIssueRetry::Available
}
FirstIssueDeliveryFailure::PublicationUncertain => {
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
FirstIssueRetry::OverwriteConfirmationRequired
}
FirstIssueDeliveryFailure::CleanupRequired => {
issue.stage = IssueStage::RetryNeedsManualCleanup;
FirstIssueRetry::ManualCleanupRequired
}
};
Ok(retry)
}
}
pub(super) fn context_for(
document: &ConfigDocument,
vault: &VaultBundle,
identity: PublicKey,
) -> Result<IssueContext, AppError> {
let matching = document
.peers()
.iter()
.filter(|peer| {
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
})
.collect::<Vec<_>>();
let [peer] = matching.as_slice() else {
return Err(AppError::FirstIssueUnavailable);
};
if peer.identity != Some(identity) || !peer.issues.is_empty() {
return Err(AppError::FirstIssueUnavailable);
}
// Alias/admin comments are descriptive metadata; only WireGuard directives
// bind first-issue authority, so renames do not invalidate the creation.
let peer_directives = document
.source_slice(peer.section)
.ok_or(AppError::FirstIssueUnavailable)?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
.collect::<Vec<_>>()
.join("\n");
let encrypted_record = vault
.record_context(identity)
.ok_or(AppError::FirstIssueUnavailable)?
.to_owned();
Ok(IssueContext {
peer_directives,
encrypted_record,
})
}
+183
View File
@@ -0,0 +1,183 @@
//! Password-free first-file delivery after Commit, with optional QR disclosure.
use super::{
AppError, Application, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome,
FirstIssueFileAndQr, FirstIssueFileDelivery,
};
use crate::delivery::files::{self, FileOutputError, PreparedDestination};
use crate::delivery::qr;
use crate::delivery::{ClientConfigPayload, QrCodeOutput, QrError};
use crate::domain::PublicKey;
impl Application {
/// Publish a password-free first issue while the committed peer, encrypted
/// record, and one-use token are valid. The mutable app borrow remains held
/// across `publish`, so no application mutation can interleave after the
/// final saved-state validation and before the file adapter runs.
#[allow(dead_code)] // Retained as a unit-test seam for generic publication closures.
pub(crate) fn deliver_first_issue<T, E>(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
}
pub(super) fn deliver_first_issue_with_warning<T, E>(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
let authorization = self.authorize_first_issue_inner(identity, attempt)?;
let activation_warning = match self.validate_first_issue_authorization(&authorization) {
Ok(warning) => warning,
Err(error) => {
self.require_overwrite_confirmation(identity);
return Err(error);
}
};
match publish(authorization.payload(), activation_warning) {
Ok(value) => {
if let Err(error) = self.mark_first_issue_delivered(authorization) {
// Publication is definite. Drop the session-held key even
// when saved-state bookkeeping cannot be completed.
self.first_delivery.remove(&identity);
return Ok(
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
error,
},
);
}
Ok(FirstIssueDeliveryOutcome::Delivered(value))
}
Err((error, failure)) => {
let retry = match self.mark_first_issue_delivery_failed(authorization, failure) {
Ok(retry) => retry,
Err(error) => {
self.require_overwrite_confirmation(identity);
return Err(error);
}
};
Ok(FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
})
}
}
}
/// Prepare a caller-selected plaintext destination without writing it. The
/// returned destination records any existing regular file so a later
/// overwrite confirmation is bound to this exact selection.
pub fn prepare_first_issue_destination(
&self,
path: impl AsRef<std::path::Path>,
) -> Result<PreparedDestination, FileOutputError> {
let protection = self.store.delivery_output_protection()?;
files::prepare_destination(path, &protection)
}
/// Deliver the committed new-client configuration through the app-owned
/// one-use authorization closure. A failed write retains retry eligibility;
/// a post-publication sync failure requires an explicit overwrite retry.
pub fn deliver_first_issue_file(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
self.deliver_first_issue_file_with(identity, attempt, destination, files::publish)
}
pub(super) fn deliver_first_issue_file_with(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
publish_file: impl FnOnce(
PreparedDestination,
&ClientConfigPayload,
) -> Result<(), FileOutputError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
publish_file(destination, payload).map_err(|error| {
let failure = match error {
FileOutputError::PublicationUncertain => {
FirstIssueDeliveryFailure::PublicationUncertain
}
FileOutputError::ManualCleanupRequired => {
FirstIssueDeliveryFailure::CleanupRequired
}
_ => FirstIssueDeliveryFailure::BeforePublication,
};
(error, failure)
})?;
Ok(FirstIssueFileDelivery { activation_warning })
})
}
/// Publish the mandatory plaintext file, then optionally encode a QR from
/// that same authorized payload. QR failure is returned separately and the
/// successful file publication still consumes first-issue eligibility.
pub fn deliver_first_issue_file_and_qr(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| {
qr::encode_payload(payload)
})
}
pub(super) fn deliver_first_issue_file_and_qr_with(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
self.deliver_first_issue_file_and_qr_with_publisher(
identity,
attempt,
destination,
files::publish,
encode,
)
}
pub(super) fn deliver_first_issue_file_and_qr_with_publisher(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
publish_file: impl FnOnce(
PreparedDestination,
&ClientConfigPayload,
) -> Result<(), FileOutputError>,
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
publish_file(destination, payload).map_err(|error| {
let failure = match error {
FileOutputError::PublicationUncertain => {
FirstIssueDeliveryFailure::PublicationUncertain
}
FileOutputError::ManualCleanupRequired => {
FirstIssueDeliveryFailure::CleanupRequired
}
_ => FirstIssueDeliveryFailure::BeforePublication,
};
(error, failure)
})?;
Ok(FirstIssueFileAndQr {
activation_warning,
qr: encode(payload),
})
})
}
}
+585
View File
@@ -0,0 +1,585 @@
//! Secret-free application facts prepared for the terminal and other readers.
use std::net::IpAddr;
use crate::domain::{ApplyStatus, Client, PublicKey};
use crate::wireguard::{ConfigDocument, PeerInspection, PeerIssue};
use super::Application;
use super::first_delivery::SessionFirstDeliveryAvailability;
/// A presentation action is available only as far as the current application
/// facts can establish. The operation itself remains the final authority.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionEligibility {
pub available: bool,
pub reason: Option<ActionUnavailableReason>,
}
impl ActionEligibility {
fn available() -> Self {
Self {
available: true,
reason: None,
}
}
fn unavailable(reason: ActionUnavailableReason) -> Self {
Self {
available: false,
reason: Some(reason),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActionUnavailableReason {
NoUniquePeer,
PeerNotInDraft,
PeerNotInSavedState,
UnsafePeerOrDocument,
ExactHostRoutesRequired,
VaultNotInitialized,
VaultRecordMissing,
VaultRecordConflict,
FirstDeliveryNotAvailable,
RestartPreviewRejected,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FirstDeliveryEligibility {
InitialFile,
Retry,
RetryNeedsOverwriteConfirmation,
RetryNeedsManualCleanup,
Unavailable,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientActions {
pub rename: ActionEligibility,
pub delete: ActionEligibility,
pub first_delivery: FirstDeliveryEligibility,
pub reissue: ActionEligibility,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerIssueClass {
MalformedOrAmbiguous,
UnsupportedCapability,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassifiedPeerIssue {
pub issue: PeerIssue,
pub class: PeerIssueClass,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientStateChange {
Added,
Removed,
Changed,
Unchanged,
UnsupportedOrUnmanaged,
}
/// Compatibility row for the original `display_projection` API.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientDisplay {
pub peer_index: usize,
pub line: usize,
pub identity: Option<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<PeerIssue>,
pub mutable_identity: Option<PublicKey>,
}
impl ClientDisplay {
/// Whether an issue is a valid configuration shape outside the current
/// client model's capabilities.
pub fn is_capability_limitation_issue(&self, issue: &PeerIssue) -> bool {
classify_issue(issue) == PeerIssueClass::UnsupportedCapability
}
pub fn presented_issues(&self) -> impl Iterator<Item = &PeerIssue> {
self.issues
.iter()
.filter(|issue| !self.is_capability_limitation_issue(issue))
}
}
/// Secret-free authoritative peer record used by the application read model.
/// Saved-only rows have no draft peer index and retain the former saved index.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerRecord {
pub peer_index: Option<usize>,
pub draft_peer_index: Option<usize>,
pub saved_peer_index: Option<usize>,
pub line: usize,
pub identity: Option<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<PeerIssue>,
pub classified_issues: Vec<ClassifiedPeerIssue>,
pub mutable_identity: Option<PublicKey>,
pub draft_client: Option<Client>,
pub saved_client: Option<Client>,
pub state_change: ClientStateChange,
pub actions: ClientActions,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReservationFacts {
pub committed: Vec<IpAddr>,
pub pending_host_routes: Vec<IpAddr>,
}
/// Compatibility projection for existing TUI callers. New consumers should
/// use [`ApplicationView`] for saved-state and action facts.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionDisplay {
pub clients: Vec<ClientDisplay>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
}
/// Public projection of application facts. It intentionally has no document,
/// saved-byte, vault, password, or client-payload owner.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ApplicationView {
pub clients: Vec<PeerRecord>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub saved_peer_count: usize,
pub draft_client_count: usize,
pub saved_client_count: usize,
pub reservations: ReservationFacts,
pub applied: ApplyStatus,
pub vault_initialized: bool,
pub has_draft: bool,
pub restart: ActionEligibility,
}
pub(super) fn legacy_session_display(application: &Application) -> SessionDisplay {
SessionDisplay {
clients: application
.draft
.document
.peers()
.iter()
.map(|peer| {
let projected = peer
.identity
.and_then(|identity| application.draft.document.project_client(&identity).ok());
let label = match &peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone())
}
_ => None,
}
.unwrap_or_else(|| {
peer.identity.map_or_else(
|| format!("Peer at line {}", peer.section_line),
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
)
});
ClientDisplay {
peer_index: peer.index,
line: peer.section_line,
identity: peer.identity,
label,
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
mutable_identity: projected
.filter(|client| {
application
.draft
.states
.draft()
.get(&client.identity)
.is_some()
})
.map(|client| client.identity),
}
})
.collect(),
document_issues: application.draft.document.issues().to_vec(),
}
}
pub(super) fn session_display(application: &Application) -> ApplicationView {
let draft_document = &application.draft.document;
// The saved document was parsed when the session opened and every saved
// replacement is parsed before publication. Keep this projection total if
// that internal invariant is ever violated: no saved records are inferred.
let saved_document = ConfigDocument::parse(&application.saved.server_config).ok();
let saved_peer_count = saved_document.as_ref().map_or(0, |doc| doc.peers().len());
let mut clients = draft_document
.peers()
.iter()
.map(|peer| display_peer(application, peer, saved_document.as_ref()))
.collect::<Vec<_>>();
let draft_peer_identities = draft_document
.peers()
.iter()
.flat_map(|peer| peer.public_key_candidates.iter().copied())
.collect::<std::collections::BTreeSet<_>>();
let mut saved_only_identities = std::collections::BTreeSet::new();
if let Some(document) = saved_document.as_ref() {
for saved_peer in document.peers() {
let Some(identity) = saved_peer.identity else {
continue;
};
if draft_peer_identities.contains(&identity)
|| !saved_only_identities.insert(identity)
|| document
.peers()
.iter()
.filter(|peer| peer.identity == Some(identity))
.count()
!= 1
{
continue;
}
let saved_client = application.draft.states.saved().get(&identity).cloned();
let label = saved_client.as_ref().map_or_else(
|| match &saved_peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
metadata.alias.clone()
}
_ => format!(
"{}…",
identity.to_string().chars().take(8).collect::<String>()
),
},
|client| client.alias.clone(),
);
clients.push(PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: Some(saved_peer.index),
line: saved_peer.section_line,
identity: Some(identity),
label,
allowed_ips: saved_peer
.allowed_ips
.iter()
.map(ToString::to_string)
.collect(),
has_subnet_routes: saved_peer.has_subnet_routes,
issues: saved_peer.issues.clone(),
classified_issues: classify_issues(&saved_peer.issues),
mutable_identity: None,
draft_client: None,
saved_client,
state_change: ClientStateChange::Removed,
actions: actions_for(application, Some(identity), false),
});
}
}
// A saved client can be absent from a malformed or unsupported saved
// document projection. Retain the state-backed row only when no
// authoritative peer row represented that identity above.
for client in application.draft.states.saved().clients() {
if draft_peer_identities.contains(&client.identity)
|| clients.iter().any(|peer| {
peer.state_change == ClientStateChange::Removed
&& peer.identity == Some(client.identity)
})
{
continue;
}
clients.push(PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: None,
line: 0,
identity: Some(client.identity),
label: client.alias.clone(),
allowed_ips: Vec::new(),
has_subnet_routes: false,
issues: Vec::new(),
classified_issues: Vec::new(),
mutable_identity: None,
draft_client: None,
saved_client: Some(client.clone()),
state_change: ClientStateChange::Removed,
actions: actions_for(application, Some(client.identity), false),
});
}
let reservations = application.draft.states.address_reservations();
let restart = if restart_is_eligible(application) {
ActionEligibility::available()
} else {
ActionEligibility::unavailable(ActionUnavailableReason::RestartPreviewRejected)
};
ApplicationView {
clients,
document_issues: draft_document.issues().to_vec(),
saved_peer_count,
draft_client_count: application.draft.states.draft().clients().count(),
saved_client_count: application.draft.states.saved().clients().count(),
reservations: ReservationFacts {
committed: reservations.committed().iter().copied().collect(),
pending_host_routes: reservations.pending_host_routes().collect(),
},
applied: *application.draft.states.applied(),
vault_initialized: application.draft.vault.is_some(),
has_draft: application.has_draft(),
restart,
}
}
fn display_peer(
application: &Application,
peer: &PeerInspection,
saved_document: Option<&ConfigDocument>,
) -> PeerRecord {
let identity = peer.identity;
let draft_client = identity.and_then(|key| application.draft.states.draft().get(&key).cloned());
let saved_client = identity.and_then(|key| application.draft.states.saved().get(&key).cloned());
let projected = identity.and_then(|key| application.draft.document.project_client(&key).ok());
let metadata_label = match &peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone())
}
_ => None,
};
let label = metadata_label.unwrap_or_else(|| {
identity.map_or_else(
|| format!("Peer at line {}", peer.section_line),
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
)
});
let saved_peer_index = identity.and_then(|key| {
saved_document.and_then(|document| {
document
.peers()
.iter()
.find(|saved_peer| saved_peer.identity == Some(key))
.map(|saved_peer| saved_peer.index)
})
});
let mutable_identity = projected
.filter(|client| {
draft_client
.as_ref()
.is_some_and(|draft| draft.identity == client.identity)
})
.map(|client| client.identity);
let state_change = match (&draft_client, &saved_client) {
(Some(_), None) => ClientStateChange::Added,
(None, Some(_)) => ClientStateChange::UnsupportedOrUnmanaged,
(Some(draft), Some(saved)) if draft == saved => ClientStateChange::Unchanged,
(Some(_), Some(_)) => ClientStateChange::Changed,
(None, None) => ClientStateChange::UnsupportedOrUnmanaged,
};
PeerRecord {
peer_index: Some(peer.index),
draft_peer_index: Some(peer.index),
saved_peer_index,
line: peer.section_line,
identity,
label,
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
classified_issues: classify_issues(&peer.issues),
mutable_identity,
draft_client,
saved_client,
state_change,
actions: actions_for(application, identity, true),
}
}
fn actions_for(
application: &Application,
identity: Option<PublicKey>,
peer_is_in_draft: bool,
) -> ClientActions {
let (rename, delete) = match identity {
None => {
let reason = ActionUnavailableReason::NoUniquePeer;
(
ActionEligibility::unavailable(reason),
ActionEligibility::unavailable(reason),
)
}
Some(_identity) if !peer_is_in_draft => {
let reason = ActionUnavailableReason::PeerNotInDraft;
(
ActionEligibility::unavailable(reason),
ActionEligibility::unavailable(reason),
)
}
Some(identity) => (
if application
.draft
.document
.rename_client(&identity, "eligibility probe")
.is_ok()
{
ActionEligibility::available()
} else {
ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument)
},
delete_eligibility(application, identity),
),
};
let first_delivery =
identity.map_or(
FirstDeliveryEligibility::Unavailable,
|identity| match application.first_delivery.read_model_availability(
identity,
&application.draft.document,
application.draft.vault.as_ref(),
) {
SessionFirstDeliveryAvailability::InitialFile => {
FirstDeliveryEligibility::InitialFile
}
SessionFirstDeliveryAvailability::Retry => FirstDeliveryEligibility::Retry,
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation => {
FirstDeliveryEligibility::RetryNeedsOverwriteConfirmation
}
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup => {
FirstDeliveryEligibility::RetryNeedsManualCleanup
}
SessionFirstDeliveryAvailability::Unavailable => {
FirstDeliveryEligibility::Unavailable
}
},
);
let reissue = match identity {
None => ActionEligibility::unavailable(ActionUnavailableReason::NoUniquePeer),
Some(identity) => reissue_eligibility(application, identity),
};
ClientActions {
rename,
delete,
first_delivery,
reissue,
}
}
fn delete_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
if application.draft.document.delete_client(&identity).is_err() {
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
}
let saved_peer = ConfigDocument::parse(&application.saved.server_config)
.ok()
.and_then(|document| {
document
.peers()
.iter()
.find(|peer| peer.identity == Some(identity))
.cloned()
});
if application.draft.states.saved().get(&identity).is_some() || saved_peer.is_none() {
return ActionEligibility::available();
}
let Some(peer) = saved_peer else {
return ActionEligibility::available();
};
let exact_host_routes = !peer.allowed_ips.is_empty()
&& peer.allowed_ips.iter().all(|route| match route {
ipnet::IpNet::V4(net) => net.prefix_len() == 32,
ipnet::IpNet::V6(net) => net.prefix_len() == 128,
});
if exact_host_routes {
ActionEligibility::available()
} else {
ActionEligibility::unavailable(ActionUnavailableReason::ExactHostRoutesRequired)
}
}
fn reissue_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
let saved_document = match ConfigDocument::parse(&application.saved.server_config) {
Ok(document) => document,
Err(_) => {
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
}
};
let matching_peers = saved_document
.peers()
.iter()
.filter(|peer| peer.identity == Some(identity))
.collect::<Vec<_>>();
let Some(peer) = matching_peers.first() else {
return ActionEligibility::unavailable(ActionUnavailableReason::PeerNotInSavedState);
};
if matching_peers.len() != 1
|| peer.identity != peer.public_key_candidates.first().copied()
|| peer.public_key_candidates.len() != 1
|| !peer.issues.is_empty()
|| peer.allowed_ips.is_empty()
{
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
}
let Some(bytes) = application.saved.vault.as_deref() else {
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
};
let vault_text = match std::str::from_utf8(bytes) {
Ok(text) => text,
Err(_) => {
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
}
};
let vault = match crate::vault::VaultBundle::from_toml(vault_text) {
Ok(vault) => vault,
Err(_) => {
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
}
};
if vault.contains_record(identity) {
if vault.reissue_blocked(identity).unwrap_or(true) {
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordConflict)
} else {
ActionEligibility::available()
}
} else {
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordMissing)
}
}
fn restart_is_eligible(application: &Application) -> bool {
let Ok(document) = ConfigDocument::parse(&application.saved.server_config) else {
return false;
};
document
.sections()
.iter()
.filter(|section| section.kind == crate::wireguard::SectionKind::Interface)
.count()
== 1
&& !document.has_unsafe_save_config()
&& super::restart::restart_projection_from_document(&document).is_ok()
}
fn classify_issues(issues: &[PeerIssue]) -> Vec<ClassifiedPeerIssue> {
issues
.iter()
.cloned()
.map(|issue| ClassifiedPeerIssue {
class: classify_issue(&issue),
issue,
})
.collect()
}
fn classify_issue(issue: &PeerIssue) -> PeerIssueClass {
match issue {
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
PeerIssueClass::UnsupportedCapability
}
_ => PeerIssueClass::MalformedOrAmbiguous,
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Freshly authenticated publication of a saved client's configuration.
use super::{Application, ReissueDelivery, ReissueError, ReissueFailureKind, ReissueStage};
use crate::delivery::files::{self, PreparedDestination};
use crate::delivery::qr;
use crate::delivery::{self, DeliverySettings};
use crate::domain::PublicKey;
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
impl Application {
/// Reissue a saved client's configuration after freshly unlocking its
/// installed vault record with this action's password. This operation reads
/// only installed saved bytes, never uses a draft or retained source file,
/// and publishes the required file before attempting optional QR encoding.
pub fn reissue_client_file(
&self,
identity: PublicKey,
password: &str,
destination: PreparedDestination,
include_qr: bool,
) -> Result<ReissueDelivery, ReissueError> {
let installed = self.store.read_saved().map_err(|_| ReissueError {
stage: ReissueStage::SavedState,
kind: ReissueFailureKind::SavedStateUnavailable,
})?;
let document =
ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError {
stage: ReissueStage::SavedState,
kind: ReissueFailureKind::SavedStateUnavailable,
})?;
let encoded_vault = installed.vault.as_deref().ok_or(ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::VaultUnavailable,
})?;
let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::VaultUnavailable,
})?;
let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::VaultUnavailable,
})?;
// Retrieval performs the password unlock and authenticated conflict
// check on every invocation. The short-lived owner is dropped when
// this method returns and is never kept in Application state.
let record = vault
.retrieve_client_record(identity, password)
.map_err(|_| ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::PasswordOrRecordRejected,
})?;
let resolved = self
.store
.resolved_settings_for(&installed)
.map_err(|_| ReissueError {
stage: ReissueStage::SavedState,
kind: ReissueFailureKind::SavedStateUnavailable,
})?;
let delivery_settings = DeliverySettings {
server_endpoint: &resolved.server_endpoint,
dns_addresses: &resolved.dns_addresses,
};
let payload = if record.is_new_template() {
delivery::render_new_client(
identity,
record.private_key().ok_or(ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::PasswordOrRecordRejected,
})?,
&document,
&delivery_settings,
)
} else {
delivery::render_imported_client(identity, &record, &document, &delivery_settings)
}
.map_err(|_| ReissueError {
stage: ReissueStage::Rendering,
kind: ReissueFailureKind::ConfigurationRejected,
})?;
files::publish(destination, &payload).map_err(|error| ReissueError {
stage: ReissueStage::FilePublication,
kind: ReissueFailureKind::FileOutput(error),
})?;
// A historical timestamp is deliberately insufficient: after reopen,
// the runtime observation is unknown. Warn unless this live session
// has verified the exact saved peer snapshot currently on disk.
let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document);
let qr = include_qr.then(|| qr::encode_payload(&payload));
Ok(ReissueDelivery {
activation_warning,
qr,
})
}
}
+670
View File
@@ -0,0 +1,670 @@
//! Saved-generation-bound restart preparation, execution, and publication.
//!
//! This module coordinates the service job, runtime observation and comparison,
//! durable verification bookkeeping, and applied state publication. Low-level
//! service and runtime adapters remain separate.
use std::time::{Duration, Instant};
use crate::backup::{self, BackupReason, HistoricalApplyStatus, SavedBytes};
use crate::config::ResolvedSettings;
use crate::domain::{PublicKey, Snapshot};
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::runtime::transport::TransportError;
use crate::runtime::{self, RuntimeObservation, SavedPeerProjection, TechnicalMatch};
use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome};
use crate::vault::VaultBundle;
use crate::wireguard::{ConfigDocument, SectionKind};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use super::{AppError, Application, clone_saved, historical_apply_from_saved, same_saved_bytes};
/// Read-only, saved-generation-bound preview used by the explicit restart confirmation flow.
pub struct RestartPreview {
saved: SavedBytes,
interface: String,
unit: String,
timeout: Duration,
projection: SavedPeerProjection,
expected_clients: Option<Snapshot>,
has_draft: bool,
}
impl RestartPreview {
pub fn interface(&self) -> &str {
&self.interface
}
pub fn systemd_unit(&self) -> &str {
&self.unit
}
pub const fn has_draft(&self) -> bool {
self.has_draft
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartChoice {
Cancel,
RestartSavedState,
DiscardDraftAndRestart,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartStage {
Cancelled,
Preparation,
Backup,
Service,
RuntimeRead,
Verification,
Persistence,
Complete,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartFailureKind {
InvalidOrStaleSavedGeneration,
UnsafeSaveConfig,
UnsupportedSavedShape,
BackupFailed,
DraftDiscardFailed,
ServiceFailed,
RuntimeReadFailed,
RuntimeNoDevice,
RuntimePermissionDenied,
RuntimeMalformed,
RuntimeTransport,
RuntimeTimeout,
RuntimeLimitExceeded,
RuntimeMismatch,
VerificationPersistenceFailed,
}
/// Secret-free explanation for saved configuration that cannot be used as a
/// restart verification target. The identity is a public WireGuard key.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartDiagnostic {
pub identity: Option<PublicKey>,
pub section_line: Option<usize>,
pub reason: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartReport {
pub stage: RestartStage,
pub failure: Option<RestartFailureKind>,
pub service_failure: Option<service::RestartFailure>,
pub service_phase: Option<service::RestartPhase>,
pub systemd_unit: Option<String>,
pub backup_path: Option<std::path::PathBuf>,
pub restart_submitted: bool,
pub restart_may_still_complete: bool,
pub manual_recovery_required: bool,
}
pub(super) struct RestartAttempt {
pub(super) id: u64,
pub(super) saved: SavedBytes,
}
pub(super) struct PreparedRestart {
pub(super) attempt_id: u64,
pub(super) installed: SavedBytes,
pub(super) settings: ResolvedSettings,
pub(super) projection: SavedPeerProjection,
pub(super) expected_clients: Option<Snapshot>,
pub(super) backup_path: std::path::PathBuf,
pub(super) deadline: service::RestartDeadline,
}
pub(super) fn preview_restart(application: &Application) -> Result<RestartPreview, AppError> {
let installed = application.store.read_saved()?;
if !same_saved_bytes(&installed, &application.saved) {
return Err(AppError::SavedGenerationChanged);
}
let settings = application.store.resolved_settings_for(&installed)?;
let document = ConfigDocument::parse(&installed.server_config)?;
if document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.count()
!= 1
{
return Err(AppError::InvalidSavedState);
}
if document.has_unsafe_save_config() {
return Err(AppError::UnsafeSaveConfig);
}
let (projection, expected_clients) =
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
let expected_clients =
expected_clients.filter(|snapshot| snapshot == application.draft.states.saved());
Ok(RestartPreview {
saved: clone_saved(&installed),
interface: settings.interface_name,
unit: settings.systemd_unit,
timeout: settings.restart_timeout,
projection,
expected_clients,
has_draft: application.has_draft(),
})
}
/// Validate the reviewed preview and preserve the established preparation
/// order: finalize the pre-restart backup before discarding any draft, then bind
/// the one-use attempt and initialize the single deadline before service work.
pub(super) fn prepare_confirmed_restart(
application: &mut Application,
preview: RestartPreview,
choice: RestartChoice,
now: &impl Fn() -> Instant,
) -> Result<PreparedRestart, RestartReport> {
if choice == RestartChoice::Cancel {
return Err(report(
RestartStage::Cancelled,
None,
None,
false,
false,
false,
));
}
application.active_restart_attempt = None;
if application.has_draft() != preview.has_draft
|| (preview.has_draft && choice != RestartChoice::DiscardDraftAndRestart)
|| (!preview.has_draft && choice == RestartChoice::DiscardDraftAndRestart)
{
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
None,
false,
false,
false,
));
}
let installed = match application.store.read_saved() {
Ok(saved)
if same_saved_bytes(&saved, &preview.saved)
&& same_saved_bytes(&saved, &application.saved) =>
{
saved
}
_ => {
application.active_restart_attempt = None;
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
None,
false,
false,
false,
));
}
};
let settings = match application.store.resolved_settings_for(&installed) {
Ok(settings) => settings,
Err(_) => return Err(preparation_failure()),
};
let document = match ConfigDocument::parse(&installed.server_config) {
Ok(document) => document,
Err(_) => return Err(preparation_failure()),
};
if document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.count()
!= 1
{
return Err(preparation_failure());
}
if document.has_unsafe_save_config() {
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::UnsafeSaveConfig),
None,
false,
false,
false,
));
}
let (projection, expected_clients) = match restart_projection_from_document(&document) {
Ok(projection) => projection,
Err(_) => {
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::UnsupportedSavedShape),
None,
false,
false,
false,
));
}
};
let expected_clients =
expected_clients.filter(|snapshot| snapshot == application.draft.states.saved());
if settings.interface_name != preview.interface
|| settings.systemd_unit != preview.unit
|| settings.restart_timeout != preview.timeout
|| projection != preview.projection
|| expected_clients != preview.expected_clients
{
return Err(preparation_failure());
}
let historical_apply = match historical_apply_from_saved(&installed) {
Ok(status) => status,
Err(_) => return Err(preparation_failure()),
};
let backup_path = match backup::create_archive(
application.store.archives_directory(),
BackupReason::PreRestart,
historical_apply,
&installed,
) {
Ok(path) => path,
Err(_) => {
application.active_restart_attempt = None;
return Err(report(
RestartStage::Backup,
Some(RestartFailureKind::BackupFailed),
None,
false,
false,
false,
));
}
};
let id = application.next_restart_attempt;
let Some(next_id) = id.checked_add(1) else {
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
Some(backup_path),
false,
false,
false,
));
};
application.next_restart_attempt = next_id;
application.active_restart_attempt = Some(id);
if choice == RestartChoice::DiscardDraftAndRestart && application.discard_draft().is_err() {
application.active_restart_attempt = None;
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::DraftDiscardFailed),
Some(backup_path),
false,
false,
false,
));
}
application.draft.states.runtime_knowledge_unknown();
let deadline = service::RestartDeadline::new(now(), settings.restart_timeout);
Ok(PreparedRestart {
attempt_id: id,
installed,
settings,
projection,
expected_clients,
backup_path,
deadline,
})
}
/// Run the confirmed restart scenario after preparation has completed. The
/// backup is already durable before this function is entered; exactly one
/// shared deadline bounds the service job and the subsequent runtime read.
pub(super) fn execute_confirmed_restart(
application: &mut Application,
prepared: PreparedRestart,
service: impl FnOnce(&str, RestartDeadline) -> RestartExecution,
runtime_read: impl FnOnce(&str, Duration) -> Result<RuntimeObservation, TransportError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
now: impl Fn() -> Instant,
) -> RestartReport {
let PreparedRestart {
attempt_id,
installed,
settings,
projection,
expected_clients,
backup_path,
deadline,
} = prepared;
let execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome;
let submitted = execution.submitted;
let service_phase = execution.phase;
if job != RestartOutcome::Succeeded {
application.active_restart_attempt = None;
let may_complete = submitted
&& !matches!(
job,
RestartOutcome::Failed(
service::RestartFailure::JobFailed
| service::RestartFailure::JobDependency
| service::RestartFailure::JobTimedOut
| service::RestartFailure::JobCanceled
| service::RestartFailure::JobSkipped
| service::RestartFailure::JobResultUnknown
)
);
let mut report = report(
RestartStage::Service,
Some(RestartFailureKind::ServiceFailed),
Some(backup_path),
submitted,
may_complete,
submitted,
);
report.service_failure = match job {
RestartOutcome::Failed(failure) => Some(failure),
RestartOutcome::Waiting | RestartOutcome::Succeeded => None,
};
report.service_phase = Some(service_phase);
report.systemd_unit = Some(settings.systemd_unit);
return report;
}
let remaining = deadline.remaining_at(now());
if remaining.is_zero() {
application.active_restart_attempt = None;
return report(
RestartStage::RuntimeRead,
Some(RestartFailureKind::RuntimeTimeout),
Some(backup_path),
true,
false,
true,
);
}
let observation = match runtime_read(&settings.interface_name, remaining) {
Ok(observation) => observation,
Err(error) => {
application.active_restart_attempt = None;
return report(
RestartStage::RuntimeRead,
Some(match error {
TransportError::NoDevice => RestartFailureKind::RuntimeNoDevice,
TransportError::PermissionDenied => RestartFailureKind::RuntimePermissionDenied,
TransportError::Malformed => RestartFailureKind::RuntimeMalformed,
TransportError::Transport => RestartFailureKind::RuntimeTransport,
TransportError::Timeout => RestartFailureKind::RuntimeTimeout,
TransportError::LimitExceeded => RestartFailureKind::RuntimeLimitExceeded,
}),
Some(backup_path),
true,
false,
true,
);
}
};
if deadline.is_expired_at(now()) {
application.active_restart_attempt = None;
return report(
RestartStage::Verification,
Some(RestartFailureKind::RuntimeTimeout),
Some(backup_path),
true,
false,
true,
);
}
if runtime::compare_peers(&projection, &settings.interface_name, &observation)
!= TechnicalMatch::Match
{
application.active_restart_attempt = None;
return report(
RestartStage::Verification,
Some(RestartFailureKind::RuntimeMismatch),
Some(backup_path),
true,
false,
true,
);
}
let Some(verified_at) = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.ok()
.and_then(|timestamp| timestamp.format(&Rfc3339).ok())
else {
application.active_restart_attempt = None;
return report(
RestartStage::Persistence,
Some(RestartFailureKind::VerificationPersistenceFailed),
Some(backup_path),
true,
false,
true,
);
};
let attempt = RestartAttempt {
id: attempt_id,
saved: clone_saved(&installed),
};
if mark_verified_with_attempt(application, attempt, expected_clients, verified_at, install)
.is_err()
{
application.active_restart_attempt = None;
return report(
RestartStage::Persistence,
Some(RestartFailureKind::VerificationPersistenceFailed),
Some(backup_path),
true,
false,
true,
);
}
report(
RestartStage::Complete,
None,
Some(backup_path),
true,
false,
false,
)
}
/// Persist verified apply knowledge using the active restart's one-use attempt
/// and exact saved generation. Session publication follows durable success.
pub(super) fn mark_verified_with_attempt(
application: &mut Application,
attempt: RestartAttempt,
observed_management_snapshot: Option<Snapshot>,
verified_at_utc: String,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<(), AppError> {
if application.active_restart_attempt != Some(attempt.id) {
return Err(AppError::PreRestartBackupRequired);
}
let installed = application.store.read_saved()?;
if !same_saved_bytes(&installed, &application.saved)
|| !same_saved_bytes(&installed, &attempt.saved)
{
return Err(AppError::SavedGenerationChanged);
}
let mut next_states = application.draft.states.clone();
next_states.prepare_technically_verified_result(observed_management_snapshot)?;
let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?,
None => PersistedState::new(
next_states
.reserved_addresses()
.iter()
.map(ToString::to_string),
None,
)?,
};
persisted.reserved_addresses = next_states
.reserved_addresses()
.iter()
.map(ToString::to_string)
.collect();
let historical_apply = HistoricalApplyStatus::Verified {
verified_at_utc: verified_at_utc.clone(),
};
persisted.last_verified_at_utc = Some(verified_at_utc);
let mut proposed = installed;
proposed.internal_state = Some(persisted.to_toml()?);
application.store.validate_saved(&proposed)?;
if let Err(error) = install(&application.store, &proposed, BackupComplete(())) {
application
.first_delivery
.invalidate_active_authorizations();
match application.store.read_saved() {
Ok(current) => {
let document = ConfigDocument::parse(&current.server_config).ok();
let vault = current
.vault
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document {
application
.first_delivery
.retain_matching(&document, vault.as_ref());
} else {
application.first_delivery.clear();
}
}
Err(_) => application.first_delivery.clear(),
}
return Err(error.into());
}
application.saved = proposed;
application.draft.states = next_states;
application.verified_saved_projection = Some(SavedPeerProjection::from_document(
&application.draft.document,
));
application.historical_apply = historical_apply;
application.active_restart_attempt = None;
Ok(())
}
fn preparation_failure() -> RestartReport {
report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
None,
false,
false,
false,
)
}
pub(super) fn report(
stage: RestartStage,
failure: Option<RestartFailureKind>,
backup_path: Option<std::path::PathBuf>,
restart_submitted: bool,
restart_may_still_complete: bool,
manual_recovery_required: bool,
) -> RestartReport {
RestartReport {
stage,
failure,
service_failure: None,
service_phase: None,
systemd_unit: None,
backup_path,
restart_submitted,
restart_may_still_complete,
manual_recovery_required,
}
}
pub(super) fn restart_projection_from_document(
document: &ConfigDocument,
) -> Result<(SavedPeerProjection, Option<Snapshot>), Vec<RestartDiagnostic>> {
use crate::wireguard::{DocumentIssue, PeerIssue};
let mut diagnostics = Vec::new();
for issue in document.issues() {
let (line, reason) = match issue {
DocumentIssue::MalformedSectionHeader { line } => (*line, "malformed section header"),
DocumentIssue::DirectiveOutsideSection { line } => {
(*line, "directive appears outside a section")
}
DocumentIssue::MalformedDirective { line } => (*line, "malformed directive"),
DocumentIssue::ManagedMetadata(_) => continue,
};
diagnostics.push(RestartDiagnostic {
identity: None,
section_line: Some(line),
reason,
});
}
for peer in document.peers() {
let identity = peer
.identity
.or_else(|| peer.public_key_candidates.first().copied());
for issue in &peer.issues {
let reason = match issue {
PeerIssue::MissingPublicKey => Some("peer is missing a public key"),
PeerIssue::MalformedPublicKey { .. } => Some("peer has a malformed public key"),
PeerIssue::DuplicatePublicKeyField { .. } => {
Some("peer has duplicate public key fields")
}
PeerIssue::DuplicatePeerIdentity { .. } => {
Some("public key is used by multiple peer sections")
}
PeerIssue::MalformedAllowedIp { .. } => {
Some("peer has a malformed AllowedIPs item")
}
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
None
}
};
if let Some(reason) = reason {
diagnostics.push(RestartDiagnostic {
identity,
section_line: Some(peer.section_line),
reason,
});
}
}
}
let projection = SavedPeerProjection::from_document(document);
if (projection.document_unverifiable
|| !projection.unverifiable_peer_indices.is_empty()
|| !projection.duplicate_public_keys.is_empty())
&& diagnostics.is_empty()
{
diagnostics.push(RestartDiagnostic {
identity: None,
section_line: None,
reason: "saved peer set is not uniquely verifiable",
});
}
if !diagnostics.is_empty() {
return Err(diagnostics);
}
let mut clients = Vec::with_capacity(document.peers().len());
for peer in document.peers() {
let Some(identity) = peer.identity else {
return Err(vec![RestartDiagnostic {
identity: None,
section_line: Some(peer.section_line),
reason: "peer identity is unavailable",
}]);
};
let Ok(client) = document.project_client(&identity) else {
clients.clear();
break;
};
clients.push(client);
}
let management_snapshot = (clients.len() == document.peers().len())
.then(|| Snapshot::new(clients).ok())
.flatten();
Ok((projection, management_snapshot))
}
+221
View File
@@ -0,0 +1,221 @@
//! Previewed, saved-state-only restore scenario.
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::backup::{self, BackupReason, SavedBytes};
use crate::domain::{ApplyStatus, ClientStates};
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::vault::VaultBundle;
use crate::wireguard::{ConfigDocument, PeerInspection};
use super::{
AppError, Application, RestorePreview, historical_apply_from_saved, safe_archive_name,
same_saved_bytes, snapshot_from_document,
};
struct PreparedRestore {
proposed: SavedBytes,
resolved_settings: crate::config::ResolvedSettings,
document: ConfigDocument,
vault: Option<VaultBundle>,
states: ClientStates,
}
pub(super) fn preview_restore(
application: &Application,
file_name: &str,
) -> Result<RestorePreview, AppError> {
if !safe_archive_name(file_name) {
return Err(AppError::InvalidArchiveSelection);
}
let installed_generation = application.store.read_saved()?;
let current_document = ConfigDocument::parse(&installed_generation.server_config)?;
let selected_path = application.store.archives_directory().join(file_name);
let archive = backup::preview_archive(&selected_path)?;
application.store.validate_saved(&archive.saved_bytes)?;
let mut proposed = archive.saved_bytes;
let document = ConfigDocument::parse(&proposed.server_config)?;
let mut states = ClientStates::new(
snapshot_from_document(&document),
None,
ApplyStatus::Unknown,
)?;
let mut carried_reservations = application
.draft
.states
.address_reservations()
.all()
.collect::<BTreeSet<_>>();
carried_reservations.extend(
snapshot_from_document(&current_document)
.clients()
.flat_map(|client| client.addresses.iter()),
);
// Saved peers outside the manageable client projection can still own
// exact host routes. Keep those addresses if this restore removes them;
// never enumerate broad routes or infer other addresses from a subnet.
carried_reservations.extend(exact_host_route_reservations(&current_document));
let archived_had_state = proposed.internal_state.is_some();
let mut archived_reservations = BTreeSet::new();
if let Some(state_bytes) = &proposed.internal_state {
let archived = PersistedState::from_toml(state_bytes)?;
archived_reservations = archived
.reserved_addresses
.iter()
.map(|address| {
address
.parse::<IpAddr>()
.map_err(|_| AppError::InvalidSavedState)
})
.collect::<Result<BTreeSet<_>, _>>()?;
}
let restored_exact_hosts = exact_host_routes(&document);
let mut reservations = carried_reservations.clone();
reservations.extend(archived_reservations.iter().copied());
reservations.retain(|address| {
let assigned_to_restored_client = states
.saved()
.clients()
.any(|client| client.addresses.iter().any(|saved| saved == *address));
!assigned_to_restored_client
&& (archived_reservations.contains(address)
|| !carried_reservations.contains(address)
|| !restored_exact_hosts.contains(address))
});
if !reservations.is_empty() || archived_had_state {
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
state.apply_to(&mut states)?;
proposed.internal_state = Some(state.to_toml()?);
}
application.store.validate_saved(&proposed)?;
Ok(RestorePreview {
created_at_utc: archive.created_at_utc,
reason: archive.reason,
historical_apply: archive.historical_apply,
installed_generation,
saved_bytes: proposed,
})
}
pub(super) fn confirm_restore(
application: &mut Application,
preview: RestorePreview,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
let current = application.store.read_saved()?;
if !same_saved_bytes(&current, &preview.installed_generation) {
return Err(AppError::SavedGenerationChanged);
}
let prepared = prepare_restore(application, preview.saved_bytes)?;
let historical_apply = historical_apply_from_saved(&current)?;
let archive = backup::create_archive(
application.store.archives_directory(),
BackupReason::PreRestore,
historical_apply,
&current,
)?;
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
// A partial journaled install may leave disk in a recovery-required
// state. Revoke session-only delivery rights until the store is read
// again and the actual installed generation is known.
application.first_delivery.clear();
return Err(error.into());
}
application.saved = prepared.proposed;
application
.store
.set_resolved_settings(prepared.resolved_settings);
application.draft.document = prepared.document;
application.draft.vault = prepared.vault;
application.draft.states = prepared.states;
application.first_delivery.clear();
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None;
application.active_restart_attempt = None;
Ok(archive)
}
fn prepare_restore(
application: &Application,
proposed: SavedBytes,
) -> Result<PreparedRestore, AppError> {
application.store.validate_saved(&proposed)?;
// Check installation path compatibility and parse every in-memory value
// before the precautionary archive or file replacement begins.
let resolved_settings = application.store.resolved_settings_for(&proposed)?;
let document = ConfigDocument::parse(&proposed.server_config)?;
let vault = proposed
.vault
.as_deref()
.map(std::str::from_utf8)
.transpose()
.map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml)
.transpose()?;
let mut states = ClientStates::new(
snapshot_from_document(&document),
None,
ApplyStatus::Unknown,
)?;
if let Some(bytes) = &proposed.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
}
Ok(PreparedRestore {
proposed,
resolved_settings,
document,
vault,
states,
})
}
fn exact_host_route_reservations(document: &ConfigDocument) -> BTreeSet<IpAddr> {
let mut reservations = BTreeSet::new();
for peer in document.peers() {
if manageable_peer(document, peer) {
continue;
}
for route in &peer.allowed_ips {
match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
reservations.insert(IpAddr::V4(network.addr()));
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
reservations.insert(IpAddr::V6(network.addr()));
}
_ => {}
}
}
}
reservations
}
fn exact_host_routes(document: &ConfigDocument) -> BTreeSet<IpAddr> {
document
.peers()
.iter()
.flat_map(|peer| peer.allowed_ips.iter())
.filter_map(|route| match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
Some(IpAddr::V4(network.addr()))
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
Some(IpAddr::V6(network.addr()))
}
_ => None,
})
.collect()
}
fn manageable_peer(document: &ConfigDocument, peer: &PeerInspection) -> bool {
peer.identity
.is_some_and(|identity| document.project_client(&identity).is_ok())
}
File diff suppressed because it is too large Load Diff
+807
View File
@@ -0,0 +1,807 @@
//! Deterministic, bounded rendering of explicitly authorized client configurations.
//!
//! Rendering is pure: callers supply the authoritative saved document, the derived
//! server identity, and settings. Plaintext output is held in [`ClientConfigPayload`]
//! and exposed only through its explicit byte accessor.
//!
//! QR encoding is intentionally not a public API. It is only reachable through
//! the application's file-and-QR delivery operation, which publishes the
//! mandatory plaintext file before encoding those same bytes.
//!
//! ```compile_fail
//! use wireguard_admin::delivery::qr::encode_payload;
//! ```
use std::collections::BTreeSet;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use ipnet::IpNet;
use thiserror::Error;
use zeroize::Zeroizing;
pub mod files;
pub(crate) mod qr;
pub use qr::{QrCodeOutput, QrError};
use crate::config::{EndpointHost, ServerEndpoint};
use crate::domain::PublicKey;
use crate::vault::{RetrievedClientRecord, WireGuardPrivateKey};
use crate::wireguard::{ConfigDocument, PeerInspection};
const MAX_CONFIG_BYTES: usize = 64 * 1024;
const MAX_DNS_ENTRIES: usize = 32;
const MAX_ROUTE_ENTRIES: usize = 256;
/// Validated connection defaults used for new clients and missing imported values.
///
/// Values are checked again here because public settings structs can be constructed
/// directly by library callers without passing through settings-file validation.
pub struct DeliverySettings<'a> {
pub server_endpoint: &'a ServerEndpoint,
pub dns_addresses: &'a [IpAddr],
}
/// Secret-bearing rendered file bytes. There is no `Display` or serialization API.
pub struct ClientConfigPayload(Zeroizing<Vec<u8>>);
impl ClientConfigPayload {
/// Explicitly expose rendered configuration bytes to a delivery operation.
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for ClientConfigPayload {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientConfigPayload")
.field("length", &self.0.len())
.field("contents", &"[REDACTED]")
.finish()
}
}
/// Secret-free rendering failures. Variants never contain source text or key material.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum DeliveryError {
#[error("client identity does not match the authorized private key")]
ClientIdentityMismatch,
#[error("client peer is missing from the authoritative saved configuration")]
MissingSavedPeer,
#[error("client identity is ambiguous in the authoritative saved configuration")]
AmbiguousSavedPeer,
#[error("authoritative saved peer data is malformed or unsupported")]
InvalidSavedPeer,
#[error("retained client addresses do not match authoritative saved addresses")]
AddressMismatch,
#[error("retained server identity does not match the derived saved server identity")]
ServerIdentityMismatch,
#[error("authoritative saved server identity is missing, ambiguous, or invalid")]
InvalidSavedServerIdentity,
#[error("client connection settings are invalid or unsupported")]
InvalidConnectionField,
#[error("rendered client configuration exceeds its size limit")]
TooLarge,
}
/// Render a newly created client's committed connection using a full-tunnel template.
pub fn render_new_client(
identity: PublicKey,
private_key: &WireGuardPrivateKey,
saved: &ConfigDocument,
settings: &DeliverySettings<'_>,
) -> Result<ClientConfigPayload, DeliveryError> {
if !private_key.matches_public_key(&identity) {
return Err(DeliveryError::ClientIdentityMismatch);
}
let server_public_key = authoritative_server_public_key(saved)?;
let addresses = authoritative_addresses(saved, identity, true)?;
let endpoint = render_endpoint(settings.server_endpoint)?;
let dns = render_dns(settings.dns_addresses)?;
let private_text = Zeroizing::new(private_key.to_base64());
let mut output = Zeroizing::new(Vec::with_capacity(256));
append(&mut output, "[Interface]\nPrivateKey = ")?;
append(&mut output, private_text.as_str())?;
append(&mut output, "\nAddress = ")?;
append(&mut output, &addresses)?;
append(&mut output, "\nDNS = ")?;
append(&mut output, &dns)?;
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
append(&mut output, &server_public_key.to_string())?;
append(&mut output, "\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ")?;
append(&mut output, &endpoint)?;
append(&mut output, "\n")?;
finish(output)
}
/// Render a password-authorized imported record, retaining validated supported fields.
///
/// The record type is only obtainable through fresh vault retrieval, which rejects
/// authenticated `reissue_blocked` records. Its retained fields are immutable to
/// library callers, and the renderer verifies their authenticated provenance and
/// server binding against the authoritative saved configuration.
pub fn render_imported_client(
identity: PublicKey,
record: &RetrievedClientRecord,
saved: &ConfigDocument,
settings: &DeliverySettings<'_>,
) -> Result<ClientConfigPayload, DeliveryError> {
let server_public_key = authoritative_server_public_key(saved)?;
if !record.is_imported() {
return Err(DeliveryError::ServerIdentityMismatch);
}
let private_key = record
.private_key()
.ok_or(DeliveryError::ClientIdentityMismatch)?;
if !private_key.matches_public_key(&identity) {
return Err(DeliveryError::ClientIdentityMismatch);
}
let addresses = authoritative_addresses(saved, identity, false)?;
validate_retained_addresses(record.client_addresses(), &addresses)?;
let expected_server_public_key = server_public_key.to_string();
if record.server_public_key() != Some(expected_server_public_key.as_str()) {
return Err(DeliveryError::ServerIdentityMismatch);
}
let interface_addresses = parse_address_string(&addresses)?;
let dns_value = match record.interface_dns() {
Some(value) => validate_dns(value)?,
None => render_dns(settings.dns_addresses)?,
};
let endpoint = match record.endpoint() {
Some(value) => validate_endpoint(value)?,
None => render_endpoint(settings.server_endpoint)?,
};
let mtu = record.interface_mtu().map(validate_mtu).transpose()?;
let keepalive = record
.persistent_keepalive()
.map(validate_keepalive)
.transpose()?;
let routes = validate_routes(record.allowed_ips())?;
let psk = record
.preshared_key_base64()
.map(validate_secret_key)
.transpose()?;
let private_text = Zeroizing::new(private_key.to_base64());
let mut output = Zeroizing::new(Vec::with_capacity(512));
append(&mut output, "[Interface]\nPrivateKey = ")?;
append(&mut output, private_text.as_str())?;
append(&mut output, "\nAddress = ")?;
append(&mut output, &interface_addresses)?;
append(&mut output, "\nDNS = ")?;
append(&mut output, &dns_value)?;
if let Some(mtu) = mtu {
append(&mut output, "\nMTU = ")?;
append(&mut output, &mtu)?;
}
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
append(&mut output, &server_public_key.to_string())?;
if let Some(psk) = psk {
append(&mut output, "\nPresharedKey = ")?;
append(&mut output, psk.as_str())?;
}
append(&mut output, "\nAllowedIPs = ")?;
append(&mut output, &routes)?;
append(&mut output, "\nEndpoint = ")?;
append(&mut output, &endpoint)?;
if let Some(keepalive) = keepalive {
append(&mut output, "\nPersistentKeepalive = ")?;
append(&mut output, &keepalive)?;
}
append(&mut output, "\n")?;
finish(output)
}
/// Derive the server identity only from one valid `[Interface] PrivateKey` in
/// the authoritative saved document. The private key and source text remain
/// borrowed/local to this call and never enter an error or owned long-lived value.
fn authoritative_server_public_key(saved: &ConfigDocument) -> Result<PublicKey, DeliveryError> {
use crate::wireguard::SectionKind;
let interfaces = saved
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.collect::<Vec<_>>();
let [interface] = interfaces.as_slice() else {
return Err(DeliveryError::InvalidSavedServerIdentity);
};
let section = saved
.source_slice(interface.span)
.ok_or(DeliveryError::InvalidSavedServerIdentity)?;
let mut private_key_value = None;
for line in section.lines() {
let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace());
if trimmed.is_empty() || trimmed.starts_with(['#', ';']) {
continue;
}
let has_private_key_name = trimmed
.split(['=', ' ', '\t'])
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("PrivateKey"));
if !has_private_key_name {
continue;
}
if private_key_value.is_some() {
return Err(DeliveryError::InvalidSavedServerIdentity);
}
let Some((name, value)) = trimmed.split_once('=') else {
return Err(DeliveryError::InvalidSavedServerIdentity);
};
if !name
.trim_matches(|ch: char| ch.is_ascii_whitespace())
.eq_ignore_ascii_case("PrivateKey")
{
return Err(DeliveryError::InvalidSavedServerIdentity);
}
private_key_value = Some(value.trim_matches(|ch: char| ch.is_ascii_whitespace()));
}
let private_key = WireGuardPrivateKey::parse_base64(
private_key_value.ok_or(DeliveryError::InvalidSavedServerIdentity)?,
)
.map_err(|_| DeliveryError::InvalidSavedServerIdentity)?;
Ok(private_key.public_key())
}
fn authoritative_addresses(
saved: &ConfigDocument,
identity: PublicKey,
require_dual_stack: bool,
) -> Result<String, DeliveryError> {
let matching = saved
.peers()
.iter()
.filter(|peer| {
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
})
.collect::<Vec<_>>();
let peer = match matching.as_slice() {
[] => return Err(DeliveryError::MissingSavedPeer),
[peer] => *peer,
_ => return Err(DeliveryError::AmbiguousSavedPeer),
};
validate_peer(peer)?;
let mut v4 = None;
let mut v6 = None;
for network in &peer.allowed_ips {
match network {
IpNet::V4(network)
if network.prefix_len() == 32 && v4.replace(network.addr()).is_none() => {}
IpNet::V6(network)
if network.prefix_len() == 128 && v6.replace(network.addr()).is_none() => {}
_ => return Err(DeliveryError::InvalidSavedPeer),
}
}
let v4 = v4.ok_or(DeliveryError::InvalidSavedPeer)?;
if require_dual_stack && v6.is_none() {
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(format_addresses(v4, v6))
}
fn validate_peer(peer: &PeerInspection) -> Result<(), DeliveryError> {
if peer.identity.is_none()
|| peer.identity != peer.public_key_candidates.first().copied()
|| peer.public_key_candidates.len() != 1
|| !peer.issues.is_empty()
|| peer.allowed_ips.is_empty()
{
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(())
}
fn format_addresses(v4: Ipv4Addr, v6: Option<Ipv6Addr>) -> String {
let mut output = format!("{v4}/32");
if let Some(v6) = v6 {
output.push_str(&format!(", {v6}/128"));
}
output
}
fn parse_address_string(value: &str) -> Result<String, DeliveryError> {
let items = value.split(", ").collect::<Vec<_>>();
if items.is_empty() || items.len() > 2 {
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(items.join(", "))
}
fn validate_retained_addresses(
retained: &[String],
authoritative: &str,
) -> Result<(), DeliveryError> {
if retained.is_empty() || retained.len() > 2 || retained.iter().any(|value| value.len() > 64) {
return Err(DeliveryError::AddressMismatch);
}
let expected = authoritative
.split(", ")
.map(str::to_owned)
.collect::<BTreeSet<_>>();
let mut actual = BTreeSet::new();
for value in retained {
if value.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let network = IpNet::from_str(value).map_err(|_| DeliveryError::InvalidConnectionField)?;
match network {
IpNet::V4(net) if net.prefix_len() == 32 => {}
IpNet::V6(net) if net.prefix_len() == 128 => {}
_ => return Err(DeliveryError::InvalidConnectionField),
}
actual.insert(network.to_string());
}
if actual == expected {
Ok(())
} else {
Err(DeliveryError::AddressMismatch)
}
}
fn render_dns(dns: &[IpAddr]) -> Result<String, DeliveryError> {
if dns.is_empty() || dns.len() > MAX_DNS_ENTRIES {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(dns
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "))
}
fn validate_dns(value: &str) -> Result<String, DeliveryError> {
if value.len() > 2048 || value.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
if items.is_empty() || items.len() > MAX_DNS_ENTRIES || items.iter().any(|item| item.is_empty())
{
return Err(DeliveryError::InvalidConnectionField);
}
let addresses = items
.iter()
.map(|item| {
item.parse::<IpAddr>()
.map_err(|_| DeliveryError::InvalidConnectionField)
})
.collect::<Result<Vec<_>, _>>()?;
render_dns(&addresses)
}
fn render_endpoint(endpoint: &ServerEndpoint) -> Result<String, DeliveryError> {
if endpoint.port == 0 {
return Err(DeliveryError::InvalidConnectionField);
}
match &endpoint.host {
EndpointHost::Ip(IpAddr::V6(address)) => Ok(format!("[{address}]:{}", endpoint.port)),
EndpointHost::Ip(address) => Ok(format!("{address}:{}", endpoint.port)),
EndpointHost::Hostname(host) => {
validate_hostname(host)?;
Ok(format!("{host}:{}", endpoint.port))
}
}
}
fn validate_endpoint(value: &str) -> Result<String, DeliveryError> {
if value.is_empty() || value.chars().any(char::is_control) || value.len() > 255 {
return Err(DeliveryError::InvalidConnectionField);
}
let (host, port) = if let Some(rest) = value.strip_prefix('[') {
let (host, suffix) = rest
.split_once(']')
.ok_or(DeliveryError::InvalidConnectionField)?;
let port = suffix
.strip_prefix(':')
.ok_or(DeliveryError::InvalidConnectionField)?;
let address = host
.parse::<Ipv6Addr>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
(format!("[{address}]"), parse_port(port)?)
} else {
let (host, port) = value
.rsplit_once(':')
.ok_or(DeliveryError::InvalidConnectionField)?;
if host.contains(':') {
return Err(DeliveryError::InvalidConnectionField);
}
let formatted = if let Ok(address) = host.parse::<Ipv4Addr>() {
address.to_string()
} else {
validate_hostname(host)?;
host.to_owned()
};
(formatted, parse_port(port)?)
};
Ok(format!("{host}:{port}"))
}
fn validate_hostname(host: &str) -> Result<(), DeliveryError> {
if host.is_empty()
|| host.len() > 253
|| host.split('.').any(|label| {
label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label.as_bytes()[0].is_ascii_alphanumeric()
|| !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
{
return Err(DeliveryError::InvalidConnectionField);
}
Ok(())
}
fn parse_port(value: &str) -> Result<u16, DeliveryError> {
let port = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if port == 0 || port.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(port)
}
fn validate_mtu(value: &str) -> Result<String, DeliveryError> {
let mtu = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if mtu == 0 || mtu.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(mtu.to_string())
}
fn validate_keepalive(value: &str) -> Result<String, DeliveryError> {
if value.len() > 5 {
return Err(DeliveryError::InvalidConnectionField);
}
let keepalive = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if keepalive.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(keepalive.to_string())
}
fn validate_routes(routes: &[String]) -> Result<String, DeliveryError> {
if routes.is_empty()
|| routes.len() > MAX_ROUTE_ENTRIES
|| routes.iter().any(|route| route.len() > 128)
{
return Err(DeliveryError::InvalidConnectionField);
}
let mut parsed = Vec::with_capacity(routes.len());
for route in routes {
if route.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let network = IpNet::from_str(route).map_err(|_| DeliveryError::InvalidConnectionField)?;
parsed.push(network.to_string());
}
Ok(parsed.join(", "))
}
fn validate_secret_key(value: &str) -> Result<Zeroizing<String>, DeliveryError> {
let key = WireGuardPrivateKey::parse_base64(value)
.map_err(|_| DeliveryError::InvalidConnectionField)?;
// Parsing confirms canonical 32-byte standard Base64; retain only a zeroizing copy.
Ok(Zeroizing::new(key.to_base64()))
}
fn append(output: &mut Zeroizing<Vec<u8>>, value: &str) -> Result<(), DeliveryError> {
if output.len().saturating_add(value.len()) > MAX_CONFIG_BYTES {
return Err(DeliveryError::TooLarge);
}
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn finish(output: Zeroizing<Vec<u8>>) -> Result<ClientConfigPayload, DeliveryError> {
if output.is_empty() || output.len() > MAX_CONFIG_BYTES {
return Err(DeliveryError::TooLarge);
}
Ok(ClientConfigPayload(output))
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::config::{EndpointHost, ServerEndpoint};
use crate::vault::{
VaultBundle, WireGuardPrivateKey,
import::{parse_client_config, reconcile},
};
use crate::wireguard::ConfigDocument;
use super::{DeliveryError, DeliverySettings, render_imported_client, render_new_client};
const PASSWORD: &str = "synthetic delivery test password";
fn key(byte: u8) -> WireGuardPrivateKey {
WireGuardPrivateKey::parse_base64(
&base64::engine::general_purpose::STANDARD.encode([byte; 32]),
)
.expect("synthetic key parses")
}
fn document(client: &WireGuardPrivateKey, addresses: &str) -> ConfigDocument {
document_with_server(client, addresses, &key(9))
}
fn document_with_server(
client: &WireGuardPrivateKey,
addresses: &str,
server: &WireGuardPrivateKey,
) -> ConfigDocument {
let source = format!(
"[Interface]\nPrivateKey = {}\n\n[Peer]\nPublicKey = {}\nAllowedIPs = {addresses}\n",
server.to_base64(),
client.public_key()
);
ConfigDocument::parse(source).expect("synthetic server document parses")
}
fn imported_record(
client: &WireGuardPrivateKey,
addresses: &str,
server: &WireGuardPrivateKey,
) -> crate::vault::RetrievedClientRecord {
let saved = document_with_server(client, addresses, server);
let source = format!(
"[Interface]\nPrivateKey = {}\nAddress = {addresses}\nDNS = 10.80.0.1, fd80::1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51821\nAllowedIPs = 10.0.0.0/8, ::/0\nPersistentKeepalive = 25\n",
client.to_base64(),
server.public_key()
);
let report = reconcile(&saved, [("fixture.conf".to_owned(), source.into_bytes())]);
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
report
.encrypt_candidate(&mut bundle, 0)
.expect("matching imported file encrypts");
bundle
.retrieve_client_record(client.public_key(), PASSWORD)
.expect("imported record retrieves")
}
fn settings() -> (ServerEndpoint, Vec<IpAddr>) {
(
ServerEndpoint {
host: EndpointHost::Hostname("vpn.example.test".to_owned()),
port: 51820,
},
vec![
"10.80.0.1".parse().expect("valid DNS"),
"fd80::1".parse().expect("valid DNS"),
],
)
}
#[test]
fn new_client_uses_saved_dual_stack_addresses_and_full_tunnel_template() {
let client = key(1);
let server = key(9).public_key();
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
let (endpoint, dns) = settings();
let payload = render_new_client(
client.public_key(),
&client,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("valid synthetic values render");
let parsed = parse_client_config(payload.as_bytes()).expect("rendered file parses");
assert_eq!(parsed.identity, client.public_key());
assert_eq!(parsed.server_public_key, server);
assert_eq!(parsed.addresses.len(), 2);
assert_eq!(parsed.allowed_ips.len(), 2);
assert_eq!(parsed.allowed_ips[0].to_string(), "0.0.0.0/0");
assert_eq!(parsed.allowed_ips[1].to_string(), "::/0");
}
#[test]
fn new_client_rejects_wrong_key_and_missing_or_ambiguous_saved_peer() {
let client = key(1);
let other = key(2);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
assert_eq!(
render_new_client(other.public_key(), &client, &saved, &opts).err(),
Some(DeliveryError::ClientIdentityMismatch)
);
let empty = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\n",
key(9).to_base64()
))
.expect("valid document");
assert_eq!(
render_new_client(client.public_key(), &client, &empty, &opts).err(),
Some(DeliveryError::MissingSavedPeer)
);
let duplicate = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
key(9).to_base64(),
client.public_key(), client.public_key()
)).expect("duplicate peer document parses");
assert_eq!(
render_new_client(client.public_key(), &client, &duplicate, &opts).err(),
Some(DeliveryError::AmbiguousSavedPeer)
);
}
#[test]
fn new_client_derives_server_identity_and_rejects_invalid_saved_interface_keys() {
let client = key(1);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let valid_peer = format!(
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
client.public_key()
);
let invalid_documents = [
valid_peer.clone(),
format!(
"[Interface]\nPrivateKey = {}\n[Interface]\nPrivateKey = {}{}",
key(9).to_base64(),
key(9).to_base64(),
valid_peer
),
format!("[Interface]\nPrivateKey = invalid{valid_peer}"),
format!(
"[Interface]\nPrivateKey = {}\nPrivateKey = {}{}",
key(9).to_base64(),
key(8).to_base64(),
valid_peer
),
format!("[Interface]\nPrivateKey\n{valid_peer}"),
];
for source in invalid_documents {
let saved = ConfigDocument::parse(source).expect("synthetic document parses");
assert_eq!(
render_new_client(client.public_key(), &client, &saved, &opts).err(),
Some(DeliveryError::InvalidSavedServerIdentity)
);
}
let valid = document(&client, "10.80.0.7/32, fd80::7/128");
let rendered = render_new_client(client.public_key(), &client, &valid, &opts)
.expect("server identity derives from valid saved private key");
let parsed = parse_client_config(rendered.as_bytes()).expect("rendered file parses");
assert_eq!(parsed.server_public_key, key(9).public_key());
}
#[test]
fn imported_ipv4_only_record_preserves_supported_fields() {
let client = key(4);
let server_private = key(9);
let record = imported_record(&client, "10.80.0.9/32", &server_private);
let saved = document(&client, "10.80.0.9/32");
let (endpoint, dns) = settings();
let payload = render_imported_client(
client.public_key(),
&record,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("supported imported values render");
let parsed =
parse_client_config(payload.as_bytes()).expect("rendered imported file parses");
assert_eq!(
parsed.addresses,
vec!["10.80.0.9/32".parse().expect("valid address")]
);
assert_eq!(parsed.allowed_ips.len(), 2);
assert_eq!(parsed.mtu.as_deref(), Some("1380"));
assert_eq!(parsed.endpoint.as_deref(), Some("vpn.example.test:51821"));
assert_eq!(parsed.persistent_keepalive.as_deref(), Some("25"));
}
#[test]
fn imported_rendering_rejects_stale_and_mismatched_bindings() {
let client = key(6);
let server_private = key(9);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let record = imported_record(&client, "10.80.0.9/32", &server_private);
let stale_saved = document(&client, "10.80.0.8/32");
assert_eq!(
render_imported_client(client.public_key(), &record, &stale_saved, &opts).err(),
Some(DeliveryError::AddressMismatch)
);
let changed_server = document_with_server(&client, "10.80.0.9/32", &key(8));
assert_eq!(
render_imported_client(client.public_key(), &record, &changed_server, &opts).err(),
Some(DeliveryError::ServerIdentityMismatch)
);
assert!(record.server_public_key().is_some());
}
#[test]
fn imported_rendering_requires_saved_server_identity() {
let client = key(12);
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
bundle
.encrypt_record(client.public_key(), &client)
.expect("client key encrypts");
let record = bundle
.retrieve_client_record(client.public_key(), PASSWORD)
.expect("record retrieves");
let saved = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = malformed\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.9/32\n",
client.public_key()
))
.expect("synthetic malformed-key document parses structurally");
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let valid_saved = document(&client, "10.80.0.9/32");
assert_eq!(
render_imported_client(client.public_key(), &record, &valid_saved, &opts).err(),
Some(DeliveryError::ServerIdentityMismatch)
);
assert_eq!(
render_imported_client(client.public_key(), &record, &saved, &opts).err(),
Some(DeliveryError::InvalidSavedServerIdentity)
);
}
#[test]
fn payload_debug_and_render_errors_do_not_contain_secret_text() {
let client = key(10);
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
let (endpoint, dns) = settings();
let payload = render_new_client(
client.public_key(),
&client,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("valid synthetic values render");
let debug = format!("{payload:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains(&client.to_base64()));
assert!(Ipv4Addr::LOCALHOST.is_loopback());
assert_eq!(
"::1".parse::<Ipv6Addr>().expect("valid IPv6"),
Ipv6Addr::LOCALHOST
);
}
}
+880
View File
@@ -0,0 +1,880 @@
//! Secure publication of explicitly selected plaintext client configurations.
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Write};
use std::os::fd::AsFd;
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use rustix::fs::{self as rfs, Mode, OFlags, RenameFlags, ResolveFlags};
use thiserror::Error;
use zeroize::{Zeroize, Zeroizing};
use super::{ClientConfigPayload, MAX_CONFIG_BYTES};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const MAX_OUTPUT_BYTES: usize = MAX_CONFIG_BYTES;
/// Resolved installation files and protected directories supplied by SavedStore.
pub(crate) struct OutputProtection {
protected_files: Vec<PathBuf>,
protected_directories: Vec<PathBuf>,
}
impl OutputProtection {
pub(crate) fn new(files: Vec<PathBuf>, directories: Vec<PathBuf>) -> Self {
Self {
protected_files: files,
protected_directories: directories,
}
}
}
/// A destination prepared without creating files or directories.
///
/// The optional existing-file identity binds overwrite confirmation to the
/// precise regular file observed by [`prepare_destination`].
pub struct PreparedDestination {
path: PathBuf,
parent: PathBuf,
filename: std::ffi::OsString,
existing: Option<FileIdentity>,
overwrite_confirmed: bool,
}
impl PreparedDestination {
pub fn path(&self) -> &Path {
&self.path
}
pub const fn overwrite_confirmation_required(&self) -> bool {
self.existing.is_some() && !self.overwrite_confirmed
}
/// Confirm replacement of the exact regular file observed during preparation.
pub fn confirm_overwrite(mut self) -> Result<Self, FileOutputError> {
if self.existing.is_none() {
return Err(FileOutputError::OverwriteNotRequired);
}
self.overwrite_confirmed = true;
Ok(self)
}
}
impl fmt::Debug for PreparedDestination {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreparedDestination")
.field("path", &self.path)
.field("existing_file", &self.existing.is_some())
.field("overwrite_confirmed", &self.overwrite_confirmed)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FileIdentity {
device: u64,
inode: u64,
length: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
}
impl From<&fs::Metadata> for FileIdentity {
fn from(metadata: &fs::Metadata) -> Self {
Self {
device: metadata.dev(),
inode: metadata.ino(),
length: metadata.len(),
modified_seconds: metadata.mtime(),
modified_nanoseconds: metadata.mtime_nsec(),
}
}
}
/// Failure categories let the application preserve retry eligibility while
/// distinguishing a failed preparation from a file that may already be visible.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum FileOutputError {
#[error("selected output path is unsafe or protected")]
UnsafePath,
#[error("selected destination already exists; confirm replacement for this file")]
OverwriteDeclined,
#[error("the selected destination changed after it was prepared")]
DestinationChanged,
#[error("client configuration exceeds the output size limit")]
TooLarge,
#[error("client configuration could not be written before publication")]
BeforePublication,
#[error("a private temporary client configuration may remain and requires manual cleanup")]
ManualCleanupRequired,
#[error("client configuration may have been published, but directory sync failed")]
PublicationUncertain,
#[error("file output failed")]
Io,
#[error("overwrite confirmation is not applicable to a new destination")]
OverwriteNotRequired,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Checkpoint {
Write,
Flush,
FileSync,
Cleanup,
Publish,
Rollback,
ParentSync,
}
/// Validate a caller-selected absolute destination without performing writes.
/// Existing targets are accepted only when they are regular files; they require
/// an explicit later call to [`PreparedDestination::confirm_overwrite`].
pub(crate) fn prepare_destination(
path: impl AsRef<Path>,
protection: &OutputProtection,
) -> Result<PreparedDestination, FileOutputError> {
let path = path.as_ref();
validate_absolute_path(path)?;
reject_protected(path, protection)?;
let parent = path.parent().ok_or(FileOutputError::UnsafePath)?;
validate_existing_ancestors(parent)?;
let filename = path
.file_name()
.ok_or(FileOutputError::UnsafePath)?
.to_os_string();
let existing = match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {
reject_protected_hardlink(&metadata, protection)?;
Some(FileIdentity::from(&metadata))
}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(_) => return Err(FileOutputError::Io),
};
Ok(PreparedDestination {
path: path.to_path_buf(),
parent: parent.to_path_buf(),
filename,
existing,
overwrite_confirmed: false,
})
}
/// Publish the bounded payload using a private same-directory temporary file.
/// New targets use a no-replace rename. Confirmed overwrite uses an atomic
/// exchange and verifies the displaced inode before removing it.
pub fn publish(
destination: PreparedDestination,
payload: &ClientConfigPayload,
) -> Result<(), FileOutputError> {
publish_with_hook(destination, payload, |_| Ok(()))
}
fn publish_with_hook(
destination: PreparedDestination,
payload: &ClientConfigPayload,
mut hook: impl FnMut(Checkpoint) -> io::Result<()>,
) -> Result<(), FileOutputError> {
if payload.len() > MAX_OUTPUT_BYTES {
return Err(FileOutputError::TooLarge);
}
if destination.overwrite_confirmation_required() {
return Err(FileOutputError::OverwriteDeclined);
}
validate_existing_ancestors(&destination.parent)?;
match (
&destination.existing,
fs::symlink_metadata(&destination.path),
) {
(Some(expected), Ok(actual))
if actual.file_type().is_file() && FileIdentity::from(&actual) == *expected => {}
(None, Err(error)) if error.kind() == io::ErrorKind::NotFound => {}
(None, Ok(_)) => return Err(FileOutputError::DestinationChanged),
_ => return Err(FileOutputError::DestinationChanged),
}
create_missing_private_directories(&destination.parent)?;
validate_existing_ancestors(&destination.parent)?;
let parent = open_parent_directory(&destination.parent)?;
let mut temp_name = None;
let mut temp_file = None;
for _ in 0..32 {
let mut random = [0_u8; 16];
getrandom::fill(&mut random).map_err(|_| FileOutputError::Io)?;
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let name = format!(".wg-admin-client-{sequence}-{}.tmp", hex(&random));
match rfs::openat(
&parent,
name.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
) {
Ok(fd) => {
temp_name = Some(name);
temp_file = Some(File::from(fd));
break;
}
Err(rustix::io::Errno::EXIST) => continue,
Err(_) => return Err(FileOutputError::Io),
}
}
let temp_name = temp_name.ok_or(FileOutputError::Io)?;
let mut file = temp_file.ok_or(FileOutputError::Io)?;
let mut bytes = Zeroizing::new(payload.as_bytes().to_vec());
let write_result = (|| -> io::Result<()> {
hook(Checkpoint::Write)?;
file.write_all(bytes.as_slice())?;
hook(Checkpoint::Flush)?;
file.flush()?;
hook(Checkpoint::FileSync)?;
file.sync_all()?;
Ok(())
})();
bytes.zeroize();
if write_result.is_err() {
drop(file);
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::BeforePublication
} else {
FileOutputError::ManualCleanupRequired
});
}
drop(file);
let staged_identity = file_identity_at(&parent, &temp_name);
let Some(staged_identity) = staged_identity else {
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::Io
} else {
FileOutputError::ManualCleanupRequired
});
};
if hook(Checkpoint::Publish).is_err() {
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::BeforePublication
} else {
FileOutputError::ManualCleanupRequired
});
}
let publication = if destination.existing.is_some() {
rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::EXCHANGE,
)
} else {
rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::NOREPLACE,
)
};
if publication.is_err() {
let clean = cleanup_temporary(&parent, &temp_name, &mut hook);
return Err(if !clean {
FileOutputError::ManualCleanupRequired
} else if destination.existing.is_some() {
FileOutputError::DestinationChanged
} else {
FileOutputError::BeforePublication
});
}
if let Some(expected) = destination.existing {
let displaced = file_identity_at(&parent, &temp_name);
if displaced != Some(expected) {
// EXCHANGE never discards either entry. Rollback is another
// unconditional exchange, so a concurrent substitution can change
// either name before or during it. Never unlink by name here.
if hook(Checkpoint::Rollback).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
if rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::EXCHANGE,
)
.is_err()
{
return Err(FileOutputError::PublicationUncertain);
}
if file_identity_at(&parent, &destination.filename) != displaced
|| file_identity_at(&parent, &temp_name) != Some(staged_identity)
{
return Err(FileOutputError::PublicationUncertain);
}
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
// Keep the staged entry under its private temporary name. Removing
// it by name would race with another substitution and could destroy
// an unconfirmed entry.
return Err(FileOutputError::DestinationChanged);
}
// The exchanged entry is the exact confirmed file. The atomic exchange
// closes the check/rename race; remove only after that identity check.
if rfs::unlinkat(&parent, temp_name.as_str(), rfs::AtFlags::empty()).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
}
if file_identity_at(&parent, &destination.filename) != Some(staged_identity) {
return Err(FileOutputError::PublicationUncertain);
}
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
Ok(())
}
fn cleanup_temporary(
parent: &impl AsFd,
name: &str,
hook: &mut impl FnMut(Checkpoint) -> io::Result<()>,
) -> bool {
if hook(Checkpoint::Cleanup).is_err() {
return false;
}
match rfs::unlinkat(parent, name, rfs::AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => true,
Err(_) => false,
}
}
fn file_identity_at(parent: &impl AsFd, name: impl AsRef<std::ffi::OsStr>) -> Option<FileIdentity> {
let fd = rfs::openat(
parent,
name.as_ref(),
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.ok()?;
let metadata = File::from(fd).metadata().ok()?;
metadata.is_file().then(|| FileIdentity::from(&metadata))
}
fn validate_absolute_path(path: &Path) -> Result<(), FileOutputError> {
if !path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
{
return Err(FileOutputError::UnsafePath);
}
Ok(())
}
fn validate_existing_ancestors(path: &Path) -> Result<(), FileOutputError> {
let mut current = PathBuf::from("/");
let components: Vec<_> = path.components().skip(1).collect();
for (index, component) in components.iter().enumerate() {
let Component::Normal(name) = component else {
return Err(FileOutputError::UnsafePath);
};
current.push(name);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_dir() => {
validate_directory_metadata(&metadata, index + 1 == components.len())?;
}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(_) => return Err(FileOutputError::Io),
}
}
Ok(())
}
fn create_missing_private_directories(path: &Path) -> Result<(), FileOutputError> {
let mut current = PathBuf::from("/");
for component in path.components().skip(1) {
let Component::Normal(name) = component else {
return Err(FileOutputError::UnsafePath);
};
current.push(name);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&current).map_err(|_| FileOutputError::Io)?;
}
Err(_) => return Err(FileOutputError::Io),
}
}
Ok(())
}
fn open_parent_directory(path: &Path) -> Result<File, FileOutputError> {
let fd = rfs::openat2(
rfs::ABS,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS,
)
.map_err(|_| FileOutputError::UnsafePath)?;
let directory = File::from(fd);
let metadata = directory.metadata().map_err(|_| FileOutputError::Io)?;
validate_directory_metadata(&metadata, true)?;
Ok(directory)
}
fn validate_directory_metadata(
metadata: &fs::Metadata,
is_output_parent: bool,
) -> Result<(), FileOutputError> {
let uid = rustix::process::geteuid().as_raw();
if is_output_parent && metadata.uid() != 0 && metadata.uid() != uid {
return Err(FileOutputError::UnsafePath);
}
let mode = metadata.mode();
let group_or_other_writable = mode & 0o022 != 0;
let sticky = mode & 0o1000 != 0;
if group_or_other_writable && (is_output_parent || !sticky) {
return Err(FileOutputError::UnsafePath);
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
output
}
fn reject_protected(path: &Path, protection: &OutputProtection) -> Result<(), FileOutputError> {
for protected in &protection.protected_files {
if lexical_normalize(path)? == lexical_normalize(protected)? {
return Err(FileOutputError::UnsafePath);
}
let target_meta = fs::symlink_metadata(path);
let protected_meta = fs::symlink_metadata(protected);
if let (Ok(target_meta), Ok(protected_meta)) = (target_meta, protected_meta)
&& target_meta.dev() == protected_meta.dev()
&& target_meta.ino() == protected_meta.ino()
{
return Err(FileOutputError::UnsafePath);
}
}
for directory in &protection.protected_directories {
if lexical_normalize(path)?.starts_with(lexical_normalize(directory)?) {
return Err(FileOutputError::UnsafePath);
}
}
Ok(())
}
fn reject_protected_hardlink(
metadata: &fs::Metadata,
protection: &OutputProtection,
) -> Result<(), FileOutputError> {
for protected in &protection.protected_files {
if let Ok(protected_metadata) = fs::symlink_metadata(protected)
&& metadata.dev() == protected_metadata.dev()
&& metadata.ino() == protected_metadata.ino()
{
return Err(FileOutputError::UnsafePath);
}
}
Ok(())
}
fn lexical_normalize(path: &Path) -> Result<PathBuf, FileOutputError> {
validate_absolute_path(path)?;
let mut normalized = PathBuf::from("/");
for component in path.components().skip(1) {
if let Component::Normal(name) = component {
normalized.push(name);
} else {
return Err(FileOutputError::UnsafePath);
}
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::{
Checkpoint, FileOutputError, OutputProtection, PreparedDestination, prepare_destination,
publish, publish_with_hook,
};
use crate::delivery::ClientConfigPayload;
use rustix::fs as rfs;
use std::fs;
use std::io;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::Path;
use zeroize::Zeroizing;
fn payload() -> ClientConfigPayload {
ClientConfigPayload(Zeroizing::new(b"synthetic client configuration\n".to_vec()))
}
fn protection(root: &Path) -> OutputProtection {
OutputProtection::new(
vec![root.join("settings.toml"), root.join("server.conf")],
vec![root.join("Backup/data"), root.join("Backup/archives")],
)
}
#[test]
fn writes_new_file_with_private_mode_and_private_new_directories() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("chosen/nested/client.conf");
let prepared = prepare_destination(&target, &protection(root.path()))
.expect("safe explicit destination");
assert!(!target.exists());
publish(prepared, &payload()).expect("publication succeeds");
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::metadata(root.path().join("chosen"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(root.path().join("chosen/nested"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert!(
!fs::read_dir(root.path().join("chosen/nested"))
.unwrap()
.any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".tmp"))
);
}
#[test]
fn existing_file_requires_bound_confirmation_and_decline_preserves_bytes() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("existing.conf");
fs::write(&target, b"old synthetic value").unwrap();
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
assert!(prepared.overwrite_confirmation_required());
assert_eq!(
publish(prepared, &payload()),
Err(FileOutputError::OverwriteDeclined)
);
assert!(fs::read(&target).unwrap() == b"old synthetic value");
let confirmed = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
publish(confirmed, &payload()).expect("confirmed bounded replacement");
assert!(fs::read(&target).unwrap() == b"synthetic client configuration\n");
assert_eq!(
fs::metadata(target).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn refuses_symlink_special_traversal_and_protected_targets() {
let root = tempfile::tempdir().expect("temp root");
let outside = root.path().join("outside");
fs::write(&outside, b"untouched").unwrap();
let link = root.path().join("link");
symlink(&outside, &link).unwrap();
assert_eq!(
prepare_destination(&link, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(root.path().join("missing/../bad"), &protection(root.path()))
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(root.path().join("settings.toml"), &protection(root.path()))
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(
root.path().join("Backup/data/vault.toml"),
&protection(root.path())
)
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(fs::read(outside).unwrap(), b"untouched");
let fifo = root.path().join("fifo");
rfs::mkfifoat(rfs::ABS, &fifo, rustix::fs::Mode::from_raw_mode(0o600))
.expect("synthetic special file");
assert_eq!(
prepare_destination(&fifo, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
}
#[test]
fn rejects_symlink_ancestors_and_protects_original_source_entries() {
let root = tempfile::tempdir().expect("temp root");
let other = tempfile::tempdir().expect("other temp root");
let link = root.path().join("linked-dir");
symlink(other.path(), &link).unwrap();
assert_eq!(
prepare_destination(link.join("client.conf"), &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
let source = root.path().join("clients");
fs::create_dir(&source).unwrap();
let original = source.join("original.conf");
fs::write(&original, b"source fixture").unwrap();
let policy = OutputProtection::new(vec![original.clone()], vec![]);
assert_eq!(
prepare_destination(&original, &policy).unwrap_err(),
FileOutputError::UnsafePath
);
}
#[test]
fn rejects_group_or_world_writable_output_parent_before_staging_plaintext() {
let root = tempfile::tempdir().expect("temp root");
let shared = root.path().join("shared");
fs::create_dir(&shared).unwrap();
fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).unwrap();
let target = shared.join("client.conf");
assert_eq!(
prepare_destination(&target, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(fs::read_dir(&shared).unwrap().count(), 0);
assert!(!target.exists());
}
#[test]
fn injected_write_and_sync_failures_report_publication_state() {
let root = tempfile::tempdir().expect("temp root");
for (index, failed_stage) in [Checkpoint::Write, Checkpoint::Flush, Checkpoint::FileSync]
.into_iter()
.enumerate()
{
let before_target = root.path().join(format!("pre-publish-{index}.conf"));
let before = prepare_destination(&before_target, &protection(root.path())).unwrap();
let failure = publish_with_hook(before, &payload(), |stage| {
if stage == failed_stage {
Err(io::Error::other("synthetic pre-publication fault"))
} else {
Ok(())
}
});
assert_eq!(failure, Err(FileOutputError::BeforePublication));
assert!(!before_target.exists());
}
let uncertain_target = root.path().join("sync-fail.conf");
let after = prepare_destination(&uncertain_target, &protection(root.path())).unwrap();
let failure = publish_with_hook(after, &payload(), |stage| {
if stage == Checkpoint::ParentSync {
Err(io::Error::other("synthetic flush fault"))
} else {
Ok(())
}
});
assert_eq!(failure, Err(FileOutputError::PublicationUncertain));
assert!(uncertain_target.exists());
assert!(fs::read(uncertain_target).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn cleanup_failure_reports_manual_recovery_and_retains_private_temporary_file() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("cleanup-fail.conf");
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
let failure = publish_with_hook(prepared, &payload(), |stage| match stage {
Checkpoint::Flush | Checkpoint::Cleanup => Err(io::Error::other("synthetic fault")),
_ => Ok(()),
});
assert_eq!(failure, Err(FileOutputError::ManualCleanupRequired));
assert!(!target.exists());
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".wg-admin-client-")
})
.expect("temporary plaintext is retained for manual cleanup");
assert_eq!(
fs::metadata(&temporary).unwrap().permissions().mode() & 0o777,
0o600
);
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn no_replace_publication_keeps_a_racing_new_file_untouched() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("racing.conf");
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
let failure = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::write(&target, b"racing synthetic content")?;
}
Ok(())
});
assert_eq!(failure, Err(FileOutputError::BeforePublication));
assert!(fs::read(target).unwrap() == b"racing synthetic content");
}
#[test]
fn changed_existing_destination_cannot_reuse_old_confirmation() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("replace.conf");
fs::write(&target, b"original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
fs::write(&target, b"changed after selection").unwrap();
assert_eq!(
publish(prepared, &payload()),
Err(FileOutputError::DestinationChanged)
);
assert!(fs::read(target).unwrap() == b"changed after selection");
}
#[test]
fn substitution_at_publish_checkpoint_is_exchanged_back_without_data_loss() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("substituted.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"unconfirmed substitute")?;
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::DestinationChanged));
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("staged output is retained under its private temporary name");
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn substitution_during_rollback_retains_every_entry_and_reports_uncertain() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("substituted-during-rollback.conf");
let parked = root.path().join("parked-published.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"first unconfirmed substitute")?;
} else if stage == Checkpoint::Rollback {
fs::rename(&target, &parked)?;
fs::write(&target, b"second unconfirmed substitute")?;
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
assert!(fs::read(&target).unwrap() == b"first unconfirmed substitute");
assert!(fs::read(parked).unwrap() == b"synthetic client configuration\n");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("second unconfirmed substitute is retained");
assert!(fs::read(temporary).unwrap() == b"second unconfirmed substitute");
}
#[test]
fn rollback_parent_sync_failure_is_uncertain_and_keeps_both_entries() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("rollback-sync-fail.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"unconfirmed substitute")?;
} else if stage == Checkpoint::ParentSync {
return Err(io::Error::other("synthetic rollback directory sync fault"));
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("published staged output remains retained");
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn confirmation_is_not_available_for_a_new_file() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("new.conf");
let prepared: PreparedDestination =
prepare_destination(&target, &protection(root.path())).unwrap();
assert_eq!(
prepared.confirm_overwrite().unwrap_err(),
FileOutputError::OverwriteNotRequired
);
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Bounded, operation-local QR representation for an authorized client payload.
//!
//! The encoder necessarily allocates internal copies of the payload and QR
//! codewords that this crate cannot zeroize. This module clears its owned matrix
//! on drop and never formats or serializes it implicitly.
use std::fmt;
use qrcode::{QrCode, types::Color};
use thiserror::Error;
use zeroize::Zeroizing;
use super::ClientConfigPayload;
const QUIET_ZONE: usize = 4;
const MAX_QR_SIDE: usize = 185; // Version 40 (177 modules) plus quiet zone.
/// Secret-bearing QR modules including the four-module quiet zone.
///
/// The bytes are row-major: `0` is light and `1` is dark. Access is explicit
/// through [`Self::module`]; there is no `Display` or serialization API.
pub struct QrCodeOutput {
side: usize,
modules: Zeroizing<Vec<u8>>,
}
impl QrCodeOutput {
pub fn side(&self) -> usize {
self.side
}
pub fn module(&self, x: usize, y: usize) -> Option<bool> {
if x >= self.side || y >= self.side {
return None;
}
self.modules.get(y * self.side + x).map(|value| *value != 0)
}
}
impl fmt::Debug for QrCodeOutput {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("QrCodeOutput")
.field("side", &self.side)
.field("modules", &"[REDACTED]")
.finish()
}
}
/// Secret-free QR encoding failures.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum QrError {
#[error("client configuration exceeds QR capacity")]
CapacityExceeded,
#[error("QR encoding failed")]
EncodingFailed,
#[error("encoded QR dimensions exceed the supported bound")]
DimensionsExceeded,
}
/// Encode exactly the supplied delivery payload with a four-module quiet zone.
pub(crate) fn encode_payload(payload: &ClientConfigPayload) -> Result<QrCodeOutput, QrError> {
encode_bytes(payload.as_bytes())
}
fn encode_bytes(bytes: &[u8]) -> Result<QrCodeOutput, QrError> {
let code = QrCode::new(bytes).map_err(|error| match error {
qrcode::types::QrError::DataTooLong => QrError::CapacityExceeded,
_ => QrError::EncodingFailed,
})?;
let inner_side = code.width();
let side = inner_side
.checked_add(QUIET_ZONE * 2)
.filter(|side| *side <= MAX_QR_SIDE)
.ok_or(QrError::DimensionsExceeded)?;
let mut modules = Zeroizing::new(vec![0; side * side]);
for y in 0..inner_side {
for x in 0..inner_side {
if code[(x, y)] == Color::Dark {
modules[(y + QUIET_ZONE) * side + x + QUIET_ZONE] = 1;
}
}
}
Ok(QrCodeOutput { side, modules })
}
/// Small, non-secret QR used only to exercise the terminal disclosure renderer.
#[cfg(test)]
pub(crate) fn synthetic_test_output() -> QrCodeOutput {
encode_bytes(b"synthetic QR disclosure fixture").expect("synthetic QR payload encodes")
}
/// Deterministic, non-secret matrix for terminal layout and glyph tests.
#[cfg(test)]
pub(crate) fn synthetic_test_matrix(side: usize) -> QrCodeOutput {
let mut modules = Zeroizing::new(vec![0; side.saturating_mul(side)]);
for y in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) {
for x in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) {
if (x * 3 + y * 5) % 7 < 3 {
modules[y * side + x] = 1;
}
}
}
QrCodeOutput { side, modules }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_bounded_matrix_with_quiet_zone_and_redacted_debug() {
let payload = ClientConfigPayload(Zeroizing::new(
b"[Interface]\nPrivateKey = synthetic-secret\n".to_vec(),
));
let matrix = encode_payload(&payload).expect("small payload encodes");
assert!((21 + 8..=MAX_QR_SIDE).contains(&matrix.side()));
assert!((0..QUIET_ZONE).all(|offset| {
(0..matrix.side()).all(|position| {
matrix.module(position, offset) == Some(false)
&& matrix.module(offset, position) == Some(false)
})
}));
let debug = format!("{matrix:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("synthetic-secret"));
assert_eq!(matrix.module(matrix.side(), 0), None);
}
#[test]
fn reports_payload_capacity_without_including_payload_in_error() {
let payload = ClientConfigPayload(Zeroizing::new(vec![b'x'; 65_536]));
let error = encode_payload(&payload).expect_err("payload exceeds QR capacity");
assert_eq!(error, QrError::CapacityExceeded);
assert_eq!(
error.to_string(),
"client configuration exceeds QR capacity"
);
}
}
+271 -67
View File
@@ -8,6 +8,70 @@ use std::str::FromStr;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use ipnet::{Ipv4Net, Ipv6Net};
/// Committed addresses retained until verified application and exact host
/// routes staged by a draft deletion.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AddressReservations {
committed: BTreeSet<IpAddr>,
pending_host_routes: BTreeSet<IpAddr>,
}
impl AddressReservations {
pub fn committed(&self) -> &BTreeSet<IpAddr> {
&self.committed
}
pub fn pending_host_routes(&self) -> impl Iterator<Item = IpAddr> + '_ {
self.pending_host_routes.iter().copied()
}
pub fn all(&self) -> impl Iterator<Item = IpAddr> + '_ {
self.committed
.iter()
.chain(&self.pending_host_routes)
.copied()
}
/// Validate the complete route list before staging any pending address.
pub(crate) fn stage_exact_host_routes(
&mut self,
routes: impl IntoIterator<Item = ipnet::IpNet>,
) -> Result<(), DomainError> {
let mut addresses = BTreeSet::new();
let mut route_count = 0;
for route in routes {
route_count += 1;
match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
addresses.insert(IpAddr::V4(network.addr()));
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
addresses.insert(IpAddr::V6(network.addr()));
}
_ => return Err(DomainError::UnsafeReservationRoute),
}
}
if route_count == 0 || addresses.is_empty() {
return Err(DomainError::UnsafeReservationRoute);
}
self.pending_host_routes.extend(addresses);
Ok(())
}
fn restore_committed(&mut self, reservations: impl IntoIterator<Item = IpAddr>) {
self.committed = reservations.into_iter().collect();
self.pending_host_routes.clear();
}
fn promote_pending(&mut self) {
self.committed.append(&mut self.pending_host_routes);
}
fn release_committed(&mut self) {
self.committed.clear();
}
}
use thiserror::Error;
/// A WireGuard public key, decoded and validated as exactly 32 bytes.
@@ -146,32 +210,77 @@ impl Snapshot {
}
}
/// Runtime knowledge is not inferred from saved state or missing data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuntimeObservation {
/// Current apply knowledge is not inferred from saved state or missing data.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApplyStatus {
Unknown,
Unavailable,
Verified,
}
/// Current runtime knowledge and the optional complete management snapshot
/// most recently verified. Historical verification time is persisted
/// separately and never initializes this value on reopen.
#[derive(Clone, Debug, Eq, PartialEq)]
struct ApplyKnowledge {
current: ApplyStatus,
last_verified_snapshot: Option<Snapshot>,
}
impl ApplyKnowledge {
fn new(current: ApplyStatus, last_verified_snapshot: Option<Snapshot>) -> Self {
Self {
current,
last_verified_snapshot,
}
}
fn runtime_unknown(&mut self) {
self.current = ApplyStatus::Unknown;
}
fn runtime_unavailable(&mut self) {
self.current = ApplyStatus::Unavailable;
}
fn saved_state_changed(&mut self) {
self.current = ApplyStatus::Unknown;
}
fn new_session(&mut self) {
self.current = ApplyStatus::Unknown;
}
fn persisted_state_restored(&mut self) {
self.current = ApplyStatus::Unknown;
self.last_verified_snapshot = None;
}
fn prepare_verified_result(&mut self, complete_snapshot: Option<Snapshot>) {
self.last_verified_snapshot = complete_snapshot;
self.current = ApplyStatus::Verified;
}
}
/// Draft, saved, and last verified applied data remain independent snapshots.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStates {
draft: Snapshot,
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation,
reserved_addresses: BTreeSet<IpAddr>,
apply_knowledge: ApplyKnowledge,
reservations: AddressReservations,
}
impl ClientStates {
/// Start with a saved snapshot and an explicit initial runtime observation.
/// `Application::open` treats caller-provided state as untrusted runtime
/// evidence and resets current knowledge before exposing a session.
pub fn new(
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation,
applied: ApplyStatus,
) -> Result<Self, DomainError> {
if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) {
if applied == ApplyStatus::Verified && applied_snapshot.as_ref() != Some(&saved) {
return Err(DomainError::AppliedSnapshotMismatch);
}
let saved_addresses = saved.addresses().collect::<BTreeSet<_>>();
@@ -185,9 +294,11 @@ impl ClientStates {
Ok(Self {
draft: saved.clone(),
saved,
applied_snapshot,
applied,
reserved_addresses,
apply_knowledge: ApplyKnowledge::new(applied, applied_snapshot),
reservations: AddressReservations {
committed: reserved_addresses,
pending_host_routes: BTreeSet::new(),
},
})
}
@@ -199,21 +310,32 @@ impl ClientStates {
&self.saved
}
pub fn applied(&self) -> &RuntimeObservation {
&self.applied
pub fn applied(&self) -> &ApplyStatus {
&self.apply_knowledge.current
}
pub fn applied_snapshot(&self) -> Option<&Snapshot> {
self.applied_snapshot.as_ref()
self.apply_knowledge.last_verified_snapshot.as_ref()
}
pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> {
&self.reserved_addresses
self.reservations.committed()
}
pub fn address_reservations(&self) -> &AddressReservations {
&self.reservations
}
pub(crate) fn stage_exact_host_routes(
&mut self,
routes: impl IntoIterator<Item = ipnet::IpNet>,
) -> Result<(), DomainError> {
self.reservations.stage_exact_host_routes(routes)
}
/// Restore conservative reservations from saved metadata without asserting
/// that the running interface matches the saved snapshot.
pub fn restore_persisted_reservations(
pub(crate) fn restore_persisted_reservations(
&mut self,
reservations: impl IntoIterator<Item = IpAddr>,
) -> Result<(), DomainError> {
@@ -224,58 +346,83 @@ impl ClientStates {
{
return Err(DomainError::ReservationConflictsWithSavedClient);
}
self.reserved_addresses = reservations;
self.applied_snapshot = None;
self.applied = RuntimeObservation::Unknown;
self.reservations.restore_committed(reservations);
self.apply_knowledge.persisted_state_restored();
Ok(())
}
pub fn draft_mut(&mut self) -> &mut Snapshot {
&mut self.draft
pub(crate) fn stage_client(&mut self, client: Client) -> Result<(), DomainError> {
self.draft.insert(client).map(|_| ())
}
pub(crate) fn remove_draft_client(&mut self, identity: &PublicKey) -> Option<Client> {
self.draft.remove(identity)
}
/// Save the draft in the model only. This performs no persistence or runtime operation.
pub fn commit_draft(&mut self) {
let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>();
self.reserved_addresses.extend(
self.reservations.committed.extend(
self.saved
.addresses()
.filter(|address| !next_addresses.contains(address)),
);
self.reservations.promote_pending();
self.saved = self.draft.clone();
if self.applied == RuntimeObservation::Verified
&& self.applied_snapshot.as_ref() != Some(&self.saved)
if self.apply_knowledge.current == ApplyStatus::Verified
&& self.apply_knowledge.last_verified_snapshot.as_ref() != Some(&self.saved)
{
self.applied = RuntimeObservation::Unknown;
self.apply_knowledge.saved_state_changed();
}
}
/// Discard draft edits by returning the draft to the saved snapshot.
pub fn discard_draft(&mut self) {
self.draft = self.saved.clone();
self.reservations.pending_host_routes.clear();
}
/// Record an applied state only when the verified observation matches saved clients.
pub fn mark_applied_after_verification(
/// Prepare a verified candidate only when the observation matches saved
/// clients. Callers publish the candidate after durable state installation.
pub(crate) fn prepare_verified_result(
&mut self,
observed: Snapshot,
) -> Result<(), DomainError> {
if observed != self.saved {
self.applied = RuntimeObservation::Unknown;
self.apply_knowledge.runtime_unknown();
return Err(DomainError::AppliedSnapshotMismatch);
}
self.applied_snapshot = Some(observed);
self.applied = RuntimeObservation::Verified;
self.reserved_addresses.clear();
self.apply_knowledge.prepare_verified_result(Some(observed));
self.reservations.release_committed();
Ok(())
}
pub fn mark_runtime_unavailable(&mut self) {
self.applied = RuntimeObservation::Unavailable;
/// Record full technical verification when the complete peer set cannot
/// be represented by the narrower management snapshot. `None` deliberately
/// keeps `applied_snapshot` absent instead of treating a partial snapshot as
/// evidence that every saved peer was applied.
pub(crate) fn prepare_technically_verified_result(
&mut self,
observed_management_snapshot: Option<Snapshot>,
) -> Result<(), DomainError> {
if let Some(observed) = observed_management_snapshot {
return self.prepare_verified_result(observed);
}
self.apply_knowledge.prepare_verified_result(None);
self.reservations.release_committed();
Ok(())
}
pub fn mark_runtime_unknown(&mut self) {
self.applied = RuntimeObservation::Unknown;
pub fn runtime_knowledge_unavailable(&mut self) {
self.apply_knowledge.runtime_unavailable();
}
pub(crate) fn runtime_knowledge_unknown(&mut self) {
self.apply_knowledge.runtime_unknown();
}
pub(crate) fn begin_runtime_session(&mut self) {
self.apply_knowledge.new_session();
}
/// Addresses in draft, saved, or last verified applied data cannot be reused.
@@ -284,8 +431,13 @@ impl ClientStates {
self.draft
.addresses()
.chain(self.saved.addresses())
.chain(self.reserved_addresses.iter().copied())
.chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses))
.chain(self.reservations.all())
.chain(
self.apply_knowledge
.last_verified_snapshot
.iter()
.flat_map(Snapshot::addresses),
)
.collect()
}
}
@@ -316,6 +468,8 @@ pub enum DomainError {
AddressUnavailable,
#[error("verified applied clients do not match the saved snapshot")]
AppliedSnapshotMismatch,
#[error("only exact IPv4/32 or IPv6/128 host routes can be reserved")]
UnsafeReservationRoute,
#[error("persisted reservation conflicts with a saved client address")]
ReservationConflictsWithSavedClient,
}
@@ -541,12 +695,12 @@ mod tests {
}
#[test]
fn unknown_and_unavailable_runtime_observations_are_explicit() {
fn unknown_and_unavailable_apply_statuses_are_explicit() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let unknown = ClientStates::new(saved.clone(), None, RuntimeObservation::Unknown).unwrap();
assert_eq!(unknown.applied(), &RuntimeObservation::Unknown);
let unavailable = ClientStates::new(saved, None, RuntimeObservation::Unavailable).unwrap();
assert_eq!(unavailable.applied(), &RuntimeObservation::Unavailable);
let unknown = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap();
assert_eq!(unknown.applied(), &ApplyStatus::Unknown);
let unavailable = ClientStates::new(saved, None, ApplyStatus::Unavailable).unwrap();
assert_eq!(unavailable.applied(), &ApplyStatus::Unavailable);
}
#[test]
@@ -555,16 +709,16 @@ mod tests {
let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap();
assert_eq!(
ClientStates::new(saved.clone(), None, RuntimeObservation::Verified).err(),
ClientStates::new(saved.clone(), None, ApplyStatus::Verified).err(),
Some(DomainError::AppliedSnapshotMismatch)
);
assert_eq!(
ClientStates::new(saved.clone(), Some(other), RuntimeObservation::Verified).err(),
ClientStates::new(saved.clone(), Some(other), ApplyStatus::Verified).err(),
Some(DomainError::AppliedSnapshotMismatch)
);
let verified =
ClientStates::new(saved.clone(), Some(saved), RuntimeObservation::Verified).unwrap();
assert_eq!(verified.applied(), &RuntimeObservation::Verified);
ClientStates::new(saved.clone(), Some(saved), ApplyStatus::Verified).unwrap();
assert_eq!(verified.applied(), &ApplyStatus::Verified);
}
#[test]
@@ -574,23 +728,19 @@ mod tests {
client(2, "two", "10.0.0.3", None),
])
.unwrap();
let mut states = ClientStates::new(
saved.clone(),
Some(saved.clone()),
RuntimeObservation::Verified,
)
.unwrap();
states.draft_mut().remove(&key(2));
let mut states =
ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap();
states.remove_draft_client(&key(2));
states.discard_draft();
assert_eq!(states.draft(), &saved);
states.draft_mut().remove(&key(2));
states.remove_draft_client(&key(2));
states.commit_draft();
assert_eq!(states.saved().clients().count(), 1);
assert_eq!(states.applied(), &RuntimeObservation::Unknown);
assert_eq!(states.applied(), &ApplyStatus::Unknown);
assert_eq!(states.applied_snapshot(), Some(&saved));
states.mark_runtime_unavailable();
assert_eq!(states.applied(), &RuntimeObservation::Unavailable);
states.runtime_knowledge_unavailable();
assert_eq!(states.applied(), &ApplyStatus::Unavailable);
assert!(states.applied_snapshot().is_some());
assert!(
states
@@ -611,8 +761,8 @@ mod tests {
#[test]
fn a_draft_only_deletion_does_not_create_a_committed_reservation() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let mut states = ClientStates::new(saved, None, RuntimeObservation::Unknown).unwrap();
states.draft_mut().remove(&key(1));
let mut states = ClientStates::new(saved, None, ApplyStatus::Unknown).unwrap();
states.remove_draft_client(&key(1));
assert!(states.reserved_addresses().is_empty());
assert!(
states
@@ -621,31 +771,85 @@ mod tests {
);
}
#[test]
fn exact_host_routes_stay_pending_until_commit_and_discard_drops_them() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let address = IpAddr::V4("10.0.0.9".parse().unwrap());
let route = "10.0.0.9/32".parse::<ipnet::IpNet>().unwrap();
let mut states = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap();
states.stage_exact_host_routes([route]).unwrap();
assert!(states.reserved_addresses().is_empty());
assert_eq!(
states
.address_reservations()
.pending_host_routes()
.collect::<Vec<_>>(),
[address]
);
assert!(states.addresses_in_use().contains(&address));
states.discard_draft();
assert!(
states
.address_reservations()
.pending_host_routes()
.next()
.is_none()
);
assert!(!states.addresses_in_use().contains(&address));
states.stage_exact_host_routes([route]).unwrap();
states.commit_draft();
assert!(
states
.address_reservations()
.pending_host_routes()
.next()
.is_none()
);
assert!(states.reserved_addresses().contains(&address));
}
#[test]
fn unsafe_route_cannot_partially_change_pending_reservations() {
let mut reservations = AddressReservations::default();
let exact = "10.0.0.9/32".parse::<ipnet::IpNet>().unwrap();
let broad = "10.0.0.0/24".parse::<ipnet::IpNet>().unwrap();
reservations.stage_exact_host_routes([exact]).unwrap();
assert_eq!(
reservations.stage_exact_host_routes(["10.0.0.10/32".parse().unwrap(), broad]),
Err(DomainError::UnsafeReservationRoute)
);
assert_eq!(
reservations.pending_host_routes().collect::<Vec<_>>(),
[IpAddr::V4("10.0.0.9".parse().unwrap())]
);
}
#[test]
fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() {
let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let mut states = ClientStates::new(
initial.clone(),
Some(initial.clone()),
RuntimeObservation::Verified,
ApplyStatus::Verified,
)
.unwrap();
let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap();
assert_eq!(
states.mark_applied_after_verification(wrong),
states.prepare_verified_result(wrong),
Err(DomainError::AppliedSnapshotMismatch)
);
assert_eq!(states.applied(), &RuntimeObservation::Unknown);
assert_eq!(states.applied(), &ApplyStatus::Unknown);
assert_eq!(states.applied_snapshot(), Some(&initial));
states.draft_mut().remove(&key(1));
states.remove_draft_client(&key(1));
states.commit_draft();
assert!(!states.reserved_addresses().is_empty());
let saved = states.saved().clone();
states
.mark_applied_after_verification(saved.clone())
.unwrap();
assert_eq!(states.applied(), &RuntimeObservation::Verified);
states.prepare_verified_result(saved.clone()).unwrap();
assert_eq!(states.applied(), &ApplyStatus::Verified);
assert!(states.reserved_addresses().is_empty());
assert!(
!states
+9
View File
@@ -1,8 +1,17 @@
pub mod app;
pub mod backup;
pub mod config;
pub mod delivery;
pub mod domain;
pub mod persistence;
pub mod runtime;
pub mod service;
pub mod startup;
pub mod tui;
pub mod vault;
pub mod wireguard;
// This driver is compiled only into the library test harness. In particular,
// the normal executable has no fixture apply path.
#[cfg(test)]
mod container_driver;
+19 -5
View File
@@ -5,7 +5,7 @@ use wireguard_admin::startup::{self, StartupAction};
const USAGE: &str = "Usage: wireguard-admin [--help | --version]\n\
With no arguments, starts the root-only management interface.\n\
The management interface is not implemented yet.";
";
fn main() -> ExitCode {
let arguments: Vec<OsString> = env::args_os().skip(1).collect();
@@ -22,13 +22,27 @@ fn main() -> ExitCode {
println!("wireguard-admin {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Ok(StartupAction::ManagementUnavailable) => {
eprintln!("The management interface is not implemented yet.");
ExitCode::from(1)
}
Ok(StartupAction::Management) => match run_management() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Error: {error}");
ExitCode::from(1)
}
},
Err(error) => {
eprintln!("Error: {error}.\n{USAGE}");
ExitCode::from(2)
}
}
}
fn run_management() -> Result<(), String> {
let executable = env::current_exe().map_err(|_| "cannot locate executable".to_owned())?;
let directory = executable
.parent()
.ok_or_else(|| "executable has no parent directory".to_owned())?;
wireguard_admin::tui::terminal::check_suitable().map_err(|error| error.to_string())?;
let app =
startup::open_management_session(directory, || true).map_err(|error| error.to_string())?;
wireguard_admin::tui::terminal::run(app).map_err(|error| error.to_string())
}
+52 -10
View File
@@ -515,6 +515,54 @@ impl SavedStore {
self.vault_path(),
]
}
/// Paths which client plaintext delivery must not replace. This is a
/// read-only view of the resolved installation mapping; source candidates
/// are protected individually so a safe, newly selected sibling output
/// remains available.
pub(crate) fn delivery_output_protection(
&self,
) -> Result<crate::delivery::files::OutputProtection, crate::delivery::files::FileOutputError>
{
let mut protected_files = self.targets().into_iter().collect::<Vec<_>>();
let source_directory = &self.settings.client_source_directory;
match fs::symlink_metadata(source_directory) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(crate::delivery::files::OutputProtection::new(
protected_files,
vec![
self.settings.data_directory.clone(),
self.settings.archives_directory.clone(),
],
));
}
Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
}
match fs::read_dir(source_directory) {
Ok(entries) => {
let mut count = 0usize;
for entry in entries {
let entry = entry.map_err(|_| crate::delivery::files::FileOutputError::Io)?;
count += 1;
if count > crate::vault::source::MAX_DIRECTORY_ENTRIES {
return Err(crate::delivery::files::FileOutputError::UnsafePath);
}
protected_files.push(entry.path());
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
}
Ok(crate::delivery::files::OutputProtection::new(
protected_files,
vec![
self.settings.data_directory.clone(),
self.settings.archives_directory.clone(),
],
))
}
}
#[derive(Deserialize, Serialize)]
@@ -905,22 +953,16 @@ restart_timeout_seconds = 30
)
.unwrap();
let saved = crate::domain::Snapshot::new([client]).unwrap();
let mut states = crate::domain::ClientStates::new(
saved,
None,
crate::domain::RuntimeObservation::Unknown,
)
.unwrap();
let mut states =
crate::domain::ClientStates::new(saved, None, crate::domain::ApplyStatus::Unknown)
.unwrap();
let state = PersistedState::new(
["10.0.0.8".to_owned()],
Some("2026-10-19T12:30:00Z".to_owned()),
)
.unwrap();
state.apply_to(&mut states).unwrap();
assert_eq!(
states.applied(),
&crate::domain::RuntimeObservation::Unknown
);
assert_eq!(states.applied(), &crate::domain::ApplyStatus::Unknown);
assert!(
states
.reserved_addresses()
+674
View File
@@ -0,0 +1,674 @@
//! Public, read-only runtime observations and pure saved-peer verification.
//!
//! The transport performs bounded read-only Linux access; comparison remains
//! pure and accepts caller-supplied observations.
use std::collections::{BTreeMap, BTreeSet};
use ipnet::IpNet;
use time::OffsetDateTime;
use crate::domain::PublicKey;
use crate::wireguard::{ConfigDocument, DocumentIssue, PeerIssue};
#[allow(dead_code)] // Integrated by the M5 orchestration step.
pub(crate) mod netlink;
#[allow(dead_code)] // Consumed by the M5 application integration step.
pub(crate) mod transport;
/// A value that could not be observed is distinct from a valid zero or empty value.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Observed<T> {
Available(T),
Unavailable,
}
/// Current statistics for a peer, from the server interface's perspective.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimePeer {
pub allowed_ips: Observed<Vec<IpNet>>,
/// `Available(None)` means WireGuard has never recorded a handshake.
pub last_handshake: Observed<Option<OffsetDateTime>>,
/// These are current counters and may reset when the interface restarts.
pub received_bytes: Observed<u64>,
pub transmitted_bytes: Observed<u64>,
}
/// Whether the peer map is exhaustive enough to establish absence and equality.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerSetCompleteness {
Unknown,
Unavailable,
Partial,
Complete,
}
/// Runtime peers keyed by their public key, with duplicate identities retained
/// as an explicit ambiguity signal rather than silently overwritten.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimePeerSet {
pub peers: BTreeMap<PublicKey, RuntimePeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub completeness: PeerSetCompleteness,
}
impl RuntimePeerSet {
/// Collect records while preserving evidence that the adapter returned a
/// duplicate public key. The first record remains available for diagnostics.
pub fn from_records(
completeness: PeerSetCompleteness,
records: impl IntoIterator<Item = (PublicKey, RuntimePeer)>,
) -> Self {
let mut peers = BTreeMap::new();
let mut duplicate_public_keys = BTreeSet::new();
for (identity, peer) in records {
if let std::collections::btree_map::Entry::Vacant(entry) = peers.entry(identity) {
entry.insert(peer);
} else {
duplicate_public_keys.insert(identity);
}
}
Self {
peers,
duplicate_public_keys,
completeness,
}
}
}
/// Interface presence and peer observation are independent facts.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InterfaceObservation {
Unknown,
Unavailable,
Missing,
Present {
/// Interface returned by the runtime adapter.
name: String,
peers: RuntimePeerSet,
},
}
/// One read-only sample. A missing timestamp means the sample time is unknown.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeObservation {
pub observed_at: Option<OffsetDateTime>,
pub interface: InterfaceObservation,
}
impl RuntimeObservation {
pub const fn unknown() -> Self {
Self {
observed_at: None,
interface: InterfaceObservation::Unknown,
}
}
}
/// One saved peer's complete technical routes, independent of alias metadata
/// and the narrower `domain::Client` address representation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SavedPeer {
pub identity: PublicKey,
pub allowed_ips: BTreeSet<IpNet>,
}
/// Technical peer projection from the authoritative byte-preserving document.
/// Invalid or ambiguous records remain represented as unverifiable entries.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SavedPeerProjection {
pub peers: BTreeMap<PublicKey, SavedPeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub unverifiable_peer_indices: BTreeSet<usize>,
pub document_unverifiable: bool,
}
impl SavedPeerProjection {
/// Project every `[Peer]` section, retaining valid but unsupported route
/// shapes that cannot fit the UI's ordinary client address model.
pub fn from_document(document: &ConfigDocument) -> Self {
let mut peers = BTreeMap::new();
let mut duplicate_public_keys = BTreeSet::new();
let mut unverifiable_peer_indices = BTreeSet::new();
for peer in document.peers() {
let Some(identity) = peer.identity else {
unverifiable_peer_indices.insert(peer.index);
continue;
};
let invalid_route = peer
.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }));
let duplicate_key_field = peer.issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::MalformedPublicKey { .. }
| PeerIssue::DuplicatePublicKeyField { .. }
| PeerIssue::DuplicatePeerIdentity { .. }
)
});
if invalid_route || duplicate_key_field {
unverifiable_peer_indices.insert(peer.index);
}
let routes = peer
.allowed_ips
.iter()
.map(|network| network.trunc())
.collect();
if peers
.insert(
identity,
SavedPeer {
identity,
allowed_ips: routes,
},
)
.is_some()
{
duplicate_public_keys.insert(identity);
unverifiable_peer_indices.insert(peer.index);
}
}
let document_unverifiable = document
.issues()
.iter()
.any(|issue| !matches!(issue, DocumentIssue::ManagedMetadata(_)));
Self {
peers,
duplicate_public_keys,
unverifiable_peer_indices,
document_unverifiable,
}
}
}
/// A technical discrepancy or a reason verification could not safely decide.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ComparisonFinding {
InterfaceUnknown,
InterfaceUnavailable,
InterfaceMissing,
InterfaceMismatch { expected: String, observed: String },
PeerSetUnknown,
PeerSetUnavailable,
PeerSetPartial,
SavedDocumentUnverifiable,
SavedPeerUnverifiable { peer_index: usize },
DuplicateSavedPublicKey { identity: PublicKey },
DuplicateRuntimePublicKey { identity: PublicKey },
MissingRuntimePeer { identity: PublicKey },
ExtraRuntimePeer { identity: PublicKey },
RuntimeRoutesUnavailable { identity: PublicKey },
AllowedIpsMismatch { identity: PublicKey },
}
/// Outcome does not depend on handshake, counters, aliases, or record ordering.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TechnicalMatch {
Match,
Mismatch(Vec<ComparisonFinding>),
Unverifiable(Vec<ComparisonFinding>),
}
/// Compare the complete saved peer set with a runtime sample, requiring the
/// observed interface to be the interface selected by current configuration.
pub fn compare_peers(
saved: &SavedPeerProjection,
expected_interface: &str,
runtime: &RuntimeObservation,
) -> TechnicalMatch {
let mut findings = Vec::new();
let runtime_peers = match &runtime.interface {
InterfaceObservation::Unknown => {
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnknown]);
}
InterfaceObservation::Unavailable => {
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnavailable]);
}
InterfaceObservation::Missing => {
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMissing]);
}
InterfaceObservation::Present { name, peers } => {
if name != expected_interface {
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
expected: expected_interface.to_owned(),
observed: name.clone(),
}]);
}
peers
}
};
if saved.document_unverifiable {
findings.push(ComparisonFinding::SavedDocumentUnverifiable);
}
findings.extend(saved.unverifiable_peer_indices.iter().map(|peer_index| {
ComparisonFinding::SavedPeerUnverifiable {
peer_index: *peer_index,
}
}));
findings.extend(saved.duplicate_public_keys.iter().map(|identity| {
ComparisonFinding::DuplicateSavedPublicKey {
identity: *identity,
}
}));
findings.extend(runtime_peers.duplicate_public_keys.iter().map(|identity| {
ComparisonFinding::DuplicateRuntimePublicKey {
identity: *identity,
}
}));
match runtime_peers.completeness {
PeerSetCompleteness::Unknown => findings.push(ComparisonFinding::PeerSetUnknown),
PeerSetCompleteness::Unavailable => findings.push(ComparisonFinding::PeerSetUnavailable),
PeerSetCompleteness::Partial => findings.push(ComparisonFinding::PeerSetPartial),
PeerSetCompleteness::Complete => {}
}
for (identity, expected) in &saved.peers {
match runtime_peers.peers.get(identity) {
None if runtime_peers.completeness == PeerSetCompleteness::Complete => {
findings.push(ComparisonFinding::MissingRuntimePeer {
identity: *identity,
});
}
None => {}
Some(actual) => match &actual.allowed_ips {
Observed::Unavailable => {
findings.push(ComparisonFinding::RuntimeRoutesUnavailable {
identity: *identity,
})
}
Observed::Available(routes) => {
let normalized: BTreeSet<IpNet> =
routes.iter().map(|network| network.trunc()).collect();
if normalized != expected.allowed_ips {
findings.push(ComparisonFinding::AllowedIpsMismatch {
identity: *identity,
});
}
}
},
}
}
if runtime_peers.completeness == PeerSetCompleteness::Complete {
for identity in runtime_peers.peers.keys() {
if !saved.peers.contains_key(identity) {
findings.push(ComparisonFinding::ExtraRuntimePeer {
identity: *identity,
});
}
}
}
if findings.is_empty() {
TechnicalMatch::Match
} else if findings.iter().any(is_unverifiable_finding) {
TechnicalMatch::Unverifiable(findings)
} else {
TechnicalMatch::Mismatch(findings)
}
}
fn is_unverifiable_finding(finding: &ComparisonFinding) -> bool {
matches!(
finding,
ComparisonFinding::InterfaceUnknown
| ComparisonFinding::InterfaceUnavailable
| ComparisonFinding::PeerSetUnknown
| ComparisonFinding::PeerSetUnavailable
| ComparisonFinding::PeerSetPartial
| ComparisonFinding::SavedDocumentUnverifiable
| ComparisonFinding::SavedPeerUnverifiable { .. }
| ComparisonFinding::DuplicateSavedPublicKey { .. }
| ComparisonFinding::DuplicateRuntimePublicKey { .. }
| ComparisonFinding::RuntimeRoutesUnavailable { .. }
)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
fn key(value: &str) -> PublicKey {
PublicKey::from_str(value).unwrap()
}
fn runtime_peer(routes: Observed<Vec<IpNet>>) -> RuntimePeer {
RuntimePeer {
allowed_ips: routes,
last_handshake: Observed::Available(None),
received_bytes: Observed::Available(0),
transmitted_bytes: Observed::Available(0),
}
}
fn observation(records: Vec<(PublicKey, RuntimePeer)>) -> RuntimeObservation {
RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records),
},
}
}
fn compare(saved: &SavedPeerProjection, runtime: &RuntimeObservation) -> TechnicalMatch {
compare_peers(saved, "wg0", runtime)
}
fn projection(source: &str) -> SavedPeerProjection {
SavedPeerProjection::from_document(&ConfigDocument::parse(source).unwrap())
}
fn route(value: &str) -> IpNet {
value.parse().unwrap()
}
#[test]
fn observation_distinguishes_unknown_missing_unavailable_zero_and_no_handshake() {
assert_eq!(
RuntimeObservation::unknown().interface,
InterfaceObservation::Unknown
);
assert_eq!(
RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Unavailable,
}
.interface,
InterfaceObservation::Unavailable
);
assert!(matches!(
runtime_peer(Observed::Available(Vec::new())).last_handshake,
Observed::Available(None)
));
assert_eq!(
runtime_peer(Observed::Available(Vec::new())).received_bytes,
Observed::Available(0)
);
assert_eq!(
runtime_peer(Observed::Unavailable).allowed_ips,
Observed::Unavailable
);
}
#[test]
fn comparison_accepts_empty_set_and_normalized_reordered_dual_stack_routes() {
let empty = projection("");
assert_eq!(
compare(&empty, &observation(Vec::new())),
TechnicalMatch::Match
);
let source =
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::2/128, 10.0.0.2/32\n");
let expected = projection(&source);
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![
route("10.0.0.2/32"),
route("fd00::2/128"),
route("10.0.0.2/32"),
])),
)]);
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
}
#[test]
fn comparison_requires_the_configured_interface_identity() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let wrong_interface = RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg1".to_owned(),
peers: RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
[(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)],
),
},
};
assert_eq!(
compare_peers(&expected, "wg0", &wrong_interface),
TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
expected: "wg0".to_owned(),
observed: "wg1".to_owned(),
}])
);
}
#[test]
fn counters_and_handshake_changes_do_not_change_technical_match() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let mut before_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
before_restart_peer.last_handshake = Observed::Available(Some(
OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
));
before_restart_peer.received_bytes = Observed::Available(1_000);
before_restart_peer.transmitted_bytes = Observed::Available(300);
let before_restart = observation(vec![(key(KEY_A), before_restart_peer)]);
let mut after_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
after_restart_peer.last_handshake = Observed::Available(None);
after_restart_peer.received_bytes = Observed::Available(0);
after_restart_peer.transmitted_bytes = Observed::Available(7);
let after_restart = observation(vec![(key(KEY_A), after_restart_peer)]);
assert_eq!(compare(&expected, &before_restart), TechnicalMatch::Match);
assert_eq!(compare(&expected, &after_restart), TechnicalMatch::Match);
}
#[test]
fn projection_keeps_valid_unsupported_routes_and_ipv4_only_peers() {
let source = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 10.20.0.0/16\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.3/32\n"
);
let expected = projection(&source);
assert_eq!(expected.peers.len(), 2);
assert!(expected.unverifiable_peer_indices.is_empty());
let actual = observation(vec![
(
key(KEY_A),
runtime_peer(Observed::Available(vec![
route("10.20.0.0/16"),
route("10.0.0.2/32"),
])),
),
(
key(KEY_B),
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
),
]);
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
}
#[test]
fn comparison_reports_missing_extra_and_route_mismatch() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let actual = observation(vec![(
key(KEY_B),
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
)]);
let TechnicalMatch::Mismatch(findings) = compare(&expected, &actual) else {
panic!("expected concrete set differences");
};
assert!(findings.contains(&ComparisonFinding::MissingRuntimePeer {
identity: key(KEY_A)
}));
assert!(findings.contains(&ComparisonFinding::ExtraRuntimePeer {
identity: key(KEY_B)
}));
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.9/32")])),
)]);
assert!(matches!(
compare(&expected, &actual),
TechnicalMatch::Mismatch(findings)
if findings.contains(&ComparisonFinding::AllowedIpsMismatch { identity: key(KEY_A) })
));
}
#[test]
fn partial_duplicate_and_unavailable_records_never_pass() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let duplicate = RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
[
(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
),
(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
),
],
);
assert!(matches!(
compare(
&expected,
&RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: duplicate,
},
}
),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::DuplicateRuntimePublicKey { identity: key(KEY_A) })
));
for completeness in [
PeerSetCompleteness::Unknown,
PeerSetCompleteness::Unavailable,
PeerSetCompleteness::Partial,
] {
let partial = RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: RuntimePeerSet {
peers: BTreeMap::from([(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]),
duplicate_public_keys: BTreeSet::new(),
completeness,
},
},
};
let TechnicalMatch::Unverifiable(findings) = compare(&expected, &partial) else {
panic!("an incomplete peer set cannot verify");
};
if completeness != PeerSetCompleteness::Complete {
assert!(!findings.iter().any(|finding| matches!(
finding,
ComparisonFinding::MissingRuntimePeer { .. }
)));
}
}
let no_routes = observation(vec![(key(KEY_A), runtime_peer(Observed::Unavailable))]);
assert!(matches!(
compare(&expected, &no_routes),
TechnicalMatch::Unverifiable(_)
));
}
#[test]
fn duplicate_saved_identities_are_unverifiable_even_if_the_map_would_match() {
let source = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
);
let expected = projection(&source);
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&expected, &actual),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::DuplicateSavedPublicKey { identity: key(KEY_A) })
));
}
#[test]
fn invalid_saved_peer_and_interface_states_fail_closed_but_metadata_does_not() {
let invalid = projection("[Peer]\nAllowedIPs = 10.0.0.2/32\n");
assert!(matches!(
compare(&invalid, &observation(Vec::new())),
TechnicalMatch::Unverifiable(_)
));
let valid_with_bad_alias = projection(&format!(
"# wireguard-admin:v1 alias=bad%0A date=unknown\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
assert!(!valid_with_bad_alias.document_unverifiable);
for interface in [
InterfaceObservation::Unknown,
InterfaceObservation::Unavailable,
InterfaceObservation::Missing,
] {
let result = compare(
&projection(""),
&RuntimeObservation {
observed_at: None,
interface,
},
);
assert!(!matches!(result, TechnicalMatch::Match));
}
}
#[test]
fn malformed_routes_and_structural_saved_issues_cannot_be_omitted_for_a_match() {
let malformed_route = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, not-a-network\n"
));
assert!(!malformed_route.unverifiable_peer_indices.is_empty());
let apparent_subset_match = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&malformed_route, &apparent_subset_match),
TechnicalMatch::Unverifiable(findings)
if findings.iter().any(|finding| matches!(finding, ComparisonFinding::SavedPeerUnverifiable { .. }))
));
let structural = projection(&format!(
"[Interface]\nMalformed Directive\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
assert!(structural.document_unverifiable);
let exact_runtime = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&structural, &exact_runtime),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::SavedDocumentUnverifiable)
));
}
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
//! Bounded, read-only Linux Generic Netlink transport for WireGuard.
//!
//! Replies are received into zeroizing buffers. The datagram sender address
//! must be kernel port 0; `nlmsg_pid` in request replies is the requester's
//! port ID, as Linux Generic Netlink constructs replies using that destination.
use std::time::{Duration, Instant};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::fd::OwnedFd;
use rustix::io::Errno;
use rustix::net::{
AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, bind, getsockname,
netlink::SocketAddrNetlink, recvfrom, sendto, socket_with,
};
use zeroize::Zeroizing;
use super::netlink::{DecodeContext, DecodeError, DecodeLimits, GetDeviceDecoder};
use super::{InterfaceObservation, PeerSetCompleteness, RuntimeObservation, RuntimePeerSet};
const NETLINK_GENERIC: u32 = 16;
const CTRL_FAMILY_ID: u16 = 0x10;
const CTRL_CMD_GETFAMILY: u8 = 3;
const CTRL_CMD_NEWFAMILY: u8 = 1;
const CTRL_ATTR_FAMILY_ID: u16 = 1;
const CTRL_ATTR_FAMILY_NAME: u16 = 2;
const WG_CMD_GET_DEVICE: u8 = 0;
const WG_GENL_VERSION: u8 = 1;
const WG_ATTR_IFNAME: u16 = 2;
const NLMSG_ERROR: u16 = 2;
const NLMSG_DONE: u16 = 3;
const NLMSG_REQUEST: u16 = 1;
const NLMSG_DUMP: u16 = 0x300;
const NLMSG_MULTI: u16 = 2;
const MAX_DATAGRAM: usize = 1024 * 1024;
const MAX_INTERFACE_NAME: usize = 15;
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum TransportError {
#[error("configured WireGuard interface was not found")]
NoDevice,
#[error("permission denied while reading WireGuard state")]
PermissionDenied,
#[error("WireGuard netlink response was malformed or incomplete")]
Malformed,
#[error("WireGuard netlink transport failed")]
Transport,
#[error("WireGuard netlink request timed out")]
Timeout,
#[error("WireGuard netlink response exceeded configured limits")]
LimitExceeded,
}
#[derive(Debug)]
struct Datagram {
bytes: Zeroizing<Vec<u8>>,
sender: u32,
}
trait Io {
fn now(&self) -> Duration;
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError>;
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError>;
fn port_id(&self) -> u32;
}
struct LinuxIo {
socket: OwnedFd,
port_id: u32,
origin: Instant,
}
impl LinuxIo {
fn new() -> Result<Self, TransportError> {
let socket = socket_with(
AddressFamily::NETLINK,
SocketType::DGRAM,
SocketFlags::CLOEXEC.union(SocketFlags::NONBLOCK),
Some(rustix::net::netlink::GENERIC),
)
.map_err(map_errno)?;
bind(&socket, &SocketAddrNetlink::new(0, 0)).map_err(map_errno)?;
let addr = SocketAddrNetlink::try_from(getsockname(&socket).map_err(map_errno)?)
.map_err(|_| TransportError::Transport)?;
Ok(Self {
socket,
port_id: addr.pid(),
origin: Instant::now(),
})
}
}
impl Io for LinuxIo {
fn now(&self) -> Duration {
self.origin.elapsed()
}
fn port_id(&self) -> u32 {
self.port_id
}
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
let sent = sendto(
&self.socket,
bytes,
SendFlags::empty(),
&SocketAddrNetlink::new(destination, 0),
)
.map_err(map_errno)?;
if sent == bytes.len() {
Ok(())
} else {
Err(TransportError::Transport)
}
}
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
let timeout = Timespec {
tv_sec: wait.as_secs().min(i64::MAX as u64) as i64,
tv_nsec: wait.subsec_nanos() as i64,
};
let mut fds = [PollFd::new(&self.socket, PollFlags::IN)];
if poll(&mut fds, Some(&timeout)).map_err(map_errno)? == 0 {
return Err(TransportError::Timeout);
}
let mut bytes = Zeroizing::new(vec![0; MAX_DATAGRAM]);
let (_, count, address) = recvfrom(
&self.socket,
&mut bytes[..],
RecvFlags::TRUNC.union(RecvFlags::DONTWAIT),
)
.map_err(map_errno)?;
if count > MAX_DATAGRAM {
return Err(TransportError::LimitExceeded);
}
let sender = address
.and_then(|a| SocketAddrNetlink::try_from(a).ok())
.ok_or(TransportError::Malformed)?
.pid();
bytes.truncate(count);
Ok(Datagram { bytes, sender })
}
}
fn map_errno(error: Errno) -> TransportError {
if error == Errno::ACCESS || error == Errno::PERM {
TransportError::PermissionDenied
} else if error == Errno::NOENT || error == Errno::NODEV {
TransportError::NoDevice
} else if error == Errno::TIMEDOUT {
TransportError::Timeout
} else {
TransportError::Transport
}
}
fn map_kernel_error(error: i32) -> TransportError {
if error == Errno::ACCESS.raw_os_error() || error == Errno::PERM.raw_os_error() {
TransportError::PermissionDenied
} else if error == Errno::NOENT.raw_os_error() || error == Errno::NODEV.raw_os_error() {
TransportError::NoDevice
} else {
TransportError::Transport
}
}
pub(crate) fn observe_interface(
interface: &str,
timeout: Duration,
limits: DecodeLimits,
) -> Result<RuntimeObservation, TransportError> {
let mut io = LinuxIo::new()?;
observe_with_io(&mut io, interface, timeout, limits)
}
fn observe_with_io(
io: &mut impl Io,
interface: &str,
timeout: Duration,
limits: DecodeLimits,
) -> Result<RuntimeObservation, TransportError> {
if interface.is_empty()
|| interface.len() > MAX_INTERFACE_NAME
|| interface.as_bytes().contains(&0)
|| timeout.is_zero()
{
return Err(TransportError::Malformed);
}
let start = io.now();
let deadline = start.checked_add(timeout).ok_or(TransportError::Timeout)?;
let family = resolve_family(io, deadline)?;
let seq = 2;
let request = get_device_request(family, seq, io.port_id(), interface);
io.send(0, &request)?;
let context = DecodeContext {
sequence: seq,
port_id: io.port_id(),
family_id: family,
};
let mut decoder = GetDeviceDecoder::new(context, interface, limits);
loop {
let datagram = receive_before(io, deadline)?;
if datagram.sender != 0 {
return Err(TransportError::Malformed);
}
decoder.push_datagram(&datagram.bytes).map_err(map_decode)?;
// Decoder only returns a device after its explicit terminal DONE.
if decoder.is_done() {
let device = decoder.finish().map_err(map_decode)?;
return Ok(RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: device.interface_name,
peers: RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
device.peers,
),
},
});
}
}
}
fn receive_before(io: &mut impl Io, deadline: Duration) -> Result<Datagram, TransportError> {
let remaining = deadline
.checked_sub(io.now())
.ok_or(TransportError::Timeout)?;
if remaining.is_zero() {
return Err(TransportError::Timeout);
}
io.receive(remaining)
}
fn resolve_family(io: &mut impl Io, deadline: Duration) -> Result<u16, TransportError> {
let seq = 1;
let mut request = message(CTRL_FAMILY_ID, NLMSG_REQUEST, seq, io.port_id());
request.extend_from_slice(&[CTRL_CMD_GETFAMILY, 1, 0, 0]);
push_attr(&mut request, CTRL_ATTR_FAMILY_NAME, b"wireguard\0");
set_len(&mut request);
io.send(0, &request)?;
let d = receive_before(io, deadline)?;
if d.sender != 0 {
return Err(TransportError::Malformed);
}
parse_family(&d.bytes, seq, io.port_id())
}
fn parse_family(bytes: &[u8], seq: u32, port: u32) -> Result<u16, TransportError> {
if bytes.len() < 20 {
return Err(TransportError::Malformed);
}
if read_u32(bytes, 0)? as usize != bytes.len()
|| read_u32(bytes, 8)? != seq
|| read_u32(bytes, 12)? != port
{
return Err(TransportError::Malformed);
}
let message_type = read_u16(bytes, 4)?;
if message_type == NLMSG_ERROR {
let status = read_u32(bytes, 16)? as i32;
if status == 0 {
return Err(TransportError::Malformed);
}
return Err(status
.checked_neg()
.map(map_kernel_error)
.ok_or(TransportError::Malformed)?);
}
if message_type != CTRL_FAMILY_ID {
return Err(TransportError::Malformed);
}
if bytes[16] != CTRL_CMD_NEWFAMILY || read_u16(bytes, 18)? != 0 {
return Err(TransportError::Malformed);
}
let mut off = 20;
while off < bytes.len() {
if bytes.len() - off < 4 {
return Err(TransportError::Malformed);
}
let len = read_u16(bytes, off)? as usize;
let kind = read_u16(bytes, off + 2)? & 0x3fff;
if len < 4 || off + len > bytes.len() {
return Err(TransportError::Malformed);
}
if kind == CTRL_ATTR_FAMILY_ID && len == 6 {
return read_u16(bytes, off + 4);
}
let aligned_len = len.checked_add(3).ok_or(TransportError::Malformed)? & !3;
off = off
.checked_add(aligned_len)
.filter(|&end| end <= bytes.len())
.ok_or(TransportError::Malformed)?;
}
Err(TransportError::NoDevice)
}
fn get_device_request(family: u16, seq: u32, port: u32, interface: &str) -> Vec<u8> {
let mut out = message(family, NLMSG_REQUEST | NLMSG_DUMP, seq, port);
out.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
let mut interface_name = interface.as_bytes().to_vec();
interface_name.push(0);
push_attr(&mut out, WG_ATTR_IFNAME, &interface_name);
set_len(&mut out);
out
}
fn message(kind: u16, flags: u16, seq: u32, port: u32) -> Vec<u8> {
let mut out = vec![0; 16];
out[4..6].copy_from_slice(&kind.to_ne_bytes());
out[6..8].copy_from_slice(&flags.to_ne_bytes());
out[8..12].copy_from_slice(&seq.to_ne_bytes());
out[12..16].copy_from_slice(&port.to_ne_bytes());
out
}
fn push_attr(out: &mut Vec<u8>, kind: u16, value: &[u8]) {
let len = (4 + value.len()) as u16;
out.extend_from_slice(&len.to_ne_bytes());
out.extend_from_slice(&kind.to_ne_bytes());
out.extend_from_slice(value);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
fn set_len(out: &mut [u8]) {
let len = out.len() as u32;
out[..4].copy_from_slice(&len.to_ne_bytes());
}
fn read_u16(bytes: &[u8], at: usize) -> Result<u16, TransportError> {
bytes
.get(at..at + 2)
.and_then(|b| b.try_into().ok())
.map(u16::from_ne_bytes)
.ok_or(TransportError::Malformed)
}
fn read_u32(bytes: &[u8], at: usize) -> Result<u32, TransportError> {
bytes
.get(at..at + 4)
.and_then(|b| b.try_into().ok())
.map(u32::from_ne_bytes)
.ok_or(TransportError::Malformed)
}
fn map_decode(error: DecodeError) -> TransportError {
match error {
DecodeError::KernelError(n) if n == Errno::NODEV.raw_os_error() => TransportError::NoDevice,
DecodeError::KernelError(n)
if n == Errno::ACCESS.raw_os_error() || n == Errno::PERM.raw_os_error() =>
{
TransportError::PermissionDenied
}
DecodeError::LimitExceeded => TransportError::LimitExceeded,
DecodeError::KernelError(_) | DecodeError::DumpFailed => TransportError::Transport,
_ => TransportError::Malformed,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctrl_family_message(sequence: u32, port: u32, family: u16) -> Vec<u8> {
let mut out = message(CTRL_FAMILY_ID, 0, sequence, port);
out.extend_from_slice(&[CTRL_CMD_NEWFAMILY, 2, 0, 0]);
push_attr(&mut out, CTRL_ATTR_FAMILY_ID, &family.to_ne_bytes());
set_len(&mut out);
out
}
#[test]
fn resolves_family_only_from_matching_kernel_reply() {
assert_eq!(parse_family(&ctrl_family_message(1, 55, 42), 1, 55), Ok(42));
assert_eq!(
parse_family(&ctrl_family_message(2, 55, 42), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&ctrl_family_message(1, 56, 42), 1, 55),
Err(TransportError::Malformed)
);
}
fn netlink_error(sequence: u32, port: u32, status: i32) -> Vec<u8> {
let mut out = message(NLMSG_ERROR, 0, sequence, port);
out.extend_from_slice(&status.to_ne_bytes());
set_len(&mut out);
out
}
#[test]
fn family_errors_map_kernel_errno_after_validating_reply_identity() {
for errno in [Errno::NOENT, Errno::NODEV] {
assert_eq!(
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
Err(TransportError::NoDevice)
);
}
for errno in [Errno::ACCESS, Errno::PERM] {
assert_eq!(
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
Err(TransportError::PermissionDenied)
);
}
assert_eq!(
parse_family(&netlink_error(1, 55, -Errno::IO.raw_os_error()), 1, 55),
Err(TransportError::Transport)
);
assert_eq!(
parse_family(&netlink_error(1, 55, i32::MIN), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(1, 55, 0), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(2, 55, -Errno::NOENT.raw_os_error()), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(1, 56, -Errno::NOENT.raw_os_error()), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(
&netlink_error(1, 55, -Errno::NOENT.raw_os_error())[..19],
1,
55
),
Err(TransportError::Malformed)
);
}
#[test]
fn family_resolution_propagates_kernel_errors_from_injected_transport() {
for (errno, expected) in [
(Errno::NODEV, TransportError::NoDevice),
(Errno::ACCESS, TransportError::PermissionDenied),
(Errno::IO, TransportError::Transport),
] {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(netlink_error(1, 55, -errno.raw_os_error()), 0));
assert_eq!(
resolve_family(&mut fake, Duration::from_secs(1)),
Err(expected)
);
assert_eq!(fake.sent.len(), 1);
}
}
#[test]
fn get_request_targets_only_named_interface_and_dump_flags() {
let request = get_device_request(42, 7, 55, "wg-test");
assert_eq!(read_u16(&request, 4), Ok(42));
assert_eq!(read_u16(&request, 6), Ok(NLMSG_REQUEST | NLMSG_DUMP));
assert_eq!(
&request[16..20],
&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]
);
assert_eq!(read_u16(&request, 20), Ok(12));
assert_eq!(&request[22..24], &2u16.to_ne_bytes());
assert_eq!(&request[24..32], b"wg-test\0");
assert_eq!(request.len(), 32);
assert_eq!(read_u32(&request, 0), Ok(request.len() as u32));
}
#[derive(Default)]
struct FakeIo {
clock: Duration,
port: u32,
sent: Vec<(u32, Vec<u8>)>,
incoming: std::collections::VecDeque<Datagram>,
}
impl Io for FakeIo {
fn now(&self) -> Duration {
self.clock
}
fn port_id(&self) -> u32 {
self.port
}
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
self.sent.push((destination, bytes.to_vec()));
Ok(())
}
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
if let Some(datagram) = self.incoming.pop_front() {
Ok(datagram)
} else {
self.clock += wait;
Err(TransportError::Timeout)
}
}
}
fn datagram(bytes: Vec<u8>, sender: u32) -> Datagram {
Datagram {
bytes: Zeroizing::new(bytes),
sender,
}
}
#[test]
fn injected_transport_rejects_non_kernel_sender_and_bounds_resolution_wait() {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(ctrl_family_message(1, 55, 42), 22));
assert_eq!(
resolve_family(&mut fake, Duration::from_secs(1)),
Err(TransportError::Malformed)
);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, 0);
let mut silent = FakeIo {
port: 55,
..FakeIo::default()
};
assert_eq!(
resolve_family(&mut silent, Duration::from_millis(5)),
Err(TransportError::Timeout)
);
assert_eq!(silent.clock, Duration::from_millis(5));
}
#[test]
fn injected_transport_returns_only_a_complete_public_empty_observation() {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(ctrl_family_message(1, 55, 42), 0));
let mut response = message(42, NLMSG_MULTI, 2, 55);
response.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
push_attr(&mut response, 2, b"wg0\0");
push_attr(&mut response, 8 | 0x8000, &[]);
set_len(&mut response);
let mut complete = response;
let done_start = complete.len();
complete.extend_from_slice(&message(NLMSG_DONE, 0, 2, 55));
complete.extend_from_slice(&0i32.to_ne_bytes());
set_len(&mut complete[done_start..]);
fake.incoming.push_back(datagram(complete, 0));
let observation = observe_with_io(
&mut fake,
"wg0",
Duration::from_secs(2),
DecodeLimits::default(),
)
.unwrap();
assert!(
matches!(observation.interface, InterfaceObservation::Present { ref name, ref peers } if name == "wg0" && peers.completeness == PeerSetCompleteness::Complete && peers.peers.is_empty())
);
assert_eq!(fake.sent.len(), 2);
assert!(fake.sent.iter().all(|(destination, _)| *destination == 0));
}
}
+480
View File
@@ -0,0 +1,480 @@
//! Systemd restart job tracking and bounded D-Bus adapter.
//!
//! The event tracker is pure. The real D-Bus restart adapter is crate-private;
//! callers outside the crate must use the application restart boundary.
//!
//! ```compile_fail
//! use wireguard_admin::service::dbus::restart_systemd_unit_report;
//! ```
mod dbus;
pub(crate) use dbus::restart_systemd_unit_report;
use std::time::{Duration, Instant};
/// One operation-wide monotonic deadline, created before bus setup begins.
///
/// The same value should be used for connection, subscription, request,
/// waiting, and subsequent runtime verification. A timed-out wait does not
/// imply that systemd canceled its job.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RestartDeadline {
started_at: Instant,
timeout: Duration,
}
impl RestartDeadline {
pub const fn new(started_at: Instant, timeout: Duration) -> Self {
Self {
started_at,
timeout,
}
}
pub const fn started_at(self) -> Instant {
self.started_at
}
pub const fn timeout(self) -> Duration {
self.timeout
}
/// Remaining time at a monotonic sample. Values sampled before the start
/// are treated as the start, which also keeps deterministic callers safe.
pub fn remaining_at(self, now: Instant) -> Duration {
self.timeout
.saturating_sub(now.saturating_duration_since(self.started_at))
}
pub fn is_expired_at(self, now: Instant) -> bool {
self.remaining_at(now).is_zero()
}
}
/// The only event the tracker needs from systemd's `JobRemoved` signal.
/// Unknown systemd result strings should map to [`JobResult::Other`], without
/// retaining or surfacing the raw bus value.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum JobResult {
Done,
Failed,
Dependency,
Timeout,
Canceled,
Skipped,
Other,
}
/// An event emitted by the D-Bus adapter and consumed by the job tracker.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ServiceEvent {
JobRemoved {
job_path: String,
unit: String,
result: JobResult,
},
Disconnected,
ManagerOwnerChanged,
SubscriptionFailed,
PermissionDenied,
RequestFailed,
}
/// Redacted terminal reason. No D-Bus error string or signal payload is kept.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartFailure {
JobFailed,
JobDependency,
JobTimedOut,
JobCanceled,
JobSkipped,
JobResultUnknown,
BusDisconnected,
ManagerOwnerChanged,
SubscriptionFailed,
UnitResolutionFailed,
PermissionDenied,
RequestFailed,
WaitTimedOutMayStillComplete,
EarlyEventBufferFull,
InvalidJobPath,
}
/// Secret-free operation boundary where a restart attempt failed.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartPhase {
Connect,
Subscribe,
ResolveUnit,
RequestRestart,
WaitForJob,
}
/// Current state of a single restart attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartOutcome {
Waiting,
Succeeded,
Failed(RestartFailure),
}
/// Terminal job outcome plus the request-submission boundary. A timeout after
/// submission explicitly leaves open the possibility that the job may finish.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RestartExecution {
pub outcome: RestartOutcome,
pub submitted: bool,
pub phase: RestartPhase,
}
/// Pure event tracker for the primary systemd unit resolved from the configured
/// target.
///
/// Signals can arrive before the method reply containing the job path. To
/// close that race, up to [`MAX_EARLY_JOB_EVENTS`] events for the configured
/// unit are retained until `job_submitted` provides the actual path. Overflow
/// fails closed instead of silently dropping a potentially relevant event.
pub struct RestartJobTracker {
configured_unit: String,
deadline: RestartDeadline,
submitted_job_path: Option<String>,
early_events: Vec<(String, JobResult)>,
outcome: RestartOutcome,
}
/// Bound for `JobRemoved` events received before the systemd method reply.
pub const MAX_EARLY_JOB_EVENTS: usize = 64;
impl RestartJobTracker {
pub fn new(configured_unit: impl Into<String>, deadline: RestartDeadline) -> Self {
Self {
configured_unit: configured_unit.into(),
deadline,
submitted_job_path: None,
early_events: Vec::new(),
outcome: RestartOutcome::Waiting,
}
}
pub fn configured_unit(&self) -> &str {
&self.configured_unit
}
pub const fn deadline(&self) -> RestartDeadline {
self.deadline
}
pub fn submitted_job_path(&self) -> Option<&str> {
self.submitted_job_path.as_deref()
}
pub const fn outcome(&self) -> RestartOutcome {
self.outcome
}
/// Record the actual object path returned by systemd's restart request.
/// Any earlier matching-unit event is reconciled against that path.
pub fn job_submitted(&mut self, job_path: impl Into<String>, now: Instant) -> RestartOutcome {
if self.outcome != RestartOutcome::Waiting {
return self.outcome;
}
if self.expire_if_needed(now) {
return self.outcome;
}
let job_path = job_path.into();
if job_path.is_empty() {
self.fail(RestartFailure::InvalidJobPath);
return self.outcome;
}
if self.submitted_job_path.is_some() {
self.fail(RestartFailure::RequestFailed);
return self.outcome;
}
self.submitted_job_path = Some(job_path.clone());
for (event_path, result) in std::mem::take(&mut self.early_events) {
if event_path == job_path {
self.finish_job(result);
break;
}
}
self.outcome
}
/// Apply one event using a caller-supplied monotonic time sample.
pub fn observe(&mut self, event: ServiceEvent, now: Instant) -> RestartOutcome {
if self.outcome != RestartOutcome::Waiting {
return self.outcome;
}
if self.expire_if_needed(now) {
return self.outcome;
}
match event {
ServiceEvent::JobRemoved {
job_path,
unit,
result,
} => {
if unit != self.configured_unit || job_path.is_empty() {
return self.outcome;
}
if let Some(submitted_path) = &self.submitted_job_path {
if &job_path == submitted_path {
self.finish_job(result);
}
} else if self.early_events.len() == MAX_EARLY_JOB_EVENTS {
self.fail(RestartFailure::EarlyEventBufferFull);
} else {
self.early_events.push((job_path, result));
}
}
ServiceEvent::Disconnected => self.fail(RestartFailure::BusDisconnected),
ServiceEvent::ManagerOwnerChanged => self.fail(RestartFailure::ManagerOwnerChanged),
ServiceEvent::SubscriptionFailed => self.fail(RestartFailure::SubscriptionFailed),
ServiceEvent::PermissionDenied => self.fail(RestartFailure::PermissionDenied),
ServiceEvent::RequestFailed => self.fail(RestartFailure::RequestFailed),
}
self.outcome
}
/// Check the operation deadline even when no event arrives.
pub fn poll(&mut self, now: Instant) -> RestartOutcome {
self.expire_if_needed(now);
self.outcome
}
fn expire_if_needed(&mut self, now: Instant) -> bool {
if self.deadline.is_expired_at(now) {
self.fail(RestartFailure::WaitTimedOutMayStillComplete);
true
} else {
false
}
}
fn finish_job(&mut self, result: JobResult) {
self.outcome = match result {
JobResult::Done => RestartOutcome::Succeeded,
JobResult::Failed => RestartOutcome::Failed(RestartFailure::JobFailed),
JobResult::Dependency => RestartOutcome::Failed(RestartFailure::JobDependency),
JobResult::Timeout => RestartOutcome::Failed(RestartFailure::JobTimedOut),
JobResult::Canceled => RestartOutcome::Failed(RestartFailure::JobCanceled),
JobResult::Skipped => RestartOutcome::Failed(RestartFailure::JobSkipped),
JobResult::Other => RestartOutcome::Failed(RestartFailure::JobResultUnknown),
};
}
fn fail(&mut self, failure: RestartFailure) {
self.outcome = RestartOutcome::Failed(failure);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (Instant, RestartJobTracker) {
let start = Instant::now();
let deadline = RestartDeadline::new(start, Duration::from_secs(5));
(
start,
RestartJobTracker::new("wg-quick@wg0.service", deadline),
)
}
fn removed(path: &str, unit: &str, result: JobResult) -> ServiceEvent {
ServiceEvent::JobRemoved {
job_path: path.to_owned(),
unit: unit.to_owned(),
result,
}
}
#[test]
fn buffers_fast_completion_until_actual_job_path_is_returned() {
let (start, mut tracker) = setup();
assert_eq!(
tracker.observe(
removed(
"/org/freedesktop/systemd1/job/41",
"wg-quick@wg0.service",
JobResult::Done
),
start + Duration::from_millis(1),
),
RestartOutcome::Waiting
);
assert_eq!(
tracker.job_submitted(
"/org/freedesktop/systemd1/job/41",
start + Duration::from_millis(2),
),
RestartOutcome::Succeeded
);
}
#[test]
fn unrelated_jobs_and_units_do_not_finish_the_attempt() {
let (start, mut tracker) = setup();
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
for event in [
removed("/job/other", "wg-quick@wg0.service", JobResult::Done),
removed("/job/actual", "wg-quick@wg1.service", JobResult::Done),
] {
assert_eq!(
tracker.observe(event, start + Duration::from_millis(2)),
RestartOutcome::Waiting
);
}
}
#[test]
fn configured_alias_matches_job_removed_primary_unit_name() {
let start = Instant::now();
let deadline = RestartDeadline::new(start, Duration::from_secs(5));
// The adapter resolves this primary ID from configured alias
// "wireguard-vpn.service" before constructing the tracker.
let mut tracker = RestartJobTracker::new("wg-quick@wg0.service", deadline);
tracker.job_submitted("/job/alias-target", start + Duration::from_millis(1));
assert_eq!(
tracker.observe(
removed("/job/alias-target", "wg-quick@wg0.service", JobResult::Done,),
start + Duration::from_millis(2),
),
RestartOutcome::Succeeded
);
}
#[test]
fn only_the_done_job_result_is_success() {
for (job_result, failure) in [
(JobResult::Failed, RestartFailure::JobFailed),
(JobResult::Dependency, RestartFailure::JobDependency),
(JobResult::Timeout, RestartFailure::JobTimedOut),
(JobResult::Canceled, RestartFailure::JobCanceled),
(JobResult::Skipped, RestartFailure::JobSkipped),
(JobResult::Other, RestartFailure::JobResultUnknown),
] {
let (start, mut tracker) = setup();
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
assert_eq!(
tracker.observe(
removed("/job/actual", "wg-quick@wg0.service", job_result),
start + Duration::from_millis(2),
),
RestartOutcome::Failed(failure)
);
}
}
#[test]
fn events_for_same_unit_are_still_correlated_by_returned_path() {
let (start, mut tracker) = setup();
tracker.observe(
removed("/job/unrelated", "wg-quick@wg0.service", JobResult::Failed),
start + Duration::from_millis(1),
);
assert_eq!(
tracker.job_submitted("/job/actual", start + Duration::from_millis(2)),
RestartOutcome::Waiting
);
}
#[test]
fn systemd_oneshot_success_needs_no_pid_or_active_state() {
let (start, mut tracker) = setup();
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
assert_eq!(
tracker.observe(
removed("/job/actual", "wg-quick@wg0.service", JobResult::Done),
start + Duration::from_millis(2),
),
RestartOutcome::Succeeded
);
}
#[test]
fn monotonic_deadline_is_shared_and_timeout_does_not_claim_cancellation() {
let (start, mut tracker) = setup();
let deadline = tracker.deadline();
assert_eq!(
deadline.remaining_at(start + Duration::from_secs(2)),
Duration::from_secs(3)
);
assert_eq!(
tracker.poll(start + Duration::from_secs(5)),
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete)
);
}
#[test]
fn bus_and_request_failures_are_redacted_terminal_outcomes() {
for (event, failure) in [
(ServiceEvent::Disconnected, RestartFailure::BusDisconnected),
(
ServiceEvent::ManagerOwnerChanged,
RestartFailure::ManagerOwnerChanged,
),
(
ServiceEvent::SubscriptionFailed,
RestartFailure::SubscriptionFailed,
),
(
ServiceEvent::PermissionDenied,
RestartFailure::PermissionDenied,
),
(ServiceEvent::RequestFailed, RestartFailure::RequestFailed),
] {
let (start, mut tracker) = setup();
assert_eq!(
tracker.observe(event, start + Duration::from_millis(1)),
RestartOutcome::Failed(failure)
);
}
}
#[test]
fn early_event_buffer_overflow_fails_closed() {
let (start, mut tracker) = setup();
for index in 0..MAX_EARLY_JOB_EVENTS {
assert_eq!(
tracker.observe(
removed(
&format!("/job/{index}"),
"wg-quick@wg0.service",
JobResult::Done,
),
start + Duration::from_millis(1),
),
RestartOutcome::Waiting
);
}
assert_eq!(
tracker.observe(
removed("/job/overflow", "wg-quick@wg0.service", JobResult::Done),
start + Duration::from_millis(2),
),
RestartOutcome::Failed(RestartFailure::EarlyEventBufferFull)
);
}
#[test]
fn terminal_outcome_cannot_be_replaced_by_later_events() {
let (start, mut tracker) = setup();
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
tracker.observe(
removed("/job/actual", "wg-quick@wg0.service", JobResult::Failed),
start + Duration::from_millis(2),
);
assert_eq!(
tracker.observe(
removed("/job/actual", "wg-quick@wg0.service", JobResult::Done),
start + Duration::from_millis(3),
),
RestartOutcome::Failed(RestartFailure::JobFailed)
);
}
}
+1282
View File
File diff suppressed because it is too large Load Diff
+138 -4
View File
@@ -1,11 +1,20 @@
use std::ffi::OsString;
use std::fmt;
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
use crate::app::Application;
use crate::config::{ResolvedSettings, SettingsFile};
use crate::persistence::SavedStore;
const MAX_SETTINGS_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StartupAction {
Help,
Version,
ManagementUnavailable,
Management,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -25,6 +34,64 @@ impl fmt::Display for StartupError {
impl std::error::Error for StartupError {}
/// Fixed, secret-free reasons management session startup can fail.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionLoadError {
RootRequired,
SettingsUnavailable,
SettingsInvalid,
StoreInvalid,
SavedStateInvalid,
}
impl fmt::Display for SessionLoadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::RootRequired => "management operations require root",
Self::SettingsUnavailable => "executable-relative settings are unavailable",
Self::SettingsInvalid => "executable-relative settings are invalid",
Self::StoreInvalid => "saved-state paths are unsafe or inconsistent",
Self::SavedStateInvalid => "saved state could not be loaded safely",
})
}
}
impl std::error::Error for SessionLoadError {}
/// Load a management session only after checking privilege. Settings are read
/// beside the executable, then SavedStore performs journal recovery and
/// validates the complete saved state before Application is exposed.
/// Terminal suitability is checked by the later terminal-startup boundary.
pub fn open_management_session(
executable_directory: &Path,
effective_uid_is_root: impl FnOnce() -> bool,
) -> Result<Application, SessionLoadError> {
if !effective_uid_is_root() {
return Err(SessionLoadError::RootRequired);
}
let settings_path = ResolvedSettings::settings_path(executable_directory);
let metadata =
fs::symlink_metadata(&settings_path).map_err(|_| SessionLoadError::SettingsUnavailable)?;
if !metadata.file_type().is_file() || metadata.len() > MAX_SETTINGS_BYTES {
return Err(SessionLoadError::SettingsUnavailable);
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
File::open(&settings_path)
.and_then(|file| file.take(MAX_SETTINGS_BYTES + 1).read_to_end(&mut bytes))
.map_err(|_| SessionLoadError::SettingsUnavailable)?;
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
return Err(SessionLoadError::SettingsUnavailable);
}
let text = std::str::from_utf8(&bytes).map_err(|_| SessionLoadError::SettingsInvalid)?;
let resolved = SettingsFile::parse_toml(text)
.and_then(SettingsFile::validate)
.map(|validated| validated.resolve_paths(executable_directory))
.map_err(|_| SessionLoadError::SettingsInvalid)?;
let store = SavedStore::new(executable_directory, resolved)
.map_err(|_| SessionLoadError::StoreInvalid)?;
Application::open_from_saved_store(store).map_err(|_| SessionLoadError::SavedStateInvalid)
}
/// Resolves the supported command line and checks management privilege lazily.
///
/// The injected root check keeps this boundary testable without running tests as
@@ -36,7 +103,7 @@ pub fn start(
match arguments {
[] => {
if effective_uid_is_root() {
Ok(StartupAction::ManagementUnavailable)
Ok(StartupAction::Management)
} else {
Err(StartupError::RootRequired)
}
@@ -72,9 +139,9 @@ mod tests {
}
#[test]
fn root_management_startup_reports_unimplemented_without_side_effects() {
fn root_management_startup_is_authorized_for_terminal_boundary() {
let result = start(&[], || true);
assert_eq!(result, Ok(StartupAction::ManagementUnavailable));
assert_eq!(result, Ok(StartupAction::Management));
}
#[test]
@@ -89,4 +156,71 @@ mod tests {
assert_eq!(result, Err(StartupError::UnknownArguments));
}
}
#[test]
fn management_session_checks_root_before_reading_settings() {
let result = super::open_management_session(
std::path::Path::new("/path/that/does/not/exist"),
|| false,
);
assert_eq!(result.err(), Some(super::SessionLoadError::RootRequired));
}
#[test]
fn management_session_loads_supported_state_and_keeps_unsupported_peers_visible() {
use std::os::unix::fs::PermissionsExt;
let directory = std::env::temp_dir().join(format!(
"wireguard-admin-startup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(
directory.join(crate::config::SETTINGS_FILE_NAME),
r#"interface_name = "wg0"
server_config_path = "wg0.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.org:51820"
ipv4_allocation_range = "10.80.0.0/24"
ipv6_allocation_range = "fd80::/64"
dns_addresses = ["10.80.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 10
"#,
)
.unwrap();
let server_path = directory.join("wg0.conf");
std::fs::write(
&server_path,
b"[Interface]\nAddress = 10.80.0.1/24\n# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xlYW4\n# wg-admin:end\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.80.0.2/32, fd80::2/128\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.80.0.0/24\n",
)
.unwrap();
std::fs::set_permissions(&server_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let app = super::open_management_session(&directory, || true)
.expect("valid synthetic installation should open");
let projection = app.display_projection();
assert_eq!(projection.clients.len(), 2);
assert_eq!(app.states().saved().clients().count(), 1);
assert!(projection.clients[0].mutable_identity.is_some());
assert!(projection.clients[1].mutable_identity.is_none());
assert_eq!(projection.clients[1].allowed_ips, ["10.80.0.0/24"]);
assert!(!format!("{projection:?}").contains("PrivateKey"));
let internal_state = directory.join("Backup/data");
std::fs::create_dir_all(&internal_state).unwrap();
std::fs::write(
internal_state.join("internal-state.toml"),
b"not valid state",
)
.unwrap();
assert_eq!(
super::open_management_session(&directory, || true).err(),
Some(super::SessionLoadError::SavedStateInvalid),
"corrupt persisted state must stop session loading rather than start empty"
);
std::fs::remove_dir_all(directory).unwrap();
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Native terminal workflow state and the serialized application worker.
//!
//! Terminal input/rendering adapters are added in the subsequent M7 tasks.
pub mod events;
pub mod forms;
pub mod render;
pub mod state;
pub mod terminal;
pub use events::{ApplicationWorker, UiCommand, WorkerConfig, WorkerEvent};
pub use state::{SessionView, UiState};
+1145
View File
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
use super::{
CommandResult, DeliveryDestinationSummary, FirstIssueAttempt, FirstIssueStatus,
FirstIssueSummary, OperationError, OperationExecutor, QrStatus, ReissueSummary,
RestartPreviewSummary, RestorePreviewSummary, SensitiveCommand, UiCommand,
};
use crate::app::{Application, FirstIssueDeliveryOutcome, RestorePreview};
use crate::delivery::files::PreparedDestination;
use crate::delivery::{QrCodeOutput, QrError};
use crate::domain::PublicKey;
enum DeliveryMode {
FirstIssue,
Reissue,
}
struct PreparedDestinationContext {
identity: PublicKey,
include_qr: bool,
mode: DeliveryMode,
attempt: FirstIssueAttempt,
generation: u64,
destination: PreparedDestination,
}
struct RestorePreviewContext {
_archive_name: String,
generation: u64,
preview: RestorePreview,
}
#[derive(Default)]
pub struct ApplicationExecutor {
prepared_destination: Option<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
let restore_continuation = matches!(
command,
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
);
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
self.prepared_destination = None;
}
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
self.restore_preview = None;
}
let result = match command {
UiCommand::CreateClient {
alias,
manual_addresses,
created_utc,
} => app
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
.map(CommandResult::ClientCreated),
UiCommand::RenameClient { identity, alias } => app
.rename_client(identity, &alias)
.map(|()| CommandResult::ClientRenamed),
UiCommand::DeleteClient { identity } => app
.delete_client(identity)
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
UiCommand::Commit => app.commit().map(CommandResult::Committed),
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
UiCommand::ImportSelectedFile { file_name } => app
.import_selected_file(&file_name)
.map(CommandResult::SelectedFileImported),
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
CommandResult::RestartPreview(RestartPreviewSummary {
has_draft: preview.has_draft(),
interface: preview.interface().to_owned(),
systemd_unit: preview.systemd_unit().to_owned(),
})
}),
UiCommand::Restart(choice) => {
let result = app
.preview_restart()
.map(|preview| app.restart(preview, choice));
result.map(CommandResult::Restarted)
}
UiCommand::PrepareDelivery {
identity,
path,
include_qr,
reissue,
attempt,
} => app
.prepare_first_issue_destination(path)
.map_err(crate::app::AppError::FileOutput)
.map(|destination| {
let summary = DeliveryDestinationSummary {
identity,
path: destination.path().to_path_buf(),
overwrite_required: destination.overwrite_confirmation_required(),
include_qr,
reissue,
attempt,
prepared_generation: generation,
};
self.prepared_destination = Some(PreparedDestinationContext {
identity,
include_qr,
mode: if reissue {
DeliveryMode::Reissue
} else {
DeliveryMode::FirstIssue
},
attempt,
generation,
destination,
});
CommandResult::DeliveryDestination(summary)
}),
UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|| prepared.attempt != attempt
|| !destination_generation_matches(
prepared.generation,
prepared_generation,
generation,
)
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let identity = prepared.identity;
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
if include_qr {
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
let (qr, qr_output) = qr_result(delivery.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => {
let (qr, qr_output) = qr_result(value.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
} else {
app.deliver_first_issue_file(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
}
}
UiCommand::PreviewRestore { file_name } => {
app.preview_restore(&file_name).map(|preview| {
let changes = preview.change_summary(app.saved_bytes());
let summary = RestorePreviewSummary {
file_name: file_name.clone(),
created_at_utc: preview.created_at_utc().to_owned(),
reason: preview.reason(),
historical_apply: preview.historical_apply().clone(),
changes,
preview_generation: generation,
};
self.restore_preview = Some(RestorePreviewContext {
_archive_name: file_name,
generation,
preview,
});
CommandResult::RestorePreview(summary)
})
}
UiCommand::CancelPreparedDelivery => {
self.prepared_destination = None;
Ok(CommandResult::Cancelled)
}
UiCommand::CancelRestorePreview => {
self.restore_preview = None;
Ok(CommandResult::Cancelled)
}
UiCommand::ConfirmRestore { preview_generation } => {
let context = self.restore_preview.take().ok_or_else(|| OperationError {
message: "Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
if !restore_preview_generation_matches(
context.generation,
preview_generation,
generation,
) {
return Err(OperationError {
message: "Restore preview is stale; select the archive again.".to_owned(),
});
}
app.confirm_restore(context.preview)
.map(CommandResult::Restored)
}
};
result.map_err(|error| OperationError {
message: error.to_string(),
})
}
fn execute_sensitive(
&mut self,
app: &mut Application,
command: SensitiveCommand,
) -> Result<CommandResult, OperationError> {
match command {
SensitiveCommand::PrepareInitialSetup(password) => app
.prepare_initial_setup_from_sources(password.expose())
.map(CommandResult::InitialSetupPrepared)
.map_err(|error| OperationError {
message: error.to_string(),
}),
SensitiveCommand::ReissueClient {
identity,
password,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if prepared.identity != identity
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|| prepared.generation != prepared_generation
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
let delivery = app
.reissue_client_file(identity, password.expose(), destination, include_qr)
.map_err(|error| OperationError {
message: error.to_string(),
})?;
let (qr, qr_output) = match delivery.qr {
None => (QrStatus::NotRequested, None),
Some(result) => qr_result(result),
};
Ok(CommandResult::Reissued(ReissueSummary {
activation_warning: delivery.activation_warning,
qr,
qr_output,
}))
}
}
}
fn execute_sensitive_at(
&mut self,
app: &mut Application,
command: SensitiveCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
match &command {
SensitiveCommand::ReissueClient {
prepared_generation,
..
} if *prepared_generation != generation => {
return Err(OperationError {
message: "Prepared output destination is stale; select it again.".to_owned(),
});
}
_ => {}
}
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
self.prepared_destination = None;
self.restore_preview = None;
}
self.execute_sensitive(app, command)
}
}
fn operation_error(error: impl std::fmt::Display) -> OperationError {
OperationError {
message: error.to_string(),
}
}
pub(super) fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && stored == current
}
pub(super) fn restore_preview_generation_matches(
stored: u64,
requested: u64,
current: u64,
) -> bool {
stored == requested && current == requested.saturating_add(1)
}
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
+1206
View File
File diff suppressed because it is too large Load Diff
+4514
View File
File diff suppressed because it is too large Load Diff
+1104
View File
File diff suppressed because it is too large Load Diff
+1738
View File
File diff suppressed because it is too large Load Diff
+135 -7
View File
@@ -110,16 +110,25 @@ impl Drop for RecordPayload {
/// Password-authorized, short-lived client configuration data for later delivery.
pub struct RetrievedClientRecord {
private_key: Option<WireGuardPrivateKey>,
pub client_addresses: Vec<String>,
pub interface_dns: Option<String>,
pub interface_mtu: Option<String>,
pub server_public_key: Option<String>,
pub endpoint: Option<String>,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<String>,
provenance: RecordProvenance,
client_addresses: Vec<String>,
interface_dns: Option<String>,
interface_mtu: Option<String>,
server_public_key: Option<String>,
endpoint: Option<String>,
allowed_ips: Vec<String>,
persistent_keepalive: Option<String>,
preshared_key: Option<String>,
}
/// Provenance is assigned only after successful authenticated decryption and
/// validation of the retained field shape.
#[derive(Clone, Copy, Eq, PartialEq)]
pub(crate) enum RecordProvenance {
NewTemplate,
Imported,
}
impl RetrievedClientRecord {
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
self.private_key.as_ref()
@@ -130,9 +139,45 @@ impl RetrievedClientRecord {
self.preshared_key.as_deref()
}
pub fn client_addresses(&self) -> &[String] {
&self.client_addresses
}
pub fn interface_dns(&self) -> Option<&str> {
self.interface_dns.as_deref()
}
pub fn interface_mtu(&self) -> Option<&str> {
self.interface_mtu.as_deref()
}
pub fn server_public_key(&self) -> Option<&str> {
self.server_public_key.as_deref()
}
pub fn endpoint(&self) -> Option<&str> {
self.endpoint.as_deref()
}
pub fn allowed_ips(&self) -> &[String] {
&self.allowed_ips
}
pub fn persistent_keepalive(&self) -> Option<&str> {
self.persistent_keepalive.as_deref()
}
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
pub(crate) fn is_new_template(&self) -> bool {
self.provenance == RecordProvenance::NewTemplate
}
pub(crate) fn is_imported(&self) -> bool {
self.provenance == RecordProvenance::Imported
}
}
impl fmt::Debug for RetrievedClientRecord {
@@ -151,6 +196,38 @@ impl Drop for RetrievedClientRecord {
}
}
fn validated_record_provenance(payload: &RecordPayload) -> Result<RecordProvenance, VaultError> {
let key_only = payload.client_addresses.is_empty()
&& payload.interface_dns.is_none()
&& payload.interface_mtu.is_none()
&& payload.server_public_key.is_none()
&& payload.endpoint.is_none()
&& payload.allowed_ips.is_empty()
&& payload.persistent_keepalive.is_none()
&& payload.preshared_key.is_none();
if key_only {
return Ok(RecordProvenance::NewTemplate);
}
// Imported records always carry their source address and server identity.
// Missing either is a partial record, not permission to fall back to the
// new-client template.
if payload.client_addresses.is_empty()
|| payload.server_public_key.is_none()
|| payload
.client_addresses
.iter()
.any(|value| value.parse::<ipnet::IpNet>().is_err())
|| payload
.server_public_key
.as_deref()
.is_some_and(|value| value.parse::<PublicKey>().is_err())
{
return Err(VaultError::CorruptRecord);
}
Ok(RecordProvenance::Imported)
}
/// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the
/// password-protected identity only for the duration of that operation.
#[derive(Serialize, Deserialize)]
@@ -258,6 +335,14 @@ impl VaultBundle {
self.records.contains_key(&label.to_string())
}
/// Return the encrypted record bytes for session-local authorization binding.
/// The ciphertext is never exposed through the public vault API.
pub(crate) fn record_context(&self, label: PublicKey) -> Option<&str> {
self.records
.get(&label.to_string())
.map(|record| record.ciphertext.as_str())
}
/// Remove only the record identified by its canonical WireGuard public key.
/// The encrypted identity and every other record remain unchanged.
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
@@ -322,6 +407,7 @@ impl VaultBundle {
if payload.reissue_blocked {
return Err(VaultError::ReissueBlocked);
}
let provenance = validated_record_provenance(&payload)?;
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
.map_err(|_| VaultError::RecordMismatch)?;
if secret.public_key() != label {
@@ -329,6 +415,7 @@ impl VaultBundle {
}
Ok(RetrievedClientRecord {
private_key: Some(secret),
provenance,
client_addresses: payload.client_addresses.clone(),
interface_dns: payload.interface_dns.clone(),
interface_mtu: payload.interface_mtu.clone(),
@@ -679,6 +766,47 @@ mod tests {
);
}
#[test]
fn authenticated_retained_shape_never_falls_back_from_partial_import_to_new_template() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let identity = key.public_key();
bundle
.encrypt_payload(
identity,
RecordPayload {
version: 1,
label: identity.to_string(),
private_key: key.to_base64(),
client_addresses: vec!["10.0.0.2/32".to_owned()],
interface_dns: None,
interface_mtu: None,
server_public_key: None,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: None,
preshared_key: None,
reissue_blocked: false,
},
true,
)
.expect("malformed legacy record can be encrypted for regression test");
assert_eq!(
bundle.retrieve_client_record(identity, PASSWORD).err(),
Some(VaultError::CorruptRecord)
);
let clean_key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let mut clean_bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
clean_bundle
.encrypt_record(clean_key.public_key(), &clean_key)
.expect("key-only new-client record encrypts");
let retrieved = clean_bundle
.retrieve_client_record(clean_key.public_key(), PASSWORD)
.expect("fresh unlock validates key-only provenance");
assert!(retrieved.is_new_template());
}
#[test]
fn authenticated_record_with_unsupported_version_has_specific_error() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
+194 -2
View File
@@ -1,6 +1,6 @@
//! Bounded, read-only access to configured client source files.
use std::ffi::OsString;
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{Read, Take};
use std::path::{Path, PathBuf};
@@ -12,7 +12,7 @@ use thiserror::Error;
use crate::config::ResolvedSettings;
use crate::wireguard::ConfigDocument;
use super::import::{ImportReport, reconcile, reconcile_zeroizing};
use super::import::{CandidateStatus, ImportReport, reconcile, reconcile_zeroizing};
use super::{VaultBundle, VaultError};
use zeroize::Zeroizing;
@@ -68,12 +68,97 @@ pub struct PreparedImportSetup {
pub report: SourceImportReport,
}
/// Secret-free result from importing one explicitly selected source entry.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SelectedFileImportReport {
pub scan_complete: bool,
pub issues: Vec<SourceIssue>,
pub candidates: Vec<(String, CandidateStatus)>,
pub server_only: Vec<crate::domain::PublicKey>,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SourceImportError {
#[error("vault setup failed")]
Vault(#[from] VaultError),
#[error("source directory is invalid")]
InvalidDirectory,
#[error("selected source entry is invalid")]
InvalidSelection,
}
/// Read one explicitly selected immediate `*.conf` entry from the configured
/// source directory, reconcile it against the server document, and encrypt it
/// into the caller's draft bundle. The original entry is never modified.
pub fn import_selected_file(
bundle: &mut VaultBundle,
settings: &ResolvedSettings,
server: &ConfigDocument,
selected_name: &OsStr,
) -> Result<SelectedFileImportReport, SourceImportError> {
if !is_candidate_name(selected_name)
|| Path::new(selected_name).components().count() != 1
|| selected_name == OsStr::new(".")
|| selected_name == OsStr::new("..")
{
return Err(SourceImportError::InvalidSelection);
}
let display_name = safe_file_name(selected_name);
let empty_report = || reconcile(server, []);
let directory = match open_source_directory(&settings.client_source_directory) {
Ok(directory) => directory,
Err(_) => {
return Ok(selected_failure_report(
display_name,
SourceFailureKind::DirectoryUnavailable,
empty_report(),
));
}
};
let bytes = match read_candidate(&directory, selected_name, 0, MAX_SCAN_BYTES) {
Ok(bytes) => bytes,
Err(kind) => return Ok(selected_failure_report(display_name, kind, empty_report())),
};
let reconciliation = reconcile_zeroizing(server, [(display_name, bytes)]);
let summaries = reconciliation.candidate_summaries();
let mut issues = Vec::new();
if let Some((name, status)) = summaries.first()
&& !matches!(status, CandidateStatus::Invalid(_))
&& let Err(error) = reconciliation.encrypt_candidate(bundle, 0)
{
issues.push(SourceIssue {
file_name: Some((*name).to_owned()),
kind: match error {
VaultError::DuplicateRecord => SourceFailureKind::DuplicateRecordEqualityUnverified,
_ => SourceFailureKind::EncryptionFailed,
},
});
}
Ok(SelectedFileImportReport {
scan_complete: true,
issues,
candidates: summaries
.into_iter()
.map(|(name, status)| (name.to_owned(), status))
.collect(),
server_only: reconciliation.server_only().to_vec(),
})
}
fn selected_failure_report(
display_name: String,
kind: SourceFailureKind,
reconciliation: ImportReport,
) -> SelectedFileImportReport {
SelectedFileImportReport {
scan_complete: false,
issues: vec![SourceIssue {
file_name: Some(display_name),
kind,
}],
candidates: Vec::new(),
server_only: reconciliation.server_only().to_vec(),
}
}
/// Create a new password-protected vault and encrypt valid candidates from the
@@ -372,6 +457,113 @@ mod tests {
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
fn resolved_settings(source: &Path) -> ResolvedSettings {
crate::config::SettingsFile::parse_toml(
r#"interface_name = "wg0"
server_config_path = "wg0.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.org:51820"
ipv4_allocation_range = "10.8.0.0/24"
ipv6_allocation_range = "fd80::/64"
dns_addresses = ["10.8.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 10
"#,
)
.unwrap()
.validate()
.unwrap()
.resolve_paths(source)
}
#[test]
fn selected_file_import_is_bounded_read_only_and_stages_encryption() {
let root = tempfile::tempdir().unwrap();
let source_directory = root.path().join("clients");
fs::create_dir(&source_directory).unwrap();
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
server_key.to_base64(),
client_key.public_key()
)
.as_bytes(),
)
.unwrap();
let original = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.org:51820\n",
client_key.to_base64(),
server_key.public_key()
);
let selected_path = source_directory.join("client.conf");
fs::write(&selected_path, original.as_bytes()).unwrap();
make_private(&selected_path);
let mut bundle = VaultBundle::create("synthetic password").unwrap();
let report = import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("client.conf"),
)
.unwrap();
assert!(report.scan_complete);
assert!(report.issues.is_empty());
assert_eq!(bundle.record_count(), 1);
assert!(matches!(
report.candidates[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: super::super::import::ServerIdentityComparison::Matches,
..
}
));
assert_eq!(fs::read(&selected_path).unwrap(), original.as_bytes());
let duplicate = import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("client.conf"),
)
.unwrap();
assert_eq!(
duplicate.issues[0].kind,
SourceFailureKind::DuplicateRecordEqualityUnverified
);
assert_eq!(bundle.record_count(), 1);
assert!(
import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("../client.conf")
)
.is_err()
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&selected_path, source_directory.join("linked.conf"))
.unwrap();
let link_report = import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("linked.conf"),
)
.unwrap();
assert!(!link_report.scan_complete);
assert_eq!(
link_report.issues[0].kind,
SourceFailureKind::FileUnavailable
);
assert_eq!(bundle.record_count(), 1);
}
}
#[test]
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
let directory = tempfile::tempdir().unwrap();
+69
View File
@@ -113,6 +113,10 @@ pub struct PeerInspection {
pub public_key_fields: Vec<DirectiveSpan>,
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
pub allowed_ips: Vec<IpNet>,
/// True when a valid parsed route is a subnet rather than a host route.
/// This is a capability limitation for the current client model, not a
/// malformed configuration issue.
pub has_subnet_routes: bool,
pub allowed_ips_fields: Vec<DirectiveSpan>,
/// Parsed alias and safe owned block span, or an explicit metadata issue.
pub metadata: MetadataAttachment,
@@ -346,11 +350,16 @@ impl ConfigDocument {
let mut allowed_ips_fields = Vec::new();
let mut has_ipv4_host_route = false;
let mut has_ipv6_host_route = false;
let mut has_subnet_routes = false;
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
allowed_ips_fields.push(directive);
for (item, result) in items {
match result {
Ok(net) => {
has_subnet_routes |= match net {
IpNet::V4(network) => network.prefix_len() < 32,
IpNet::V6(network) => network.prefix_len() < 128,
};
let supported = match net {
IpNet::V4(network) if network.prefix_len() == 32 => {
let first = !has_ipv4_host_route;
@@ -385,6 +394,7 @@ impl ConfigDocument {
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
public_key_fields,
allowed_ips,
has_subnet_routes,
allowed_ips_fields,
metadata: MetadataAttachment::Absent,
issues: peer_issues,
@@ -457,6 +467,45 @@ impl ConfigDocument {
&self.issues
}
/// Whether any interface section enables or ambiguously configures
/// wg-quick SaveConfig. Restarting such a service can write live peer state
/// back over the saved configuration during shutdown.
pub fn has_unsafe_save_config(&self) -> bool {
let mut found = false;
for section in self
.sections
.iter()
.filter(|section| section.kind == SectionKind::Interface)
{
let Some(source) = self.source_slice(section.span) else {
return true;
};
let mut in_interface = false;
for line in source.lines() {
let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace());
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_interface = parse_section_name(trimmed)
.is_some_and(|name| name.eq_ignore_ascii_case("Interface"));
continue;
}
if !in_interface || trimmed.is_empty() || trimmed.starts_with(['#', ';']) {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
if !key.trim().eq_ignore_ascii_case("SaveConfig") {
continue;
}
if found || !value.trim().eq_ignore_ascii_case("false") {
return true;
}
found = true;
}
}
false
}
/// A raw source slice for an explicitly selected span. Callers must treat it as secret-bearing.
/// This narrow primitive supports later edit code that owns and replaces exact spans.
pub fn source_slice(&self, span: SourceSpan) -> Option<&str> {
@@ -491,6 +540,26 @@ mod tests {
}
}
#[test]
fn save_config_detection_is_interface_scoped_and_fails_closed_on_ambiguity() {
let disabled =
ConfigDocument::parse(b"[Interface]\nSaveConfig = false\n[Peer]\nSaveConfig = true\n")
.unwrap();
assert!(!disabled.has_unsafe_save_config());
for input in [
b"[Interface]\nSaveConfig = true\n".as_slice(),
b"[Interface]\nSaveConfig = maybe\n".as_slice(),
b"[Interface]\nSaveConfig = false\nSaveConfig = false\n".as_slice(),
] {
assert!(
ConfigDocument::parse(input)
.unwrap()
.has_unsafe_save_config()
);
}
}
#[test]
fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() {
let input = format!(
+409
View File
@@ -0,0 +1,409 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use wireguard_admin::app::{
Application, FirstIssueAttempt, FirstIssueDeliveryOutcome, ReissueFailureKind, ReissueStage,
};
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::delivery::files::FileOutputError;
use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot};
use wireguard_admin::persistence::SavedStore;
use wireguard_admin::vault::import::{parse_client_config, reconcile};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
use wireguard_admin::wireguard::ConfigDocument;
const SETTINGS: &str = r#"
interface_name = "wg0"
server_config_path = "server.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.test:51820"
ipv4_allocation_range = "10.20.0.0/24"
ipv6_allocation_range = "fd20::/64"
dns_addresses = ["10.20.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
"#;
struct Fixture {
root: tempfile::TempDir,
settings: ResolvedSettings,
store: SavedStore,
states: ClientStates,
}
fn fixture(server: String, states: ClientStates) -> Fixture {
let root = tempfile::tempdir().expect("private temporary fixture");
let settings = SettingsFile::parse_toml(SETTINGS)
.expect("fixture settings parse")
.validate()
.expect("fixture settings validate")
.resolve_paths(root.path());
fs::create_dir_all(&settings.client_source_directory).expect("source directory");
fs::write(ResolvedSettings::settings_path(root.path()), SETTINGS).expect("settings file");
fs::write(&settings.server_config_path, server).expect("server configuration");
fs::set_permissions(
&settings.server_config_path,
fs::Permissions::from_mode(0o600),
)
.expect("private server configuration");
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
Fixture {
root,
settings,
store,
states,
}
}
fn empty_states() -> ClientStates {
ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty state")
}
fn open(fixture: &Fixture) -> Application {
Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens")
}
fn new_client_fixture() -> (Fixture, Application, wireguard_admin::domain::PublicKey) {
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n",
server_key.to_base64()
);
let fixture = fixture(server, empty_states());
let mut app = open(&fixture);
app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault"))
.expect("stage vault");
app.commit().expect("install vault");
let identity = app
.prepare_new_client("same alias", None, None)
.expect("prepare client");
app.commit().expect("commit client");
(fixture, app, identity)
}
fn destination(
app: &Application,
path: impl AsRef<Path>,
) -> wireguard_admin::delivery::files::PreparedDestination {
app.prepare_first_issue_destination(path)
.expect("prepare selected destination")
}
fn archive_name(path: &Path) -> &str {
path.file_name()
.and_then(|name| name.to_str())
.expect("synthetic archive filename")
}
#[test]
fn committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent() {
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n",
server_key.to_base64()
);
let fixture = fixture(server, empty_states());
let mut app = open(&fixture);
app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault"))
.expect("stage vault");
app.commit().expect("install vault");
let first = app
.prepare_new_client("duplicate", None, None)
.expect("first client");
let second = app
.prepare_new_client("duplicate", None, None)
.expect("second client");
app.commit().expect("commit clients");
let existing = fixture.root.path().join("occupied.conf");
fs::write(&existing, b"existing output").expect("existing regular file");
let declined = app
.deliver_first_issue_file(
first,
FirstIssueAttempt::Initial,
destination(&app, &existing),
)
.expect("decline reported as an operation result");
assert!(matches!(
declined,
FirstIssueDeliveryOutcome::Failed {
error: FileOutputError::OverwriteDeclined,
..
}
));
let retry_path = fixture.root.path().join("new-dir/first.conf");
let retried = app
.deliver_first_issue_file(
first,
FirstIssueAttempt::Retry {
confirm_possible_overwrite: false,
confirm_manual_cleanup: false,
},
destination(&app, &retry_path),
)
.expect("retry remains password free in creation session");
assert!(matches!(
retried,
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
));
assert_eq!(
fs::metadata(&retry_path)
.expect("delivered file")
.permissions()
.mode()
& 0o777,
0o600
);
let second_path = fixture.root.path().join("second.conf");
let delivered_second = app
.deliver_first_issue_file(
second,
FirstIssueAttempt::Initial,
destination(&app, second_path),
)
.expect("second key has independent eligibility");
assert!(matches!(
delivered_second,
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
));
}
#[test]
fn reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen() {
let (fixture, mut app, identity) = new_client_fixture();
let initial_path = fixture.root.path().join("initial.conf");
let issued = app
.deliver_first_issue_file(
identity,
FirstIssueAttempt::Initial,
destination(&app, &initial_path),
)
.expect("first file issued");
assert!(matches!(
issued,
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
));
// A draft rename must not change the saved configuration used by reissue.
app.rename_client(identity, "unsaved draft alias")
.expect("prepare unrelated draft rename");
let wrong_path = fixture.root.path().join("wrong-password.conf");
let wrong = app.reissue_client_file(
identity,
"wrong password",
destination(&app, &wrong_path),
false,
);
let error = wrong.expect_err("wrong password rejected");
assert_eq!(error.stage, ReissueStage::Authorization);
assert_eq!(error.kind, ReissueFailureKind::PasswordOrRecordRejected);
assert!(!wrong_path.exists());
let output = fixture.root.path().join("reissue/client.conf");
let result = app
.reissue_client_file(
identity,
"fixture vault password",
destination(&app, &output),
true,
)
.expect("fresh password authorizes reissue");
assert!(result.activation_warning);
assert!(result.qr.is_some_and(|qr| qr.is_ok()));
let bytes = fs::read(&output).expect("mandatory reissue file");
let parsed = parse_client_config(&bytes).expect("rendered file parses");
assert_eq!(parsed.identity, identity);
assert_eq!(
fs::metadata(&output)
.expect("private output")
.permissions()
.mode()
& 0o777,
0o600
);
let next_action_path = fixture.root.path().join("next-action.conf");
// A successful action does not preserve an unlocked identity for the next
// action in the same session.
let next_action = app.reissue_client_file(
identity,
"wrong password",
destination(&app, next_action_path),
false,
);
assert_eq!(
next_action
.expect_err("previous action does not cache an unlocked identity")
.kind,
ReissueFailureKind::PasswordOrRecordRejected
);
}
#[test]
fn imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue() {
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let client_key = WireGuardPrivateKey::generate().expect("synthetic client key");
let identity = client_key.public_key();
let server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {identity}\nAllowedIPs = 10.20.0.2/32\n",
server_key.to_base64()
);
let client = Client::new(
identity,
"imported",
ClientAddresses {
ipv4: "10.20.0.2".parse().expect("IPv4"),
ipv6: None,
},
)
.expect("IPv4-only domain client");
let snapshot = Snapshot::new([client]).expect("snapshot");
let import_fixture = fixture(
server.clone(),
ClientStates::new(snapshot, None, ApplyStatus::Unknown).expect("client state"),
);
let document = ConfigDocument::parse(server.as_bytes()).expect("server document");
let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key");
let source = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 1.1.1.1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = imported.example.test:12345\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n",
client_key.to_base64(),
server_key.public_key(),
psk.to_base64()
);
let report = reconcile(&document, [("client.conf".to_owned(), source.into_bytes())]);
let mut bundle = VaultBundle::create("fixture vault password").expect("vault");
report
.encrypt_candidate(&mut bundle, 0)
.expect("encrypt matching import");
let mut app = open(&import_fixture);
app.prepare_initial_vault(bundle)
.expect("stage imported vault");
app.commit().expect("commit imported vault");
let output = import_fixture.root.path().join("imported.conf");
app.reissue_client_file(
identity,
"fixture vault password",
destination(&app, &output),
false,
)
.expect("imported record reissues");
let rendered = fs::read_to_string(&output).expect("rendered imported file");
assert!(rendered.contains("Address = 10.20.0.2/32\n"));
assert!(rendered.contains("DNS = 1.1.1.1\n"));
assert!(rendered.contains("MTU = 1380\n"));
assert!(rendered.contains("Endpoint = imported.example.test:12345\n"));
assert!(rendered.contains("AllowedIPs = 0.0.0.0/0\n"));
assert!(rendered.contains("PersistentKeepalive = 25\n"));
assert!(rendered.contains("PresharedKey = "));
assert!(!rendered.contains("Address = 10.20.0.2/32, fd20::"));
// A saved peer with a mismatching retained address is authenticated as
// blocked during import; an edited public status bit cannot authorize it.
let conflict_key = WireGuardPrivateKey::generate().expect("second synthetic key");
let blocked_server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.9/32\n",
server_key.to_base64(),
conflict_key.public_key()
);
let blocked_document = ConfigDocument::parse(blocked_server.as_bytes()).expect("server");
let blocked_source = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0\n",
conflict_key.to_base64(),
server_key.public_key()
);
let blocked_report = reconcile(
&blocked_document,
[("blocked.conf".to_owned(), blocked_source.into_bytes())],
);
let mut blocked_bundle = VaultBundle::create("fixture vault password").expect("vault");
blocked_report
.encrypt_candidate(&mut blocked_bundle, 0)
.expect("encrypt blocked candidate");
let blocked_fixture = fixture(blocked_server, empty_states());
let mut blocked_app = open(&blocked_fixture);
blocked_app
.prepare_initial_vault(blocked_bundle)
.expect("stage blocked vault");
blocked_app.commit().expect("commit blocked record");
let blocked_path = blocked_fixture.root.path().join("blocked.conf");
let blocked = blocked_app.reissue_client_file(
conflict_key.public_key(),
"fixture vault password",
destination(&blocked_app, blocked_path),
false,
);
assert_eq!(
blocked
.expect_err("authenticated discrepancy blocks reissue")
.kind,
ReissueFailureKind::PasswordOrRecordRejected
);
}
#[test]
fn deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password() {
let (fixture, mut app, identity) = new_client_fixture();
let before_delete_archive = app.create_manual_backup().expect("saved client archive");
assert_eq!(
fs::metadata(&fixture.settings.server_config_path)
.expect("server remains installed")
.permissions()
.mode()
& 0o777,
0o600
);
assert!(app.delete_client(identity).expect("delete client"));
app.commit().expect("commit deletion");
let deleted = app.reissue_client_file(
identity,
"fixture vault password",
destination(&app, fixture.root.path().join("deleted.conf")),
false,
);
assert!(deleted.is_err());
assert!(!fixture.root.path().join("deleted.conf").exists());
let preview = app
.preview_restore(archive_name(&before_delete_archive))
.expect("preview prior saved client");
app.confirm_restore(preview)
.expect("restore saved client only");
let reopened = open(&fixture);
let restored_path = fixture.root.path().join("restored.conf");
let restored = reopened
.reissue_client_file(
identity,
"fixture vault password",
destination(&reopened, &restored_path),
false,
)
.expect("restored encrypted record requires and accepts password");
assert!(restored.activation_warning);
assert!(restored_path.exists());
}
#[test]
fn overwrite_confirmation_is_bound_to_the_selected_destination() {
let (fixture, app, identity) = new_client_fixture();
let path: PathBuf = fixture.root.path().join("output.conf");
fs::write(&path, b"replace only after confirmation").expect("existing file");
let prepared = destination(&app, &path);
let declined = app.reissue_client_file(identity, "fixture vault password", prepared, false);
assert!(matches!(
declined,
Err(error) if error.stage == ReissueStage::FilePublication
&& error.kind == ReissueFailureKind::FileOutput(FileOutputError::OverwriteDeclined)
));
let confirmed = destination(&app, &path)
.confirm_overwrite()
.expect("confirm exact destination");
app.reissue_client_file(identity, "fixture vault password", confirmed, false)
.expect("confirmed reissue replaces destination");
let parsed = parse_client_config(&fs::read(&path).expect("replacement output"))
.expect("replacement is valid config");
assert_eq!(parsed.identity, identity);
}
+6 -10
View File
@@ -6,7 +6,7 @@ use wireguard_admin::app::{AppError, Application};
use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes};
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::{
Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot,
ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot,
};
use wireguard_admin::persistence::{PersistedState, SavedStore};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
@@ -75,12 +75,8 @@ fn fixture(with_client: bool) -> Fixture {
)
.expect("server fixture is private");
let snapshot = Snapshot::new(clients).expect("initial snapshot");
let states = ClientStates::new(
snapshot.clone(),
Some(snapshot),
RuntimeObservation::Verified,
)
.expect("initial states");
let states = ClientStates::new(snapshot.clone(), Some(snapshot), ApplyStatus::Verified)
.expect("initial states");
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
Fixture {
root,
@@ -240,7 +236,7 @@ fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes(
.expect("new client");
app.add_client(client, None).expect("stage client");
app.commit().expect("save client");
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
let preview = app
.preview_restore(archive_name(&empty_archive))
@@ -264,7 +260,7 @@ fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes(
.contains(&"fd20::9".to_owned())
);
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
assert!(
app.states()
.reserved_addresses()
@@ -520,5 +516,5 @@ fn deletion_uses_public_key_and_removes_only_that_vault_record() {
.expect("vault parses");
assert!(!bundle.contains_record(fixture.key));
assert!(bundle.contains_record(retained_key));
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
}
+39
View File
@@ -372,6 +372,45 @@ fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection
}
}
#[test]
fn valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety() {
let source = format!(
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24\n"
);
let document = ConfigDocument::parse(&source).unwrap();
let peer = &document.peers()[0];
assert!(peer.has_subnet_routes);
assert_eq!(peer.allowed_ips.len(), 3);
assert!(peer.issues.iter().any(|issue| matches!(
issue,
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }
)));
assert_eq!(document.render(), source.as_bytes());
assert_eq!(
document.project_client(&key(KEY_A)),
Err(EditError::UnsupportedAddresses)
);
let renamed = document
.rename_client(&key(KEY_A), "alias-only edit remains byte-preserving")
.expect("safe alias-only edit remains supported");
assert!(
String::from_utf8_lossy(renamed.render())
.contains("AllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24")
);
let malformed = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, not-an-address\n"
);
let mixed = ConfigDocument::parse(&malformed).unwrap();
assert!(mixed.peers()[0].has_subnet_routes);
assert!(
mixed.peers()[0]
.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }))
);
}
#[test]
fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() {
let document = ConfigDocument::parse(FIXTURE).unwrap();
+5
View File
@@ -0,0 +1,5 @@
**
!Dockerfile
!.dockerignore
!runtime-check.sh
!packages.lock
+19
View File
@@ -0,0 +1,19 @@
# linux/amd64 platform manifest for the official Alpine 3.22.7 image.
FROM --platform=linux/amd64 alpine:3.22.7@sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec
LABEL org.opencontainers.image.title="WireGuard Admin Alpine integration fixture" \
org.opencontainers.image.version="3.22.7" \
org.opencontainers.image.base.name="docker.io/library/alpine:3.22.7" \
org.opencontainers.image.base.digest="sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec"
COPY packages.lock /tmp/alpine-packages.lock
RUN apk add --no-cache $(sed '/^#/d' /tmp/alpine-packages.lock) \
&& mkdir -p /usr/local/share/wireguard-admin \
&& install -m 0755 /tmp/alpine-packages.lock /usr/local/share/wireguard-admin/alpine-packages.lock \
&& rm /tmp/alpine-packages.lock
COPY runtime-check.sh /usr/local/bin/wireguard-admin-alpine-check
RUN chmod 0755 /usr/local/bin/wireguard-admin-alpine-check \
&& /usr/local/bin/wireguard-admin-alpine-check
CMD ["/bin/sh"]
+37
View File
@@ -0,0 +1,37 @@
# Complete installed package closure from the selected-daemon Alpine 3.22.7
# linux/amd64 image build on 2026-10-22. Dockerfile treats every entry as an
# exact apk constraint. Rebuilds fail if this repository no longer serves the
# recorded versions rather than silently choosing a different closure.
alpine-baselayout=3.7.0-r0
alpine-baselayout-data=3.7.0-r0
alpine-keys=2.5-r0
alpine-release=3.22.7-r0
apk-tools=2.14.12-r0
bash=5.2.37-r0
busybox=1.37.0-r20
busybox-binsh=1.37.0-r20
ca-certificates-bundle=20261020-r0
iproute2=6.15.0-r0
iproute2-minimal=6.15.0-r0
iproute2-ss=6.15.0-r0
iproute2-tc=6.15.0-r0
libapk2=2.14.12-r0
libcap2=2.78-r0
libcrypto3=3.5.9-r0
libelf=0.193-r0
libmnl=1.0.5-r2
libncursesw=6.5_p20250503-r0
libssl3=3.5.9-r0
libxtables=1.8.11-r1
musl=1.2.5-r13
musl-utils=1.2.5-r13
ncurses-terminfo-base=6.5_p20250503-r0
openresolv=3.14.1-r0
readline=8.2.13-r1
scanelf=1.3.8-r1
ssl_client=1.37.0-r20
wireguard-tools=1.0.20250521-r0
wireguard-tools-wg=1.0.20250521-r0
wireguard-tools-wg-quick=1.0.20250521-r0
zlib=1.3.2-r1
zstd-libs=1.5.7-r0
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
command -v wg >/dev/null
command -v ip >/dev/null
command -v busybox >/dev/null
wg --version >/dev/null
ip -Version >/dev/null
ip link show lo >/dev/null
if [ "${1:-}" = "--driver" ]; then
shift
[ "$#" -eq 1 ]
[ -x "$1" ]
# M8.4 provides this ignored cfg(test) entry point. Keep the selection
# exact so no other ignored test can run in the fixture image.
"$1" --ignored --exact container_driver::alpine_readiness --test-threads=1
elif [ "$#" -ne 0 ]; then
echo "usage: wireguard-admin-alpine-check [--driver PATH]" >&2
exit 2
fi
+182
View File
@@ -0,0 +1,182 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use wireguard_admin::app::{Application, RestartChoice, RestartStage};
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot};
use wireguard_admin::persistence::SavedStore;
use wireguard_admin::vault::WireGuardPrivateKey;
fn settings(service: &str, timeout: u64) -> String {
format!(
r#"
interface_name = "wg0"
server_config_path = "server.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.test:51820"
ipv4_allocation_range = "10.20.0.0/24"
ipv6_allocation_range = "fd20::/64"
dns_addresses = ["10.20.0.1"]
systemd_unit = "{service}"
restart_timeout_seconds = {timeout}
"#
)
}
struct Fixture {
_root: tempfile::TempDir,
store: SavedStore,
initial_settings: String,
}
fn fixture(service: &str, timeout: u64) -> Fixture {
let root = tempfile::tempdir().expect("temporary saved store");
let initial_settings = settings(service, timeout);
let settings = SettingsFile::parse_toml(&initial_settings)
.expect("synthetic settings parse")
.validate()
.expect("synthetic settings validate")
.resolve_paths(root.path());
fs::create_dir_all(&settings.client_source_directory).expect("client directory");
fs::create_dir_all(&settings.data_directory).expect("data directory");
fs::create_dir_all(&settings.archives_directory).expect("archive directory");
fs::write(
ResolvedSettings::settings_path(root.path()),
initial_settings.as_bytes(),
)
.expect("install synthetic settings");
fs::write(
&settings.server_config_path,
b"[Interface]\nPrivateKey = synthetic-server-key\nAddress = 10.20.0.1/24\n",
)
.expect("install synthetic server configuration");
fs::set_permissions(
&settings.server_config_path,
fs::Permissions::from_mode(0o600),
)
.expect("restrict synthetic server configuration");
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store mapping");
Fixture {
_root: root,
store,
initial_settings,
}
}
fn open(fixture: &Fixture) -> Application {
let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown)
.expect("empty saved states");
Application::open(fixture.store.clone(), states).expect("open synthetic application")
}
fn archive_name(path: &Path) -> &str {
path.file_name()
.and_then(|name| name.to_str())
.expect("archive filename")
}
#[test]
fn public_restart_preview_and_cancel_preserve_draft_and_saved_files() {
let fixture = fixture("wg-quick@wg0.service", 30);
let mut app = open(&fixture);
let original_server_config = app.saved_bytes().server_config.clone();
let secret = WireGuardPrivateKey::generate().expect("synthetic client key");
let client = Client::new(
secret.public_key(),
"draft client",
ClientAddresses {
ipv4: "10.20.0.2".parse().expect("synthetic IPv4"),
ipv6: Some("fd20::2".parse().expect("synthetic IPv6")),
},
)
.expect("synthetic client");
app.add_client(client.clone(), None)
.expect("stage client draft");
let preview = app.preview_restart().expect("preview saved generation");
assert!(preview.has_draft());
assert_eq!(preview.interface(), "wg0");
assert_eq!(preview.systemd_unit(), "wg-quick@wg0.service");
assert!(app.list_backups().expect("list archives").is_empty());
// The public cancellation path returns before either real host adapter can run.
let report = app.restart(preview, RestartChoice::Cancel);
assert_eq!(report.stage, RestartStage::Cancelled);
assert!(!report.restart_submitted);
assert_eq!(app.saved_bytes().server_config, original_server_config);
assert!(!app.document().render().is_empty());
assert_eq!(
app.states()
.draft()
.get(&client.identity)
.map(|saved| saved.alias.as_str()),
Some("draft client")
);
assert!(
app.list_backups()
.expect("no cancellation backup")
.is_empty()
);
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
}
#[test]
fn public_restart_preview_accepts_metadata_free_ipv4_and_broader_legacy_routes() {
let fixture = fixture("wg-quick@wg0.service", 30);
let server_path = fixture._root.path().join("server.conf");
let mut server = fs::read(&server_path).expect("synthetic server config reads");
server.extend_from_slice(
b"\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.20.0.19/32\n\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 192.0.2.0/24, 2001:db8::/48\n",
);
fs::write(&server_path, &server).expect("install synthetic legacy peers");
let app = Application::open_from_saved_store(fixture.store.clone())
.expect("open metadata-free legacy peers");
let preview = app.preview_restart().expect("valid routes are restartable");
assert_eq!(preview.interface(), "wg0");
assert_eq!(preview.systemd_unit(), "wg-quick@wg0.service");
assert_eq!(
fs::read(server_path).expect("server config remains readable"),
server
);
assert!(app.list_backups().expect("preview is read-only").is_empty());
}
#[test]
fn restore_changes_the_settings_used_by_the_next_restart_preview() {
let fixture = fixture("wg-quick@wg0.service", 30);
let app = open(&fixture);
let archive = app
.create_manual_backup()
.expect("archive original saved settings");
let replacement = settings("wg-quick@wg9.service", 90);
fs::write(
ResolvedSettings::settings_path(fixture._root.path()),
replacement.as_bytes(),
)
.expect("install a second synthetic saved settings generation");
drop(app);
let mut app = open(&fixture);
let preview = app
.preview_restore(archive_name(&archive))
.expect("preview archived saved settings");
app.confirm_restore(preview)
.expect("restore archived saved settings");
assert_eq!(
app.saved_bytes().settings,
fixture.initial_settings.as_bytes()
);
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
let restart = app
.preview_restart()
.expect("preview restored generation without invoking adapters");
assert_eq!(restart.interface(), "wg0");
assert_eq!(restart.systemd_unit(), "wg-quick@wg0.service");
assert!(!restart.has_draft());
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
use std::{
io::Read,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
const COMMAND_DEADLINE: Duration = Duration::from_secs(15);
const OUTPUT_LIMIT: usize = 4096;
pub fn docker_text(arguments: &[&str]) -> Result<String, &'static str> {
docker_text_with_timeout(arguments, COMMAND_DEADLINE)
}
pub fn docker_text_with_timeout(
arguments: &[&str],
command_deadline: Duration,
) -> Result<String, &'static str> {
docker_text_with_timeout_and_limits(arguments, command_deadline, OUTPUT_LIMIT, OUTPUT_LIMIT)
}
pub fn docker_text_with_timeout_and_limits(
arguments: &[&str],
command_deadline: Duration,
stdout_limit: usize,
stderr_limit: usize,
) -> Result<String, &'static str> {
let mut child = Command::new("docker")
.args(arguments)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|_| "Docker CLI could not be started")?;
let stdout = child.stdout.take().ok_or("Docker stdout unavailable")?;
let stderr = child.stderr.take().ok_or("Docker stderr unavailable")?;
let stdout_reader = thread::spawn(move || read_capped(stdout, stdout_limit));
let stderr_reader = thread::spawn(move || read_capped(stderr, stderr_limit));
let deadline = Instant::now() + command_deadline;
let status = loop {
match child.try_wait().map_err(|_| "Docker command wait failed")? {
Some(status) => break status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err("Docker command timed out");
}
None => thread::sleep(Duration::from_millis(20)),
}
};
let (stdout, stdout_exceeded) = stdout_reader
.join()
.map_err(|_| "Docker stdout reader failed")?
.map_err(|_| "Docker stdout read failed")?;
let (stderr, stderr_exceeded) = stderr_reader
.join()
.map_err(|_| "Docker stderr reader failed")?
.map_err(|_| "Docker stderr read failed")?;
if !status.success() {
return Err(classify_docker_failure(status.code(), &stderr));
}
if stdout_exceeded || stderr_exceeded {
return Err("Docker command output exceeded its limit");
}
String::from_utf8(stdout).map_err(|_| "Docker returned non-UTF-8 output")
}
fn classify_docker_error(stderr: &[u8]) -> &'static str {
let diagnostic = String::from_utf8_lossy(stderr).to_ascii_lowercase();
if diagnostic.contains("table") && diagnostic.contains("does not exist")
|| diagnostic.contains("no chain/target/match")
{
return "Docker host lacks required bridge networking support";
}
if diagnostic.contains("overlap") {
return "Docker rejected a test subnet because it overlaps an existing network";
}
if diagnostic.contains("subnet") || diagnostic.contains("ipv4") {
return "Docker rejected the IPv4 network parameters";
}
if diagnostic.contains("pool") || diagnostic.contains("address space") {
return "Docker rejected the test network address pool";
}
if diagnostic.contains("already exists") {
return "Docker reported a conflicting owned resource name";
}
if diagnostic.contains("no such container")
|| diagnostic.contains("no such network")
|| diagnostic.contains("no such object")
|| diagnostic.contains(" not found")
{
return "Docker named resource was not found";
}
if diagnostic.contains("permission denied")
|| diagnostic.contains("forbidden")
|| diagnostic.contains("operation not permitted")
{
return "Docker API denied the requested operation";
}
"Docker command returned a nonzero status"
}
fn classify_docker_failure(status_code: Option<i32>, stderr: &[u8]) -> &'static str {
if matches!(status_code, Some(124 | 137 | 143)) {
return "In-container fixture command timed out";
}
classify_docker_error(stderr)
}
fn read_capped(mut reader: impl Read, limit: usize) -> std::io::Result<(Vec<u8>, bool)> {
let mut captured = Vec::new();
let mut buffer = [0_u8; 1024];
let mut exceeded = false;
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
return Ok((captured, exceeded));
}
let remaining = limit.saturating_sub(captured.len());
captured.extend_from_slice(&buffer[..count.min(remaining)]);
exceeded |= count > remaining;
}
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::{OUTPUT_LIMIT, classify_docker_error, classify_docker_failure, read_capped};
#[test]
fn output_capture_is_capped_while_the_pipe_is_drained() {
let input = vec![b'x'; OUTPUT_LIMIT + 17];
let (result, exceeded) =
read_capped(Cursor::new(input), OUTPUT_LIMIT).expect("read test bytes");
assert_eq!(result.len(), OUTPUT_LIMIT);
assert!(exceeded);
}
#[test]
fn docker_errors_are_reported_as_fixed_redacted_categories() {
assert_eq!(
classify_docker_error(b"iptables: Table does not exist; /etc/wireguard/private.conf"),
"Docker host lacks required bridge networking support"
);
assert_eq!(
classify_docker_error(b"Pool overlaps with another subnet"),
"Docker rejected a test subnet because it overlaps an existing network"
);
assert_eq!(
classify_docker_error(b"Error: No such object: wga-m83-net-test"),
"Docker named resource was not found"
);
assert_eq!(
classify_docker_error(b"network wga-m83-net-test not found"),
"Docker named resource was not found"
);
}
#[test]
fn busybox_timeout_exit_status_is_classified_without_stderr() {
for status_code in [124, 137, 143] {
assert_eq!(
classify_docker_failure(Some(status_code), b""),
"In-container fixture command timed out"
);
}
assert_eq!(
classify_docker_failure(Some(2), b""),
"Docker command returned a nonzero status"
);
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::{env, path::Path};
const OPT_IN: &str = "WIREGUARD_ADMIN_TESTCONTAINERS";
const SELECTED_ENDPOINT: &str = "WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT";
const SELECTED_ID: &str = "WIREGUARD_ADMIN_TEST_DAEMON_ID";
#[derive(Clone, Debug)]
pub struct Selection {
pub endpoint: String,
pub daemon_id: String,
}
impl Selection {
pub fn from_process_environment() -> Result<Self, &'static str> {
Self::from_values(
env::var(OPT_IN).ok().as_deref(),
env::var(SELECTED_ENDPOINT).ok().as_deref(),
env::var(SELECTED_ID).ok().as_deref(),
env::var("DOCKER_HOST").ok().as_deref(),
env::var("TESTCONTAINERS_COMMAND").ok().as_deref(),
)
}
fn from_values(
opt_in: Option<&str>,
endpoint: Option<&str>,
daemon_id: Option<&str>,
docker_host: Option<&str>,
testcontainers_command: Option<&str>,
) -> Result<Self, &'static str> {
if opt_in != Some("1") {
return Err("explicit opt-in is missing");
}
if testcontainers_command == Some("keep") {
return Err("Testcontainers keep mode is forbidden");
}
let endpoint = endpoint.ok_or("selected daemon endpoint is missing")?;
if !endpoint.starts_with("unix:///")
|| endpoint.contains(['\n', '\r', '\0'])
|| !Path::new(endpoint.trim_start_matches("unix://")).is_absolute()
{
return Err("only an explicitly selected local Unix socket is supported");
}
if docker_host != Some(endpoint) {
return Err("DOCKER_HOST must exactly match the selected endpoint");
}
let daemon_id = daemon_id.ok_or("selected daemon ID is missing")?;
if daemon_id.is_empty()
|| daemon_id.len() > 128
|| !daemon_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err("selected daemon ID has invalid syntax");
}
Ok(Self {
endpoint: endpoint.to_owned(),
daemon_id: daemon_id.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::Selection;
const ENDPOINT: &str = "unix:///var/run/docker.sock";
const ID: &str = "synthetic-daemon-id";
fn selection(
opt_in: Option<&str>,
endpoint: Option<&str>,
id: Option<&str>,
docker_host: Option<&str>,
keep: Option<&str>,
) -> Result<Selection, &'static str> {
Selection::from_values(opt_in, endpoint, id, docker_host, keep)
}
#[test]
fn requires_explicit_opt_in_endpoint_and_matching_current_daemon_identity() {
assert!(selection(None, Some(ENDPOINT), Some(ID), Some(ENDPOINT), None).is_err());
assert!(selection(Some("1"), None, Some(ID), Some(ENDPOINT), None).is_err());
assert!(selection(Some("1"), Some(ENDPOINT), None, Some(ENDPOINT), None).is_err());
assert!(
selection(
Some("1"),
Some(ENDPOINT),
Some(ID),
Some("tcp://remote:2376"),
None
)
.is_err()
);
assert!(
selection(
Some("1"),
Some("tcp://remote:2376"),
Some(ID),
Some("tcp://remote:2376"),
None
)
.is_err()
);
assert!(
selection(
Some("1"),
Some(ENDPOINT),
Some(ID),
Some(ENDPOINT),
Some("keep")
)
.is_err()
);
assert_eq!(
selection(Some("1"), Some(ENDPOINT), Some(ID), Some(ENDPOINT), None)
.expect("valid explicit local daemon")
.daemon_id,
ID
);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod container_harness;
pub mod container_process;
pub mod container_selection;
+47
View File
@@ -0,0 +1,47 @@
mod support;
use std::time::Duration;
use support::container_harness::{ProbeNames, cleanup_confirmed_probe_resources, run_probe};
use support::container_selection::Selection;
const CLEANUP_BUDGET: Duration = Duration::from_secs(40);
#[test]
#[ignore = "requires an explicitly selected disposable Docker daemon"]
fn selected_daemon_wireguard_and_application_smoke() {
let selection = Selection::from_process_environment()
.unwrap_or_else(|category| panic!("Testcontainers selection failed ({category})"));
let names = ProbeNames::unique();
let result = run_probe(&names);
// run_probe is synchronous: all Testcontainers requests return or reach the
// pinned client's request timeout before this ownership-checked cleanup.
let cleanup = cleanup_confirmed_probe_resources(
&selection,
&names,
&std::process::id().to_string(),
CLEANUP_BUDGET,
);
if !cleanup.is_empty() {
eprintln!(
"Testcontainers cleanup incomplete for owned resources {}, {}, {}, {} ({})",
names.network,
names.server,
names.baseline,
names.client,
cleanup.join(", ")
);
}
if let Err(category) = result {
panic!("Testcontainers probe failed ({category})");
}
if !cleanup.is_empty() {
panic!("Testcontainers probe passed but owned-resource cleanup was incomplete");
}
eprintln!(
"Testcontainers capability and Application smoke passed; owned resources: {}, {}, {}, {}",
names.network, names.server, names.baseline, names.client
);
}
File diff suppressed because it is too large Load Diff
+284
View File
@@ -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())
+385
View File
@@ -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())
+255
View File
@@ -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())
+210
View File
@@ -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())
+279
View File
@@ -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())
+209
View File
@@ -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()

Some files were not shown because too many files have changed in this diff Show More