Compare commits
27 Commits
5418841b92
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4299118f7 | |||
| e648bc4dc6 | |||
| 4c86472740 | |||
| 7123197ec4 | |||
| 9a143e975e | |||
| 7cef400b9a | |||
| ed602f0596 | |||
| c1d4f935c8 | |||
| 2766c6254a | |||
| 8413b382b1 | |||
| ede45d224a | |||
| 23d6180f24 | |||
| 4960c65f65 | |||
| d49721ff8e | |||
| 4b53aad20c | |||
| fe7e3d61c2 | |||
| 86c28a35b5 | |||
| 4bb975f122 | |||
| af2d061880 | |||
| 6d8d6d572d | |||
| ecc5c83d78 | |||
| e141c03c2c | |||
| 7d0f006601 | |||
| f4a6540df9 | |||
| 2ccd67d64c | |||
| f718f739b4 | |||
| 6ec593c783 |
@@ -4,6 +4,9 @@
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Python bytecode from opt-in VM test helpers.
|
||||
__pycache__/
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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(¤t.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(),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(¤t.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))
|
||||
}
|
||||
@@ -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(¤t_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(¤t_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(¤t, &preview.installed_generation) {
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
|
||||
let prepared = prepare_restore(application, preview.saved_bytes)?;
|
||||
let historical_apply = historical_apply_from_saved(¤t)?;
|
||||
let archive = backup::create_archive(
|
||||
application.store.archives_directory(),
|
||||
BackupReason::PreRestore,
|
||||
historical_apply,
|
||||
¤t,
|
||||
)?;
|
||||
|
||||
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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t) {
|
||||
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(¤t) {
|
||||
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(¤t).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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+138
-4
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+4514
File diff suppressed because it is too large
Load Diff
+1104
File diff suppressed because it is too large
Load Diff
+1738
File diff suppressed because it is too large
Load Diff
+135
-7
@@ -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
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
**
|
||||
!Dockerfile
|
||||
!.dockerignore
|
||||
!runtime-check.sh
|
||||
!packages.lock
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Executable
+21
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod container_harness;
|
||||
pub mod container_process;
|
||||
pub mod container_selection;
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
Reference in New Issue
Block a user