Compare commits

..

3 Commits

37 changed files with 18203 additions and 117 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
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.
Generated
+1065 -9
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -16,14 +16,18 @@ 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"] }
+1228 -12
View File
File diff suppressed because it is too large Load Diff
+99 -17
View File
@@ -1,11 +1,13 @@
# 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:
@@ -15,9 +17,11 @@ 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
@@ -44,6 +48,32 @@ 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.
## Terminal workflows
The client list supports arrow-key selection, Enter for details, `?` for help,
and `q` to exit. Exit warns when a draft would be discarded; it never commits
implicitly. From the list or details screen, the current shortcuts are:
| 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 |
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
@@ -67,9 +97,10 @@ 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;
it has not been accepted against a real WireGuard interface or systemd service.
Real WireGuard integration is deferred to the isolated Alpine milestone, and
real systemd/D-Bus acceptance requires the disposable VM milestone.
## Saved state, Commit, and backups
@@ -83,12 +114,15 @@ 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
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.
[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
@@ -108,3 +142,51 @@ 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.
+200
View File
@@ -0,0 +1,200 @@
# Client configuration rendering
`delivery` contains pure rendering functions. They accept the committed
byte-preserving `ConfigDocument`, validated saved settings, and either the new
client's private key or a freshly password-retrieved `RetrievedClientRecord`.
Each renderer derives the server public key inside its boundary from exactly one
valid `[Interface] PrivateKey` in that saved document. Missing or duplicate
interface sections, missing or duplicate private-key directives, malformed key
text, and structurally malformed private-key directives fail closed. Callers
cannot supply or retain an unrelated server identity. The derivation keeps the
source key borrowed and the parsed secret local to the call; neither is copied
into an error or retained object. The renderer does not read paths, inspect the
host, access the running interface, save a file, or authorize a QR request.
The renderer locates the client's peer by public key and requires one unique
peer with one valid public key field and supported authoritative address routes.
Addresses come from that saved peer's `AllowedIPs` host entries. Each assigned
address is rendered as a host prefix (`/32` for IPv4 and `/128` for IPv6); these
client `[Interface] Address` values remain distinct from server-side
`[Peer] AllowedIPs` routes. A new client requires one IPv4 and one IPv6 saved
address, uses `0.0.0.0/0, ::/0`, and gets endpoint and DNS from the saved
application settings. No MTU, keepalive, or preshared-key defaults are added.
For imported reissue, the record's client addresses and optional saved server
public key are checked against current authoritative saved values. A mismatch,
stale address, missing or ambiguous peer, unsupported peer shape, or malformed
retained field fails closed. The renderer never truncates routes. It preserves
validated retained DNS, MTU, endpoint, AllowedIPs, keepalive, and preshared key
when present; application DNS and endpoint settings supply values absent from
the imported record. Saved peer addresses and derived server identity always
remain authoritative. DNS and endpoint values are parsed and emitted as
canonical single-line text; unknown directives and hooks are never copied.
The precedence is therefore:
1. The saved interface private key determines the server public identity, and
saved server peer identity and host addresses are authoritative for both
rendering paths. Imported retained server identity, when present, must match
this derived identity.
2. For new clients, saved application endpoint and DNS settings fill the
template; full-tunnel routes are the fixed new-client template.
3. For imported clients, authenticated retained optional values take precedence
over current endpoint and DNS defaults when present; retained server identity
and addresses must still match saved state. Retained AllowedIPs are preserved
after validation.
`ClientConfigPayload` owns rendered plaintext in a zeroizing byte buffer. Its
`Debug` output reports only length and redaction; it has no generic serialization
or display implementation. `as_bytes()` is the explicit disclosure boundary
that later delivery code must keep operation-local. The payload is capped at
64 KiB. Temporary Base64 encodings and application-owned copies created after
`as_bytes()` cannot be erased by this owner; delivery code must minimize and
bound those copies. No payload, password, or key is returned in errors.
Fresh retrieval is required for imported records: `VaultBundle` checks the
authenticated reissue-blocked state before constructing a
`RetrievedClientRecord`. This renderer offers no password-free retrieval API
and does not create first-issue eligibility. Retained fields are readable
through reference accessors, while their authenticated values cannot be changed
by library callers after retrieval. The imported renderer requires imported
provenance and a present matching server public key; a key-only new-template
record has no such binding and cannot use the imported path. The application grants initial
output only after Commit in the same creation session and supplies the payload
only inside its file-publication closure.
Later reissue is exposed by `Application::reissue_client_file`. Every call
reads installed saved bytes, freshly decrypts the installed vault identity with
the supplied per-action password, checks the authenticated blocked status, and
renders against the saved server document and settings. Draft peers and
retained original client files are never consulted. The short-lived decrypted
record is dropped after the file and optional QR operation; no unlocked vault
or identity is cached in the application.
Authenticated key-only records created by this application are classified as
new-template records only when every retained connection field is absent or
empty. That validated provenance path renders the current saved peer addresses
with the saved endpoint/DNS template. An empty address list combined with any
retained field is treated as a corrupt partial record. Imported records must
carry valid addresses and a server identity; those values are checked against
the saved peer and derived server identity, and retained optional parameters
are validated before rendering. Missing or malformed required retained values
fail closed instead of falling through to the new-client template.
First-issue file and file-plus-QR success results, including definite
publication with uncertain post-publication bookkeeping, carry an activation
warning. The warning is suppressed only when the current application session
has verified the exact installed saved bytes and matching saved/applied peer
snapshot. Unknown runtime state and historical verification timestamps do not
suppress it.
Reissue failures expose a fixed stage (`SavedState`, `Authorization`,
`Rendering`, or `FilePublication`) and secret-free category. The mandatory file
is published before optional QR encoding; QR failure is returned separately
after file success. Reissue uses the same generation-bound activation warning.
Delivery performs no runtime read, service operation, restart, or applied-state
mutation.
## Plaintext file publication
Initial delivery asks the caller for an explicit destination path; aliases are
never used to construct a path. Destination preparation performs no writes.
It rejects non-absolute paths, parent traversal, symlinks in existing path
components, symlink or special-file targets, the settings/server/internal-state/
vault files, the data and archive directories, and every existing entry in the
configured original-client directory. An existing regular file can be selected,
but preparation records its filesystem identity and requires a separate
confirmation tied to that prepared destination. The configured source directory
is enumerated with the same entry bound used by import; enumeration failures or
an unsafe source directory stop output preparation.
Publication requires the chosen output parent to be owned by the effective
user or root. The output parent cannot be group- or world-writable; existing
ancestors cannot be group- or world-writable unless the sticky bit protects
entries (for example `/tmp`). These checks are repeated immediately before
opening the parent, and the opened directory is checked as well. This keeps
other users from substituting the temporary entry or destination inside the
output parent. A bounded payload is written to a same-directory temporary
file created with mode `0600` and a 128-bit operating-system-random name, then
all bytes are flushed and synced before publication. A new destination uses
no-replace publication, so a file created after preparation is not silently
overwritten. A confirmed replacement uses an atomic exchange: the displaced
entry is checked against the confirmed inode before it is removed. If a
different entry was substituted at publication, the adapter attempts a second
exchange and checks both resulting identities. It never unlinks by name during
rollback: a same-privilege concurrent substitution could otherwise cause an
unconfirmed entry to be destroyed. Even after an identity-verified rollback,
the staged configuration remains under its mode-`0600` temporary name because
unlink-by-name cannot be made conditional on inode identity. A completed
rollback is reported as `DestinationChanged` only after the parent directory
sync succeeds. If the second exchange, identity checks, or sync fail,
publication is reported as uncertain and all entries still reachable by their
names are retained.
Missing parent directories are created with mode `0700`; existing directories
are not chmodded. The destination parent is synced after publication. On a
pre-publication error, the temporary file is removed and first-issue eligibility
remains retryable. If removal of a pre-publication temporary file fails, the
operation reports that a private temporary configuration may remain and
requires manual cleanup. First-issue retry requires an explicit cleanup
acknowledgement; inspect the selected output parent for
`.wg-admin-client-*.tmp` files and remove the retained file after verifying its
identity. This result does not mean the selected destination was published.
If parent sync fails after publication, the output may
already exist: it is reported as uncertain, retained, and a retry requires a
fresh selection and explicit overwrite confirmation. The application never
removes a published file to pretend an uncertain save did not happen.
The Linux exchange operation is atomic for the two names but cannot condition
the exchange on previously observed inode identities. The parent-directory
ownership and permission checks plus the unpredictable temporary name prevent
other users from racing those names. A process with the same effective
privilege can still change names after identity checks; concurrent edits by the
administrator are outside this adapter's single-administrator model. Callers
must treat `PublicationUncertain` as requiring inspection before retrying. A
retained temporary file is private to the current user and may need explicit
cleanup after the output state is understood.
Successful file publication consumes the session's first-issue eligibility. If
the file is published but the subsequent saved-state read needed to record that
transition fails, the result explicitly reports the file as published with
bookkeeping uncertain and drops the cached first-issue key; a later issuance
requires password authorization.
The selected path and confirmation are operation-local and contain no
configuration bytes or key material. These checks are filesystem adapter tests
using synthetic payloads and temporary directories; they do not represent a
privileged production-path test.
The renderer is verified with synthetic keys and configurations, including
round-trip parsing through `vault::import::parse_client_config`. The file
adapter is verified with synthetic payloads and temporary paths. This is not
evidence of a phone import, live tunnel, production filesystem, or QR scan.
## Optional QR output
An authorized first-file operation may request a QR representation in the same
operation. The app first publishes the mandatory configuration file, then gives
the same borrowed `ClientConfigPayload` to the QR encoder. There is no QR-only
entry point and no reconstruction from cached key material. File publication
failure skips QR encoding and preserves the existing retry behavior. If QR
encoding fails after the file was published, the result reports the file as
delivered and the QR error separately; first-issue eligibility is consumed.
The QR module and encoder are crate-private. External callers can request QR
output only through the application file-and-QR operation; the returned matrix
and fixed-category error types remain public so callers can display the result.
The encoder uses `qrcode` 0.14.1 with default features disabled (no image, SVG,
or external utility). The application owns a row-major matrix of 0/1 modules with
a four-module quiet zone. Its side is at most 185 modules (version 40 plus the
quiet zone). Matrix access is explicit; its `Debug` output redacts module data,
and the owner zeroizes its buffer on drop. The qrcode encoder necessarily keeps
internal payload/codeword and matrix copies that this application cannot
zeroize; the app drops its local encoder result immediately after copying the
modules into the bounded owner. QR errors contain only a fixed category.
Tests establish that the encoder receives bytes equal to the just-published file,
that encoding succeeds for a synthetic payload, and that capacity failure does
not undo file delivery. They do not decode the matrix, establish phone scanning
compatibility, or test terminal rendering; those remain future UI/integration
checks.
+5 -3
View File
@@ -16,18 +16,20 @@ 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 only `async-io` enabled. It uses zbus's asynchronous signal and method APIs, driven by a small standard-library wake/park loop so every call shares 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.
- 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.
+13
View File
@@ -103,3 +103,16 @@ WireGuard or systemd operation, does not provide concurrent-editor locking,
and is not automatic rollback after a failed runtime restart. The separate
pre-operation archive remains the recovery source if local journal data is
unusable; the adapter does not silently discard an invalid journal.
For an explicit restart, the application creates the pre-restart archive before
calling the service adapter. After a successful terminal service job, it checks
the complete observed peer set against the saved server document. A match is
then recorded by installing the updated `internal-state.toml` through this same
journal, with reservations removed only from the proposed state as appropriate.
The in-memory saved/applied state advances only after installation succeeds. If
that write or journal recovery fails after the VPN may have changed, the
application reports the persistence stage, retains in-memory reservations, and
requires manual recovery; it does not roll back or restart again. Default tests
exercise this orchestration with private fake adapters and synthetic files.
Actual interface and systemd behavior remain deferred to the isolated Alpine
and disposable VM acceptance environments described in `RUNTIME.md`.
+12 -3
View File
@@ -1,12 +1,19 @@
# Project Layout
Read this document when creating or reorganizing modules. The paths below are planned until implemented.
Read this document when creating or reorganizing modules. The layout below
reflects the current package; sections marked planned describe later test and
integration work only.
## 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 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 management TUI are 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.
Implemented modules include the following. Add modules as implementation
requires them; do not generate empty scaffolding for future integration work.
```text
Cargo.toml
@@ -36,12 +43,14 @@ src/
tui/
state.rs # Screen state and navigation
events.rs # Keyboard and background events
forms.rs # Short-lived forms and secret owners
render.rs # Rendering
terminal.rs # Terminal setup and cleanup
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
tui_workflow.rs # Synthetic worker-level TUI workflows
fixtures/ # Synthetic configuration and archive inputs
support/
mod.rs # Shared test adapters and helpers
+168
View File
@@ -0,0 +1,168 @@
# 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. The Linux transport has not been validated
against a real interface; that check belongs to the isolated Alpine suite at
M8. The real zbus/systemd path and end-to-end restart acceptance require the
disposable systemd VM at M9. The TUI and management startup wiring are not
implemented.
## 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. Real-interface behavior remains
subject to the isolated Alpine integration milestone.
+43 -1
View File
@@ -17,6 +17,21 @@ cargo build --locked --release
Use only supported feature combinations; do not assume `--all-features` is valid. Keep privileged integration tests out of the default test suite. If a check cannot run, report the exact limitation rather than claiming success. Documentation-only edits do not require rebuilding the application.
The unprivileged TUI fixture commands currently available are:
```sh
cargo test --locked --test tui_workflow
cargo test --locked tui::
```
`tui_workflow` runs setup with a redacted invalid-source anomaly, client
creation/rename, Commit, first-file and QR delivery, manual backup/list, and
preview/cancel/confirm restore through the serialized application worker. It
uses temporary settings/server/source files and a fake statistics reader; it
does not invoke service or runtime mutation.
The `tui::` filter covers forms, rendering, worker responsiveness/staleness,
QR fit, and terminal cleanup/error seams. It does not require root or a PTY.
Prioritize meaningful behavioral checks:
- Configuration round trips preserve untouched bytes, unknown fields, and administrator comments; managed metadata cannot escape its comment block.
@@ -30,6 +45,33 @@ 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.
@@ -40,7 +82,7 @@ Production access is limited and experimentation on the production VPN is prohib
- 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.
- Run the application operations or a test driver linked to the real library inside the server container, including the approved `rustix` Generic Netlink runtime 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.
+3226 -50
View File
File diff suppressed because it is too large Load Diff
+807
View File
@@ -0,0 +1,807 @@
//! Deterministic, bounded rendering of explicitly authorized client configurations.
//!
//! Rendering is pure: callers supply the authoritative saved document, the derived
//! server identity, and settings. Plaintext output is held in [`ClientConfigPayload`]
//! and exposed only through its explicit byte accessor.
//!
//! QR encoding is intentionally not a public API. It is only reachable through
//! the application's file-and-QR delivery operation, which publishes the
//! mandatory plaintext file before encoding those same bytes.
//!
//! ```compile_fail
//! use wireguard_admin::delivery::qr::encode_payload;
//! ```
use std::collections::BTreeSet;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use ipnet::IpNet;
use thiserror::Error;
use zeroize::Zeroizing;
pub mod files;
pub(crate) mod qr;
pub use qr::{QrCodeOutput, QrError};
use crate::config::{EndpointHost, ServerEndpoint};
use crate::domain::PublicKey;
use crate::vault::{RetrievedClientRecord, WireGuardPrivateKey};
use crate::wireguard::{ConfigDocument, PeerInspection};
const MAX_CONFIG_BYTES: usize = 64 * 1024;
const MAX_DNS_ENTRIES: usize = 32;
const MAX_ROUTE_ENTRIES: usize = 256;
/// Validated connection defaults used for new clients and missing imported values.
///
/// Values are checked again here because public settings structs can be constructed
/// directly by library callers without passing through settings-file validation.
pub struct DeliverySettings<'a> {
pub server_endpoint: &'a ServerEndpoint,
pub dns_addresses: &'a [IpAddr],
}
/// Secret-bearing rendered file bytes. There is no `Display` or serialization API.
pub struct ClientConfigPayload(Zeroizing<Vec<u8>>);
impl ClientConfigPayload {
/// Explicitly expose rendered configuration bytes to a delivery operation.
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for ClientConfigPayload {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientConfigPayload")
.field("length", &self.0.len())
.field("contents", &"[REDACTED]")
.finish()
}
}
/// Secret-free rendering failures. Variants never contain source text or key material.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum DeliveryError {
#[error("client identity does not match the authorized private key")]
ClientIdentityMismatch,
#[error("client peer is missing from the authoritative saved configuration")]
MissingSavedPeer,
#[error("client identity is ambiguous in the authoritative saved configuration")]
AmbiguousSavedPeer,
#[error("authoritative saved peer data is malformed or unsupported")]
InvalidSavedPeer,
#[error("retained client addresses do not match authoritative saved addresses")]
AddressMismatch,
#[error("retained server identity does not match the derived saved server identity")]
ServerIdentityMismatch,
#[error("authoritative saved server identity is missing, ambiguous, or invalid")]
InvalidSavedServerIdentity,
#[error("client connection settings are invalid or unsupported")]
InvalidConnectionField,
#[error("rendered client configuration exceeds its size limit")]
TooLarge,
}
/// Render a newly created client's committed connection using a full-tunnel template.
pub fn render_new_client(
identity: PublicKey,
private_key: &WireGuardPrivateKey,
saved: &ConfigDocument,
settings: &DeliverySettings<'_>,
) -> Result<ClientConfigPayload, DeliveryError> {
if !private_key.matches_public_key(&identity) {
return Err(DeliveryError::ClientIdentityMismatch);
}
let server_public_key = authoritative_server_public_key(saved)?;
let addresses = authoritative_addresses(saved, identity, true)?;
let endpoint = render_endpoint(settings.server_endpoint)?;
let dns = render_dns(settings.dns_addresses)?;
let private_text = Zeroizing::new(private_key.to_base64());
let mut output = Zeroizing::new(Vec::with_capacity(256));
append(&mut output, "[Interface]\nPrivateKey = ")?;
append(&mut output, private_text.as_str())?;
append(&mut output, "\nAddress = ")?;
append(&mut output, &addresses)?;
append(&mut output, "\nDNS = ")?;
append(&mut output, &dns)?;
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
append(&mut output, &server_public_key.to_string())?;
append(&mut output, "\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ")?;
append(&mut output, &endpoint)?;
append(&mut output, "\n")?;
finish(output)
}
/// Render a password-authorized imported record, retaining validated supported fields.
///
/// The record type is only obtainable through fresh vault retrieval, which rejects
/// authenticated `reissue_blocked` records. Its retained fields are immutable to
/// library callers, and the renderer verifies their authenticated provenance and
/// server binding against the authoritative saved configuration.
pub fn render_imported_client(
identity: PublicKey,
record: &RetrievedClientRecord,
saved: &ConfigDocument,
settings: &DeliverySettings<'_>,
) -> Result<ClientConfigPayload, DeliveryError> {
let server_public_key = authoritative_server_public_key(saved)?;
if !record.is_imported() {
return Err(DeliveryError::ServerIdentityMismatch);
}
let private_key = record
.private_key()
.ok_or(DeliveryError::ClientIdentityMismatch)?;
if !private_key.matches_public_key(&identity) {
return Err(DeliveryError::ClientIdentityMismatch);
}
let addresses = authoritative_addresses(saved, identity, false)?;
validate_retained_addresses(record.client_addresses(), &addresses)?;
let expected_server_public_key = server_public_key.to_string();
if record.server_public_key() != Some(expected_server_public_key.as_str()) {
return Err(DeliveryError::ServerIdentityMismatch);
}
let interface_addresses = parse_address_string(&addresses)?;
let dns_value = match record.interface_dns() {
Some(value) => validate_dns(value)?,
None => render_dns(settings.dns_addresses)?,
};
let endpoint = match record.endpoint() {
Some(value) => validate_endpoint(value)?,
None => render_endpoint(settings.server_endpoint)?,
};
let mtu = record.interface_mtu().map(validate_mtu).transpose()?;
let keepalive = record
.persistent_keepalive()
.map(validate_keepalive)
.transpose()?;
let routes = validate_routes(record.allowed_ips())?;
let psk = record
.preshared_key_base64()
.map(validate_secret_key)
.transpose()?;
let private_text = Zeroizing::new(private_key.to_base64());
let mut output = Zeroizing::new(Vec::with_capacity(512));
append(&mut output, "[Interface]\nPrivateKey = ")?;
append(&mut output, private_text.as_str())?;
append(&mut output, "\nAddress = ")?;
append(&mut output, &interface_addresses)?;
append(&mut output, "\nDNS = ")?;
append(&mut output, &dns_value)?;
if let Some(mtu) = mtu {
append(&mut output, "\nMTU = ")?;
append(&mut output, &mtu)?;
}
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
append(&mut output, &server_public_key.to_string())?;
if let Some(psk) = psk {
append(&mut output, "\nPresharedKey = ")?;
append(&mut output, psk.as_str())?;
}
append(&mut output, "\nAllowedIPs = ")?;
append(&mut output, &routes)?;
append(&mut output, "\nEndpoint = ")?;
append(&mut output, &endpoint)?;
if let Some(keepalive) = keepalive {
append(&mut output, "\nPersistentKeepalive = ")?;
append(&mut output, &keepalive)?;
}
append(&mut output, "\n")?;
finish(output)
}
/// Derive the server identity only from one valid `[Interface] PrivateKey` in
/// the authoritative saved document. The private key and source text remain
/// borrowed/local to this call and never enter an error or owned long-lived value.
fn authoritative_server_public_key(saved: &ConfigDocument) -> Result<PublicKey, DeliveryError> {
use crate::wireguard::SectionKind;
let interfaces = saved
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.collect::<Vec<_>>();
let [interface] = interfaces.as_slice() else {
return Err(DeliveryError::InvalidSavedServerIdentity);
};
let section = saved
.source_slice(interface.span)
.ok_or(DeliveryError::InvalidSavedServerIdentity)?;
let mut private_key_value = None;
for line in section.lines() {
let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace());
if trimmed.is_empty() || trimmed.starts_with(['#', ';']) {
continue;
}
let has_private_key_name = trimmed
.split(['=', ' ', '\t'])
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("PrivateKey"));
if !has_private_key_name {
continue;
}
if private_key_value.is_some() {
return Err(DeliveryError::InvalidSavedServerIdentity);
}
let Some((name, value)) = trimmed.split_once('=') else {
return Err(DeliveryError::InvalidSavedServerIdentity);
};
if !name
.trim_matches(|ch: char| ch.is_ascii_whitespace())
.eq_ignore_ascii_case("PrivateKey")
{
return Err(DeliveryError::InvalidSavedServerIdentity);
}
private_key_value = Some(value.trim_matches(|ch: char| ch.is_ascii_whitespace()));
}
let private_key = WireGuardPrivateKey::parse_base64(
private_key_value.ok_or(DeliveryError::InvalidSavedServerIdentity)?,
)
.map_err(|_| DeliveryError::InvalidSavedServerIdentity)?;
Ok(private_key.public_key())
}
fn authoritative_addresses(
saved: &ConfigDocument,
identity: PublicKey,
require_dual_stack: bool,
) -> Result<String, DeliveryError> {
let matching = saved
.peers()
.iter()
.filter(|peer| {
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
})
.collect::<Vec<_>>();
let peer = match matching.as_slice() {
[] => return Err(DeliveryError::MissingSavedPeer),
[peer] => *peer,
_ => return Err(DeliveryError::AmbiguousSavedPeer),
};
validate_peer(peer)?;
let mut v4 = None;
let mut v6 = None;
for network in &peer.allowed_ips {
match network {
IpNet::V4(network)
if network.prefix_len() == 32 && v4.replace(network.addr()).is_none() => {}
IpNet::V6(network)
if network.prefix_len() == 128 && v6.replace(network.addr()).is_none() => {}
_ => return Err(DeliveryError::InvalidSavedPeer),
}
}
let v4 = v4.ok_or(DeliveryError::InvalidSavedPeer)?;
if require_dual_stack && v6.is_none() {
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(format_addresses(v4, v6))
}
fn validate_peer(peer: &PeerInspection) -> Result<(), DeliveryError> {
if peer.identity.is_none()
|| peer.identity != peer.public_key_candidates.first().copied()
|| peer.public_key_candidates.len() != 1
|| !peer.issues.is_empty()
|| peer.allowed_ips.is_empty()
{
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(())
}
fn format_addresses(v4: Ipv4Addr, v6: Option<Ipv6Addr>) -> String {
let mut output = format!("{v4}/32");
if let Some(v6) = v6 {
output.push_str(&format!(", {v6}/128"));
}
output
}
fn parse_address_string(value: &str) -> Result<String, DeliveryError> {
let items = value.split(", ").collect::<Vec<_>>();
if items.is_empty() || items.len() > 2 {
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(items.join(", "))
}
fn validate_retained_addresses(
retained: &[String],
authoritative: &str,
) -> Result<(), DeliveryError> {
if retained.is_empty() || retained.len() > 2 || retained.iter().any(|value| value.len() > 64) {
return Err(DeliveryError::AddressMismatch);
}
let expected = authoritative
.split(", ")
.map(str::to_owned)
.collect::<BTreeSet<_>>();
let mut actual = BTreeSet::new();
for value in retained {
if value.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let network = IpNet::from_str(value).map_err(|_| DeliveryError::InvalidConnectionField)?;
match network {
IpNet::V4(net) if net.prefix_len() == 32 => {}
IpNet::V6(net) if net.prefix_len() == 128 => {}
_ => return Err(DeliveryError::InvalidConnectionField),
}
actual.insert(network.to_string());
}
if actual == expected {
Ok(())
} else {
Err(DeliveryError::AddressMismatch)
}
}
fn render_dns(dns: &[IpAddr]) -> Result<String, DeliveryError> {
if dns.is_empty() || dns.len() > MAX_DNS_ENTRIES {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(dns
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "))
}
fn validate_dns(value: &str) -> Result<String, DeliveryError> {
if value.len() > 2048 || value.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
if items.is_empty() || items.len() > MAX_DNS_ENTRIES || items.iter().any(|item| item.is_empty())
{
return Err(DeliveryError::InvalidConnectionField);
}
let addresses = items
.iter()
.map(|item| {
item.parse::<IpAddr>()
.map_err(|_| DeliveryError::InvalidConnectionField)
})
.collect::<Result<Vec<_>, _>>()?;
render_dns(&addresses)
}
fn render_endpoint(endpoint: &ServerEndpoint) -> Result<String, DeliveryError> {
if endpoint.port == 0 {
return Err(DeliveryError::InvalidConnectionField);
}
match &endpoint.host {
EndpointHost::Ip(IpAddr::V6(address)) => Ok(format!("[{address}]:{}", endpoint.port)),
EndpointHost::Ip(address) => Ok(format!("{address}:{}", endpoint.port)),
EndpointHost::Hostname(host) => {
validate_hostname(host)?;
Ok(format!("{host}:{}", endpoint.port))
}
}
}
fn validate_endpoint(value: &str) -> Result<String, DeliveryError> {
if value.is_empty() || value.chars().any(char::is_control) || value.len() > 255 {
return Err(DeliveryError::InvalidConnectionField);
}
let (host, port) = if let Some(rest) = value.strip_prefix('[') {
let (host, suffix) = rest
.split_once(']')
.ok_or(DeliveryError::InvalidConnectionField)?;
let port = suffix
.strip_prefix(':')
.ok_or(DeliveryError::InvalidConnectionField)?;
let address = host
.parse::<Ipv6Addr>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
(format!("[{address}]"), parse_port(port)?)
} else {
let (host, port) = value
.rsplit_once(':')
.ok_or(DeliveryError::InvalidConnectionField)?;
if host.contains(':') {
return Err(DeliveryError::InvalidConnectionField);
}
let formatted = if let Ok(address) = host.parse::<Ipv4Addr>() {
address.to_string()
} else {
validate_hostname(host)?;
host.to_owned()
};
(formatted, parse_port(port)?)
};
Ok(format!("{host}:{port}"))
}
fn validate_hostname(host: &str) -> Result<(), DeliveryError> {
if host.is_empty()
|| host.len() > 253
|| host.split('.').any(|label| {
label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label.as_bytes()[0].is_ascii_alphanumeric()
|| !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
{
return Err(DeliveryError::InvalidConnectionField);
}
Ok(())
}
fn parse_port(value: &str) -> Result<u16, DeliveryError> {
let port = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if port == 0 || port.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(port)
}
fn validate_mtu(value: &str) -> Result<String, DeliveryError> {
let mtu = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if mtu == 0 || mtu.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(mtu.to_string())
}
fn validate_keepalive(value: &str) -> Result<String, DeliveryError> {
if value.len() > 5 {
return Err(DeliveryError::InvalidConnectionField);
}
let keepalive = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if keepalive.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(keepalive.to_string())
}
fn validate_routes(routes: &[String]) -> Result<String, DeliveryError> {
if routes.is_empty()
|| routes.len() > MAX_ROUTE_ENTRIES
|| routes.iter().any(|route| route.len() > 128)
{
return Err(DeliveryError::InvalidConnectionField);
}
let mut parsed = Vec::with_capacity(routes.len());
for route in routes {
if route.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let network = IpNet::from_str(route).map_err(|_| DeliveryError::InvalidConnectionField)?;
parsed.push(network.to_string());
}
Ok(parsed.join(", "))
}
fn validate_secret_key(value: &str) -> Result<Zeroizing<String>, DeliveryError> {
let key = WireGuardPrivateKey::parse_base64(value)
.map_err(|_| DeliveryError::InvalidConnectionField)?;
// Parsing confirms canonical 32-byte standard Base64; retain only a zeroizing copy.
Ok(Zeroizing::new(key.to_base64()))
}
fn append(output: &mut Zeroizing<Vec<u8>>, value: &str) -> Result<(), DeliveryError> {
if output.len().saturating_add(value.len()) > MAX_CONFIG_BYTES {
return Err(DeliveryError::TooLarge);
}
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn finish(output: Zeroizing<Vec<u8>>) -> Result<ClientConfigPayload, DeliveryError> {
if output.is_empty() || output.len() > MAX_CONFIG_BYTES {
return Err(DeliveryError::TooLarge);
}
Ok(ClientConfigPayload(output))
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::config::{EndpointHost, ServerEndpoint};
use crate::vault::{
VaultBundle, WireGuardPrivateKey,
import::{parse_client_config, reconcile},
};
use crate::wireguard::ConfigDocument;
use super::{DeliveryError, DeliverySettings, render_imported_client, render_new_client};
const PASSWORD: &str = "synthetic delivery test password";
fn key(byte: u8) -> WireGuardPrivateKey {
WireGuardPrivateKey::parse_base64(
&base64::engine::general_purpose::STANDARD.encode([byte; 32]),
)
.expect("synthetic key parses")
}
fn document(client: &WireGuardPrivateKey, addresses: &str) -> ConfigDocument {
document_with_server(client, addresses, &key(9))
}
fn document_with_server(
client: &WireGuardPrivateKey,
addresses: &str,
server: &WireGuardPrivateKey,
) -> ConfigDocument {
let source = format!(
"[Interface]\nPrivateKey = {}\n\n[Peer]\nPublicKey = {}\nAllowedIPs = {addresses}\n",
server.to_base64(),
client.public_key()
);
ConfigDocument::parse(source).expect("synthetic server document parses")
}
fn imported_record(
client: &WireGuardPrivateKey,
addresses: &str,
server: &WireGuardPrivateKey,
) -> crate::vault::RetrievedClientRecord {
let saved = document_with_server(client, addresses, server);
let source = format!(
"[Interface]\nPrivateKey = {}\nAddress = {addresses}\nDNS = 10.80.0.1, fd80::1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51821\nAllowedIPs = 10.0.0.0/8, ::/0\nPersistentKeepalive = 25\n",
client.to_base64(),
server.public_key()
);
let report = reconcile(&saved, [("fixture.conf".to_owned(), source.into_bytes())]);
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
report
.encrypt_candidate(&mut bundle, 0)
.expect("matching imported file encrypts");
bundle
.retrieve_client_record(client.public_key(), PASSWORD)
.expect("imported record retrieves")
}
fn settings() -> (ServerEndpoint, Vec<IpAddr>) {
(
ServerEndpoint {
host: EndpointHost::Hostname("vpn.example.test".to_owned()),
port: 51820,
},
vec![
"10.80.0.1".parse().expect("valid DNS"),
"fd80::1".parse().expect("valid DNS"),
],
)
}
#[test]
fn new_client_uses_saved_dual_stack_addresses_and_full_tunnel_template() {
let client = key(1);
let server = key(9).public_key();
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
let (endpoint, dns) = settings();
let payload = render_new_client(
client.public_key(),
&client,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("valid synthetic values render");
let parsed = parse_client_config(payload.as_bytes()).expect("rendered file parses");
assert_eq!(parsed.identity, client.public_key());
assert_eq!(parsed.server_public_key, server);
assert_eq!(parsed.addresses.len(), 2);
assert_eq!(parsed.allowed_ips.len(), 2);
assert_eq!(parsed.allowed_ips[0].to_string(), "0.0.0.0/0");
assert_eq!(parsed.allowed_ips[1].to_string(), "::/0");
}
#[test]
fn new_client_rejects_wrong_key_and_missing_or_ambiguous_saved_peer() {
let client = key(1);
let other = key(2);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
assert_eq!(
render_new_client(other.public_key(), &client, &saved, &opts).err(),
Some(DeliveryError::ClientIdentityMismatch)
);
let empty = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\n",
key(9).to_base64()
))
.expect("valid document");
assert_eq!(
render_new_client(client.public_key(), &client, &empty, &opts).err(),
Some(DeliveryError::MissingSavedPeer)
);
let duplicate = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
key(9).to_base64(),
client.public_key(), client.public_key()
)).expect("duplicate peer document parses");
assert_eq!(
render_new_client(client.public_key(), &client, &duplicate, &opts).err(),
Some(DeliveryError::AmbiguousSavedPeer)
);
}
#[test]
fn new_client_derives_server_identity_and_rejects_invalid_saved_interface_keys() {
let client = key(1);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let valid_peer = format!(
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
client.public_key()
);
let invalid_documents = [
valid_peer.clone(),
format!(
"[Interface]\nPrivateKey = {}\n[Interface]\nPrivateKey = {}{}",
key(9).to_base64(),
key(9).to_base64(),
valid_peer
),
format!("[Interface]\nPrivateKey = invalid{valid_peer}"),
format!(
"[Interface]\nPrivateKey = {}\nPrivateKey = {}{}",
key(9).to_base64(),
key(8).to_base64(),
valid_peer
),
format!("[Interface]\nPrivateKey\n{valid_peer}"),
];
for source in invalid_documents {
let saved = ConfigDocument::parse(source).expect("synthetic document parses");
assert_eq!(
render_new_client(client.public_key(), &client, &saved, &opts).err(),
Some(DeliveryError::InvalidSavedServerIdentity)
);
}
let valid = document(&client, "10.80.0.7/32, fd80::7/128");
let rendered = render_new_client(client.public_key(), &client, &valid, &opts)
.expect("server identity derives from valid saved private key");
let parsed = parse_client_config(rendered.as_bytes()).expect("rendered file parses");
assert_eq!(parsed.server_public_key, key(9).public_key());
}
#[test]
fn imported_ipv4_only_record_preserves_supported_fields() {
let client = key(4);
let server_private = key(9);
let record = imported_record(&client, "10.80.0.9/32", &server_private);
let saved = document(&client, "10.80.0.9/32");
let (endpoint, dns) = settings();
let payload = render_imported_client(
client.public_key(),
&record,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("supported imported values render");
let parsed =
parse_client_config(payload.as_bytes()).expect("rendered imported file parses");
assert_eq!(
parsed.addresses,
vec!["10.80.0.9/32".parse().expect("valid address")]
);
assert_eq!(parsed.allowed_ips.len(), 2);
assert_eq!(parsed.mtu.as_deref(), Some("1380"));
assert_eq!(parsed.endpoint.as_deref(), Some("vpn.example.test:51821"));
assert_eq!(parsed.persistent_keepalive.as_deref(), Some("25"));
}
#[test]
fn imported_rendering_rejects_stale_and_mismatched_bindings() {
let client = key(6);
let server_private = key(9);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let record = imported_record(&client, "10.80.0.9/32", &server_private);
let stale_saved = document(&client, "10.80.0.8/32");
assert_eq!(
render_imported_client(client.public_key(), &record, &stale_saved, &opts).err(),
Some(DeliveryError::AddressMismatch)
);
let changed_server = document_with_server(&client, "10.80.0.9/32", &key(8));
assert_eq!(
render_imported_client(client.public_key(), &record, &changed_server, &opts).err(),
Some(DeliveryError::ServerIdentityMismatch)
);
assert!(record.server_public_key().is_some());
}
#[test]
fn imported_rendering_requires_saved_server_identity() {
let client = key(12);
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
bundle
.encrypt_record(client.public_key(), &client)
.expect("client key encrypts");
let record = bundle
.retrieve_client_record(client.public_key(), PASSWORD)
.expect("record retrieves");
let saved = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = malformed\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.9/32\n",
client.public_key()
))
.expect("synthetic malformed-key document parses structurally");
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let valid_saved = document(&client, "10.80.0.9/32");
assert_eq!(
render_imported_client(client.public_key(), &record, &valid_saved, &opts).err(),
Some(DeliveryError::ServerIdentityMismatch)
);
assert_eq!(
render_imported_client(client.public_key(), &record, &saved, &opts).err(),
Some(DeliveryError::InvalidSavedServerIdentity)
);
}
#[test]
fn payload_debug_and_render_errors_do_not_contain_secret_text() {
let client = key(10);
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
let (endpoint, dns) = settings();
let payload = render_new_client(
client.public_key(),
&client,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("valid synthetic values render");
let debug = format!("{payload:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains(&client.to_base64()));
assert!(Ipv4Addr::LOCALHOST.is_loopback());
assert_eq!(
"::1".parse::<Ipv6Addr>().expect("valid IPv6"),
Ipv6Addr::LOCALHOST
);
}
}
+880
View File
@@ -0,0 +1,880 @@
//! Secure publication of explicitly selected plaintext client configurations.
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Write};
use std::os::fd::AsFd;
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use rustix::fs::{self as rfs, Mode, OFlags, RenameFlags, ResolveFlags};
use thiserror::Error;
use zeroize::{Zeroize, Zeroizing};
use super::{ClientConfigPayload, MAX_CONFIG_BYTES};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const MAX_OUTPUT_BYTES: usize = MAX_CONFIG_BYTES;
/// Resolved installation files and protected directories supplied by SavedStore.
pub(crate) struct OutputProtection {
protected_files: Vec<PathBuf>,
protected_directories: Vec<PathBuf>,
}
impl OutputProtection {
pub(crate) fn new(files: Vec<PathBuf>, directories: Vec<PathBuf>) -> Self {
Self {
protected_files: files,
protected_directories: directories,
}
}
}
/// A destination prepared without creating files or directories.
///
/// The optional existing-file identity binds overwrite confirmation to the
/// precise regular file observed by [`prepare_destination`].
pub struct PreparedDestination {
path: PathBuf,
parent: PathBuf,
filename: std::ffi::OsString,
existing: Option<FileIdentity>,
overwrite_confirmed: bool,
}
impl PreparedDestination {
pub fn path(&self) -> &Path {
&self.path
}
pub const fn overwrite_confirmation_required(&self) -> bool {
self.existing.is_some() && !self.overwrite_confirmed
}
/// Confirm replacement of the exact regular file observed during preparation.
pub fn confirm_overwrite(mut self) -> Result<Self, FileOutputError> {
if self.existing.is_none() {
return Err(FileOutputError::OverwriteNotRequired);
}
self.overwrite_confirmed = true;
Ok(self)
}
}
impl fmt::Debug for PreparedDestination {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreparedDestination")
.field("path", &self.path)
.field("existing_file", &self.existing.is_some())
.field("overwrite_confirmed", &self.overwrite_confirmed)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FileIdentity {
device: u64,
inode: u64,
length: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
}
impl From<&fs::Metadata> for FileIdentity {
fn from(metadata: &fs::Metadata) -> Self {
Self {
device: metadata.dev(),
inode: metadata.ino(),
length: metadata.len(),
modified_seconds: metadata.mtime(),
modified_nanoseconds: metadata.mtime_nsec(),
}
}
}
/// Failure categories let the application preserve retry eligibility while
/// distinguishing a failed preparation from a file that may already be visible.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum FileOutputError {
#[error("selected output path is unsafe or protected")]
UnsafePath,
#[error("selected destination already exists; confirm replacement for this file")]
OverwriteDeclined,
#[error("the selected destination changed after it was prepared")]
DestinationChanged,
#[error("client configuration exceeds the output size limit")]
TooLarge,
#[error("client configuration could not be written before publication")]
BeforePublication,
#[error("a private temporary client configuration may remain and requires manual cleanup")]
ManualCleanupRequired,
#[error("client configuration may have been published, but directory sync failed")]
PublicationUncertain,
#[error("file output failed")]
Io,
#[error("overwrite confirmation is not applicable to a new destination")]
OverwriteNotRequired,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Checkpoint {
Write,
Flush,
FileSync,
Cleanup,
Publish,
Rollback,
ParentSync,
}
/// Validate a caller-selected absolute destination without performing writes.
/// Existing targets are accepted only when they are regular files; they require
/// an explicit later call to [`PreparedDestination::confirm_overwrite`].
pub(crate) fn prepare_destination(
path: impl AsRef<Path>,
protection: &OutputProtection,
) -> Result<PreparedDestination, FileOutputError> {
let path = path.as_ref();
validate_absolute_path(path)?;
reject_protected(path, protection)?;
let parent = path.parent().ok_or(FileOutputError::UnsafePath)?;
validate_existing_ancestors(parent)?;
let filename = path
.file_name()
.ok_or(FileOutputError::UnsafePath)?
.to_os_string();
let existing = match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {
reject_protected_hardlink(&metadata, protection)?;
Some(FileIdentity::from(&metadata))
}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(_) => return Err(FileOutputError::Io),
};
Ok(PreparedDestination {
path: path.to_path_buf(),
parent: parent.to_path_buf(),
filename,
existing,
overwrite_confirmed: false,
})
}
/// Publish the bounded payload using a private same-directory temporary file.
/// New targets use a no-replace rename. Confirmed overwrite uses an atomic
/// exchange and verifies the displaced inode before removing it.
pub fn publish(
destination: PreparedDestination,
payload: &ClientConfigPayload,
) -> Result<(), FileOutputError> {
publish_with_hook(destination, payload, |_| Ok(()))
}
fn publish_with_hook(
destination: PreparedDestination,
payload: &ClientConfigPayload,
mut hook: impl FnMut(Checkpoint) -> io::Result<()>,
) -> Result<(), FileOutputError> {
if payload.len() > MAX_OUTPUT_BYTES {
return Err(FileOutputError::TooLarge);
}
if destination.overwrite_confirmation_required() {
return Err(FileOutputError::OverwriteDeclined);
}
validate_existing_ancestors(&destination.parent)?;
match (
&destination.existing,
fs::symlink_metadata(&destination.path),
) {
(Some(expected), Ok(actual))
if actual.file_type().is_file() && FileIdentity::from(&actual) == *expected => {}
(None, Err(error)) if error.kind() == io::ErrorKind::NotFound => {}
(None, Ok(_)) => return Err(FileOutputError::DestinationChanged),
_ => return Err(FileOutputError::DestinationChanged),
}
create_missing_private_directories(&destination.parent)?;
validate_existing_ancestors(&destination.parent)?;
let parent = open_parent_directory(&destination.parent)?;
let mut temp_name = None;
let mut temp_file = None;
for _ in 0..32 {
let mut random = [0_u8; 16];
getrandom::fill(&mut random).map_err(|_| FileOutputError::Io)?;
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let name = format!(".wg-admin-client-{sequence}-{}.tmp", hex(&random));
match rfs::openat(
&parent,
name.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
) {
Ok(fd) => {
temp_name = Some(name);
temp_file = Some(File::from(fd));
break;
}
Err(rustix::io::Errno::EXIST) => continue,
Err(_) => return Err(FileOutputError::Io),
}
}
let temp_name = temp_name.ok_or(FileOutputError::Io)?;
let mut file = temp_file.ok_or(FileOutputError::Io)?;
let mut bytes = Zeroizing::new(payload.as_bytes().to_vec());
let write_result = (|| -> io::Result<()> {
hook(Checkpoint::Write)?;
file.write_all(bytes.as_slice())?;
hook(Checkpoint::Flush)?;
file.flush()?;
hook(Checkpoint::FileSync)?;
file.sync_all()?;
Ok(())
})();
bytes.zeroize();
if write_result.is_err() {
drop(file);
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::BeforePublication
} else {
FileOutputError::ManualCleanupRequired
});
}
drop(file);
let staged_identity = file_identity_at(&parent, &temp_name);
let Some(staged_identity) = staged_identity else {
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::Io
} else {
FileOutputError::ManualCleanupRequired
});
};
if hook(Checkpoint::Publish).is_err() {
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::BeforePublication
} else {
FileOutputError::ManualCleanupRequired
});
}
let publication = if destination.existing.is_some() {
rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::EXCHANGE,
)
} else {
rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::NOREPLACE,
)
};
if publication.is_err() {
let clean = cleanup_temporary(&parent, &temp_name, &mut hook);
return Err(if !clean {
FileOutputError::ManualCleanupRequired
} else if destination.existing.is_some() {
FileOutputError::DestinationChanged
} else {
FileOutputError::BeforePublication
});
}
if let Some(expected) = destination.existing {
let displaced = file_identity_at(&parent, &temp_name);
if displaced != Some(expected) {
// EXCHANGE never discards either entry. Rollback is another
// unconditional exchange, so a concurrent substitution can change
// either name before or during it. Never unlink by name here.
if hook(Checkpoint::Rollback).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
if rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::EXCHANGE,
)
.is_err()
{
return Err(FileOutputError::PublicationUncertain);
}
if file_identity_at(&parent, &destination.filename) != displaced
|| file_identity_at(&parent, &temp_name) != Some(staged_identity)
{
return Err(FileOutputError::PublicationUncertain);
}
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
// Keep the staged entry under its private temporary name. Removing
// it by name would race with another substitution and could destroy
// an unconfirmed entry.
return Err(FileOutputError::DestinationChanged);
}
// The exchanged entry is the exact confirmed file. The atomic exchange
// closes the check/rename race; remove only after that identity check.
if rfs::unlinkat(&parent, temp_name.as_str(), rfs::AtFlags::empty()).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
}
if file_identity_at(&parent, &destination.filename) != Some(staged_identity) {
return Err(FileOutputError::PublicationUncertain);
}
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
Ok(())
}
fn cleanup_temporary(
parent: &impl AsFd,
name: &str,
hook: &mut impl FnMut(Checkpoint) -> io::Result<()>,
) -> bool {
if hook(Checkpoint::Cleanup).is_err() {
return false;
}
match rfs::unlinkat(parent, name, rfs::AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => true,
Err(_) => false,
}
}
fn file_identity_at(parent: &impl AsFd, name: impl AsRef<std::ffi::OsStr>) -> Option<FileIdentity> {
let fd = rfs::openat(
parent,
name.as_ref(),
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.ok()?;
let metadata = File::from(fd).metadata().ok()?;
metadata.is_file().then(|| FileIdentity::from(&metadata))
}
fn validate_absolute_path(path: &Path) -> Result<(), FileOutputError> {
if !path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
{
return Err(FileOutputError::UnsafePath);
}
Ok(())
}
fn validate_existing_ancestors(path: &Path) -> Result<(), FileOutputError> {
let mut current = PathBuf::from("/");
let components: Vec<_> = path.components().skip(1).collect();
for (index, component) in components.iter().enumerate() {
let Component::Normal(name) = component else {
return Err(FileOutputError::UnsafePath);
};
current.push(name);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_dir() => {
validate_directory_metadata(&metadata, index + 1 == components.len())?;
}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(_) => return Err(FileOutputError::Io),
}
}
Ok(())
}
fn create_missing_private_directories(path: &Path) -> Result<(), FileOutputError> {
let mut current = PathBuf::from("/");
for component in path.components().skip(1) {
let Component::Normal(name) = component else {
return Err(FileOutputError::UnsafePath);
};
current.push(name);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&current).map_err(|_| FileOutputError::Io)?;
}
Err(_) => return Err(FileOutputError::Io),
}
}
Ok(())
}
fn open_parent_directory(path: &Path) -> Result<File, FileOutputError> {
let fd = rfs::openat2(
rfs::ABS,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS,
)
.map_err(|_| FileOutputError::UnsafePath)?;
let directory = File::from(fd);
let metadata = directory.metadata().map_err(|_| FileOutputError::Io)?;
validate_directory_metadata(&metadata, true)?;
Ok(directory)
}
fn validate_directory_metadata(
metadata: &fs::Metadata,
is_output_parent: bool,
) -> Result<(), FileOutputError> {
let uid = rustix::process::geteuid().as_raw();
if is_output_parent && metadata.uid() != 0 && metadata.uid() != uid {
return Err(FileOutputError::UnsafePath);
}
let mode = metadata.mode();
let group_or_other_writable = mode & 0o022 != 0;
let sticky = mode & 0o1000 != 0;
if group_or_other_writable && (is_output_parent || !sticky) {
return Err(FileOutputError::UnsafePath);
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
output
}
fn reject_protected(path: &Path, protection: &OutputProtection) -> Result<(), FileOutputError> {
for protected in &protection.protected_files {
if lexical_normalize(path)? == lexical_normalize(protected)? {
return Err(FileOutputError::UnsafePath);
}
let target_meta = fs::symlink_metadata(path);
let protected_meta = fs::symlink_metadata(protected);
if let (Ok(target_meta), Ok(protected_meta)) = (target_meta, protected_meta)
&& target_meta.dev() == protected_meta.dev()
&& target_meta.ino() == protected_meta.ino()
{
return Err(FileOutputError::UnsafePath);
}
}
for directory in &protection.protected_directories {
if lexical_normalize(path)?.starts_with(lexical_normalize(directory)?) {
return Err(FileOutputError::UnsafePath);
}
}
Ok(())
}
fn reject_protected_hardlink(
metadata: &fs::Metadata,
protection: &OutputProtection,
) -> Result<(), FileOutputError> {
for protected in &protection.protected_files {
if let Ok(protected_metadata) = fs::symlink_metadata(protected)
&& metadata.dev() == protected_metadata.dev()
&& metadata.ino() == protected_metadata.ino()
{
return Err(FileOutputError::UnsafePath);
}
}
Ok(())
}
fn lexical_normalize(path: &Path) -> Result<PathBuf, FileOutputError> {
validate_absolute_path(path)?;
let mut normalized = PathBuf::from("/");
for component in path.components().skip(1) {
if let Component::Normal(name) = component {
normalized.push(name);
} else {
return Err(FileOutputError::UnsafePath);
}
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::{
Checkpoint, FileOutputError, OutputProtection, PreparedDestination, prepare_destination,
publish, publish_with_hook,
};
use crate::delivery::ClientConfigPayload;
use rustix::fs as rfs;
use std::fs;
use std::io;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::Path;
use zeroize::Zeroizing;
fn payload() -> ClientConfigPayload {
ClientConfigPayload(Zeroizing::new(b"synthetic client configuration\n".to_vec()))
}
fn protection(root: &Path) -> OutputProtection {
OutputProtection::new(
vec![root.join("settings.toml"), root.join("server.conf")],
vec![root.join("Backup/data"), root.join("Backup/archives")],
)
}
#[test]
fn writes_new_file_with_private_mode_and_private_new_directories() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("chosen/nested/client.conf");
let prepared = prepare_destination(&target, &protection(root.path()))
.expect("safe explicit destination");
assert!(!target.exists());
publish(prepared, &payload()).expect("publication succeeds");
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::metadata(root.path().join("chosen"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(root.path().join("chosen/nested"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert!(
!fs::read_dir(root.path().join("chosen/nested"))
.unwrap()
.any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".tmp"))
);
}
#[test]
fn existing_file_requires_bound_confirmation_and_decline_preserves_bytes() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("existing.conf");
fs::write(&target, b"old synthetic value").unwrap();
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
assert!(prepared.overwrite_confirmation_required());
assert_eq!(
publish(prepared, &payload()),
Err(FileOutputError::OverwriteDeclined)
);
assert!(fs::read(&target).unwrap() == b"old synthetic value");
let confirmed = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
publish(confirmed, &payload()).expect("confirmed bounded replacement");
assert!(fs::read(&target).unwrap() == b"synthetic client configuration\n");
assert_eq!(
fs::metadata(target).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn refuses_symlink_special_traversal_and_protected_targets() {
let root = tempfile::tempdir().expect("temp root");
let outside = root.path().join("outside");
fs::write(&outside, b"untouched").unwrap();
let link = root.path().join("link");
symlink(&outside, &link).unwrap();
assert_eq!(
prepare_destination(&link, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(root.path().join("missing/../bad"), &protection(root.path()))
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(root.path().join("settings.toml"), &protection(root.path()))
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(
root.path().join("Backup/data/vault.toml"),
&protection(root.path())
)
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(fs::read(outside).unwrap(), b"untouched");
let fifo = root.path().join("fifo");
rfs::mkfifoat(rfs::ABS, &fifo, rustix::fs::Mode::from_raw_mode(0o600))
.expect("synthetic special file");
assert_eq!(
prepare_destination(&fifo, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
}
#[test]
fn rejects_symlink_ancestors_and_protects_original_source_entries() {
let root = tempfile::tempdir().expect("temp root");
let other = tempfile::tempdir().expect("other temp root");
let link = root.path().join("linked-dir");
symlink(other.path(), &link).unwrap();
assert_eq!(
prepare_destination(link.join("client.conf"), &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
let source = root.path().join("clients");
fs::create_dir(&source).unwrap();
let original = source.join("original.conf");
fs::write(&original, b"source fixture").unwrap();
let policy = OutputProtection::new(vec![original.clone()], vec![]);
assert_eq!(
prepare_destination(&original, &policy).unwrap_err(),
FileOutputError::UnsafePath
);
}
#[test]
fn rejects_group_or_world_writable_output_parent_before_staging_plaintext() {
let root = tempfile::tempdir().expect("temp root");
let shared = root.path().join("shared");
fs::create_dir(&shared).unwrap();
fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).unwrap();
let target = shared.join("client.conf");
assert_eq!(
prepare_destination(&target, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(fs::read_dir(&shared).unwrap().count(), 0);
assert!(!target.exists());
}
#[test]
fn injected_write_and_sync_failures_report_publication_state() {
let root = tempfile::tempdir().expect("temp root");
for (index, failed_stage) in [Checkpoint::Write, Checkpoint::Flush, Checkpoint::FileSync]
.into_iter()
.enumerate()
{
let before_target = root.path().join(format!("pre-publish-{index}.conf"));
let before = prepare_destination(&before_target, &protection(root.path())).unwrap();
let failure = publish_with_hook(before, &payload(), |stage| {
if stage == failed_stage {
Err(io::Error::other("synthetic pre-publication fault"))
} else {
Ok(())
}
});
assert_eq!(failure, Err(FileOutputError::BeforePublication));
assert!(!before_target.exists());
}
let uncertain_target = root.path().join("sync-fail.conf");
let after = prepare_destination(&uncertain_target, &protection(root.path())).unwrap();
let failure = publish_with_hook(after, &payload(), |stage| {
if stage == Checkpoint::ParentSync {
Err(io::Error::other("synthetic flush fault"))
} else {
Ok(())
}
});
assert_eq!(failure, Err(FileOutputError::PublicationUncertain));
assert!(uncertain_target.exists());
assert!(fs::read(uncertain_target).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn cleanup_failure_reports_manual_recovery_and_retains_private_temporary_file() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("cleanup-fail.conf");
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
let failure = publish_with_hook(prepared, &payload(), |stage| match stage {
Checkpoint::Flush | Checkpoint::Cleanup => Err(io::Error::other("synthetic fault")),
_ => Ok(()),
});
assert_eq!(failure, Err(FileOutputError::ManualCleanupRequired));
assert!(!target.exists());
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".wg-admin-client-")
})
.expect("temporary plaintext is retained for manual cleanup");
assert_eq!(
fs::metadata(&temporary).unwrap().permissions().mode() & 0o777,
0o600
);
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn no_replace_publication_keeps_a_racing_new_file_untouched() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("racing.conf");
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
let failure = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::write(&target, b"racing synthetic content")?;
}
Ok(())
});
assert_eq!(failure, Err(FileOutputError::BeforePublication));
assert!(fs::read(target).unwrap() == b"racing synthetic content");
}
#[test]
fn changed_existing_destination_cannot_reuse_old_confirmation() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("replace.conf");
fs::write(&target, b"original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
fs::write(&target, b"changed after selection").unwrap();
assert_eq!(
publish(prepared, &payload()),
Err(FileOutputError::DestinationChanged)
);
assert!(fs::read(target).unwrap() == b"changed after selection");
}
#[test]
fn substitution_at_publish_checkpoint_is_exchanged_back_without_data_loss() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("substituted.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"unconfirmed substitute")?;
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::DestinationChanged));
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("staged output is retained under its private temporary name");
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn substitution_during_rollback_retains_every_entry_and_reports_uncertain() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("substituted-during-rollback.conf");
let parked = root.path().join("parked-published.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"first unconfirmed substitute")?;
} else if stage == Checkpoint::Rollback {
fs::rename(&target, &parked)?;
fs::write(&target, b"second unconfirmed substitute")?;
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
assert!(fs::read(&target).unwrap() == b"first unconfirmed substitute");
assert!(fs::read(parked).unwrap() == b"synthetic client configuration\n");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("second unconfirmed substitute is retained");
assert!(fs::read(temporary).unwrap() == b"second unconfirmed substitute");
}
#[test]
fn rollback_parent_sync_failure_is_uncertain_and_keeps_both_entries() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("rollback-sync-fail.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"unconfirmed substitute")?;
} else if stage == Checkpoint::ParentSync {
return Err(io::Error::other("synthetic rollback directory sync fault"));
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("published staged output remains retained");
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn confirmation_is_not_available_for_a_new_file() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("new.conf");
let prepared: PreparedDestination =
prepare_destination(&target, &protection(root.path())).unwrap();
assert_eq!(
prepared.confirm_overwrite().unwrap_err(),
FileOutputError::OverwriteNotRequired
);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! 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 })
}
#[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"
);
}
}
+1 -1
View File
@@ -256,7 +256,7 @@ impl ClientStates {
}
/// Record an applied state only when the verified observation matches saved clients.
pub fn mark_applied_after_verification(
pub(crate) fn mark_applied_after_verification(
&mut self,
observed: Snapshot,
) -> Result<(), DomainError> {
+4
View File
@@ -1,8 +1,12 @@
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;
+19 -5
View File
@@ -5,7 +5,7 @@ use wireguard_admin::startup::{self, StartupAction};
const USAGE: &str = "Usage: wireguard-admin [--help | --version]\n\
With no arguments, starts the root-only management interface.\n\
The management interface is not implemented yet.";
";
fn main() -> ExitCode {
let arguments: Vec<OsString> = env::args_os().skip(1).collect();
@@ -22,13 +22,27 @@ fn main() -> ExitCode {
println!("wireguard-admin {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Ok(StartupAction::ManagementUnavailable) => {
eprintln!("The management interface is not implemented yet.");
ExitCode::from(1)
}
Ok(StartupAction::Management) => match run_management() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Error: {error}");
ExitCode::from(1)
}
},
Err(error) => {
eprintln!("Error: {error}.\n{USAGE}");
ExitCode::from(2)
}
}
}
fn run_management() -> Result<(), String> {
let executable = env::current_exe().map_err(|_| "cannot locate executable".to_owned())?;
let directory = executable
.parent()
.ok_or_else(|| "executable has no parent directory".to_owned())?;
wireguard_admin::tui::terminal::check_suitable().map_err(|error| error.to_string())?;
let app =
startup::open_management_session(directory, || true).map_err(|error| error.to_string())?;
wireguard_admin::tui::terminal::run(app).map_err(|error| error.to_string())
}
+48
View File
@@ -515,6 +515,54 @@ impl SavedStore {
self.vault_path(),
]
}
/// Paths which client plaintext delivery must not replace. This is a
/// read-only view of the resolved installation mapping; source candidates
/// are protected individually so a safe, newly selected sibling output
/// remains available.
pub(crate) fn delivery_output_protection(
&self,
) -> Result<crate::delivery::files::OutputProtection, crate::delivery::files::FileOutputError>
{
let mut protected_files = self.targets().into_iter().collect::<Vec<_>>();
let source_directory = &self.settings.client_source_directory;
match fs::symlink_metadata(source_directory) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(crate::delivery::files::OutputProtection::new(
protected_files,
vec![
self.settings.data_directory.clone(),
self.settings.archives_directory.clone(),
],
));
}
Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
}
match fs::read_dir(source_directory) {
Ok(entries) => {
let mut count = 0usize;
for entry in entries {
let entry = entry.map_err(|_| crate::delivery::files::FileOutputError::Io)?;
count += 1;
if count > crate::vault::source::MAX_DIRECTORY_ENTRIES {
return Err(crate::delivery::files::FileOutputError::UnsafePath);
}
protected_files.push(entry.path());
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
}
Ok(crate::delivery::files::OutputProtection::new(
protected_files,
vec![
self.settings.data_directory.clone(),
self.settings.archives_directory.clone(),
],
))
}
}
#[derive(Deserialize, Serialize)]
+674
View File
@@ -0,0 +1,674 @@
//! Public, read-only runtime observations and pure saved-peer verification.
//!
//! The transport performs bounded read-only Linux access; comparison remains
//! pure and accepts caller-supplied observations.
use std::collections::{BTreeMap, BTreeSet};
use ipnet::IpNet;
use time::OffsetDateTime;
use crate::domain::PublicKey;
use crate::wireguard::{ConfigDocument, DocumentIssue, PeerIssue};
#[allow(dead_code)] // Integrated by the M5 orchestration step.
pub(crate) mod netlink;
#[allow(dead_code)] // Consumed by the M5 application integration step.
pub(crate) mod transport;
/// A value that could not be observed is distinct from a valid zero or empty value.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Observed<T> {
Available(T),
Unavailable,
}
/// Current statistics for a peer, from the server interface's perspective.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimePeer {
pub allowed_ips: Observed<Vec<IpNet>>,
/// `Available(None)` means WireGuard has never recorded a handshake.
pub last_handshake: Observed<Option<OffsetDateTime>>,
/// These are current counters and may reset when the interface restarts.
pub received_bytes: Observed<u64>,
pub transmitted_bytes: Observed<u64>,
}
/// Whether the peer map is exhaustive enough to establish absence and equality.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerSetCompleteness {
Unknown,
Unavailable,
Partial,
Complete,
}
/// Runtime peers keyed by their public key, with duplicate identities retained
/// as an explicit ambiguity signal rather than silently overwritten.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimePeerSet {
pub peers: BTreeMap<PublicKey, RuntimePeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub completeness: PeerSetCompleteness,
}
impl RuntimePeerSet {
/// Collect records while preserving evidence that the adapter returned a
/// duplicate public key. The first record remains available for diagnostics.
pub fn from_records(
completeness: PeerSetCompleteness,
records: impl IntoIterator<Item = (PublicKey, RuntimePeer)>,
) -> Self {
let mut peers = BTreeMap::new();
let mut duplicate_public_keys = BTreeSet::new();
for (identity, peer) in records {
if let std::collections::btree_map::Entry::Vacant(entry) = peers.entry(identity) {
entry.insert(peer);
} else {
duplicate_public_keys.insert(identity);
}
}
Self {
peers,
duplicate_public_keys,
completeness,
}
}
}
/// Interface presence and peer observation are independent facts.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InterfaceObservation {
Unknown,
Unavailable,
Missing,
Present {
/// Interface returned by the runtime adapter.
name: String,
peers: RuntimePeerSet,
},
}
/// One read-only sample. A missing timestamp means the sample time is unknown.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeObservation {
pub observed_at: Option<OffsetDateTime>,
pub interface: InterfaceObservation,
}
impl RuntimeObservation {
pub const fn unknown() -> Self {
Self {
observed_at: None,
interface: InterfaceObservation::Unknown,
}
}
}
/// One saved peer's complete technical routes, independent of alias metadata
/// and the narrower `domain::Client` address representation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SavedPeer {
pub identity: PublicKey,
pub allowed_ips: BTreeSet<IpNet>,
}
/// Technical peer projection from the authoritative byte-preserving document.
/// Invalid or ambiguous records remain represented as unverifiable entries.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SavedPeerProjection {
pub peers: BTreeMap<PublicKey, SavedPeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub unverifiable_peer_indices: BTreeSet<usize>,
pub document_unverifiable: bool,
}
impl SavedPeerProjection {
/// Project every `[Peer]` section, retaining valid but unsupported route
/// shapes that cannot fit the UI's ordinary client address model.
pub fn from_document(document: &ConfigDocument) -> Self {
let mut peers = BTreeMap::new();
let mut duplicate_public_keys = BTreeSet::new();
let mut unverifiable_peer_indices = BTreeSet::new();
for peer in document.peers() {
let Some(identity) = peer.identity else {
unverifiable_peer_indices.insert(peer.index);
continue;
};
let invalid_route = peer
.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }));
let duplicate_key_field = peer.issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::MalformedPublicKey { .. }
| PeerIssue::DuplicatePublicKeyField { .. }
| PeerIssue::DuplicatePeerIdentity { .. }
)
});
if invalid_route || duplicate_key_field {
unverifiable_peer_indices.insert(peer.index);
}
let routes = peer
.allowed_ips
.iter()
.map(|network| network.trunc())
.collect();
if peers
.insert(
identity,
SavedPeer {
identity,
allowed_ips: routes,
},
)
.is_some()
{
duplicate_public_keys.insert(identity);
unverifiable_peer_indices.insert(peer.index);
}
}
let document_unverifiable = document
.issues()
.iter()
.any(|issue| !matches!(issue, DocumentIssue::ManagedMetadata(_)));
Self {
peers,
duplicate_public_keys,
unverifiable_peer_indices,
document_unverifiable,
}
}
}
/// A technical discrepancy or a reason verification could not safely decide.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ComparisonFinding {
InterfaceUnknown,
InterfaceUnavailable,
InterfaceMissing,
InterfaceMismatch { expected: String, observed: String },
PeerSetUnknown,
PeerSetUnavailable,
PeerSetPartial,
SavedDocumentUnverifiable,
SavedPeerUnverifiable { peer_index: usize },
DuplicateSavedPublicKey { identity: PublicKey },
DuplicateRuntimePublicKey { identity: PublicKey },
MissingRuntimePeer { identity: PublicKey },
ExtraRuntimePeer { identity: PublicKey },
RuntimeRoutesUnavailable { identity: PublicKey },
AllowedIpsMismatch { identity: PublicKey },
}
/// Outcome does not depend on handshake, counters, aliases, or record ordering.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TechnicalMatch {
Match,
Mismatch(Vec<ComparisonFinding>),
Unverifiable(Vec<ComparisonFinding>),
}
/// Compare the complete saved peer set with a runtime sample, requiring the
/// observed interface to be the interface selected by current configuration.
pub fn compare_peers(
saved: &SavedPeerProjection,
expected_interface: &str,
runtime: &RuntimeObservation,
) -> TechnicalMatch {
let mut findings = Vec::new();
let runtime_peers = match &runtime.interface {
InterfaceObservation::Unknown => {
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnknown]);
}
InterfaceObservation::Unavailable => {
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnavailable]);
}
InterfaceObservation::Missing => {
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMissing]);
}
InterfaceObservation::Present { name, peers } => {
if name != expected_interface {
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
expected: expected_interface.to_owned(),
observed: name.clone(),
}]);
}
peers
}
};
if saved.document_unverifiable {
findings.push(ComparisonFinding::SavedDocumentUnverifiable);
}
findings.extend(saved.unverifiable_peer_indices.iter().map(|peer_index| {
ComparisonFinding::SavedPeerUnverifiable {
peer_index: *peer_index,
}
}));
findings.extend(saved.duplicate_public_keys.iter().map(|identity| {
ComparisonFinding::DuplicateSavedPublicKey {
identity: *identity,
}
}));
findings.extend(runtime_peers.duplicate_public_keys.iter().map(|identity| {
ComparisonFinding::DuplicateRuntimePublicKey {
identity: *identity,
}
}));
match runtime_peers.completeness {
PeerSetCompleteness::Unknown => findings.push(ComparisonFinding::PeerSetUnknown),
PeerSetCompleteness::Unavailable => findings.push(ComparisonFinding::PeerSetUnavailable),
PeerSetCompleteness::Partial => findings.push(ComparisonFinding::PeerSetPartial),
PeerSetCompleteness::Complete => {}
}
for (identity, expected) in &saved.peers {
match runtime_peers.peers.get(identity) {
None if runtime_peers.completeness == PeerSetCompleteness::Complete => {
findings.push(ComparisonFinding::MissingRuntimePeer {
identity: *identity,
});
}
None => {}
Some(actual) => match &actual.allowed_ips {
Observed::Unavailable => {
findings.push(ComparisonFinding::RuntimeRoutesUnavailable {
identity: *identity,
})
}
Observed::Available(routes) => {
let normalized: BTreeSet<IpNet> =
routes.iter().map(|network| network.trunc()).collect();
if normalized != expected.allowed_ips {
findings.push(ComparisonFinding::AllowedIpsMismatch {
identity: *identity,
});
}
}
},
}
}
if runtime_peers.completeness == PeerSetCompleteness::Complete {
for identity in runtime_peers.peers.keys() {
if !saved.peers.contains_key(identity) {
findings.push(ComparisonFinding::ExtraRuntimePeer {
identity: *identity,
});
}
}
}
if findings.is_empty() {
TechnicalMatch::Match
} else if findings.iter().any(is_unverifiable_finding) {
TechnicalMatch::Unverifiable(findings)
} else {
TechnicalMatch::Mismatch(findings)
}
}
fn is_unverifiable_finding(finding: &ComparisonFinding) -> bool {
matches!(
finding,
ComparisonFinding::InterfaceUnknown
| ComparisonFinding::InterfaceUnavailable
| ComparisonFinding::PeerSetUnknown
| ComparisonFinding::PeerSetUnavailable
| ComparisonFinding::PeerSetPartial
| ComparisonFinding::SavedDocumentUnverifiable
| ComparisonFinding::SavedPeerUnverifiable { .. }
| ComparisonFinding::DuplicateSavedPublicKey { .. }
| ComparisonFinding::DuplicateRuntimePublicKey { .. }
| ComparisonFinding::RuntimeRoutesUnavailable { .. }
)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
fn key(value: &str) -> PublicKey {
PublicKey::from_str(value).unwrap()
}
fn runtime_peer(routes: Observed<Vec<IpNet>>) -> RuntimePeer {
RuntimePeer {
allowed_ips: routes,
last_handshake: Observed::Available(None),
received_bytes: Observed::Available(0),
transmitted_bytes: Observed::Available(0),
}
}
fn observation(records: Vec<(PublicKey, RuntimePeer)>) -> RuntimeObservation {
RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records),
},
}
}
fn compare(saved: &SavedPeerProjection, runtime: &RuntimeObservation) -> TechnicalMatch {
compare_peers(saved, "wg0", runtime)
}
fn projection(source: &str) -> SavedPeerProjection {
SavedPeerProjection::from_document(&ConfigDocument::parse(source).unwrap())
}
fn route(value: &str) -> IpNet {
value.parse().unwrap()
}
#[test]
fn observation_distinguishes_unknown_missing_unavailable_zero_and_no_handshake() {
assert_eq!(
RuntimeObservation::unknown().interface,
InterfaceObservation::Unknown
);
assert_eq!(
RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Unavailable,
}
.interface,
InterfaceObservation::Unavailable
);
assert!(matches!(
runtime_peer(Observed::Available(Vec::new())).last_handshake,
Observed::Available(None)
));
assert_eq!(
runtime_peer(Observed::Available(Vec::new())).received_bytes,
Observed::Available(0)
);
assert_eq!(
runtime_peer(Observed::Unavailable).allowed_ips,
Observed::Unavailable
);
}
#[test]
fn comparison_accepts_empty_set_and_normalized_reordered_dual_stack_routes() {
let empty = projection("");
assert_eq!(
compare(&empty, &observation(Vec::new())),
TechnicalMatch::Match
);
let source =
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::2/128, 10.0.0.2/32\n");
let expected = projection(&source);
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![
route("10.0.0.2/32"),
route("fd00::2/128"),
route("10.0.0.2/32"),
])),
)]);
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
}
#[test]
fn comparison_requires_the_configured_interface_identity() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let wrong_interface = RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg1".to_owned(),
peers: RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
[(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)],
),
},
};
assert_eq!(
compare_peers(&expected, "wg0", &wrong_interface),
TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
expected: "wg0".to_owned(),
observed: "wg1".to_owned(),
}])
);
}
#[test]
fn counters_and_handshake_changes_do_not_change_technical_match() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let mut before_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
before_restart_peer.last_handshake = Observed::Available(Some(
OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
));
before_restart_peer.received_bytes = Observed::Available(1_000);
before_restart_peer.transmitted_bytes = Observed::Available(300);
let before_restart = observation(vec![(key(KEY_A), before_restart_peer)]);
let mut after_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
after_restart_peer.last_handshake = Observed::Available(None);
after_restart_peer.received_bytes = Observed::Available(0);
after_restart_peer.transmitted_bytes = Observed::Available(7);
let after_restart = observation(vec![(key(KEY_A), after_restart_peer)]);
assert_eq!(compare(&expected, &before_restart), TechnicalMatch::Match);
assert_eq!(compare(&expected, &after_restart), TechnicalMatch::Match);
}
#[test]
fn projection_keeps_valid_unsupported_routes_and_ipv4_only_peers() {
let source = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 10.20.0.0/16\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.3/32\n"
);
let expected = projection(&source);
assert_eq!(expected.peers.len(), 2);
assert!(expected.unverifiable_peer_indices.is_empty());
let actual = observation(vec![
(
key(KEY_A),
runtime_peer(Observed::Available(vec![
route("10.20.0.0/16"),
route("10.0.0.2/32"),
])),
),
(
key(KEY_B),
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
),
]);
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
}
#[test]
fn comparison_reports_missing_extra_and_route_mismatch() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let actual = observation(vec![(
key(KEY_B),
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
)]);
let TechnicalMatch::Mismatch(findings) = compare(&expected, &actual) else {
panic!("expected concrete set differences");
};
assert!(findings.contains(&ComparisonFinding::MissingRuntimePeer {
identity: key(KEY_A)
}));
assert!(findings.contains(&ComparisonFinding::ExtraRuntimePeer {
identity: key(KEY_B)
}));
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.9/32")])),
)]);
assert!(matches!(
compare(&expected, &actual),
TechnicalMatch::Mismatch(findings)
if findings.contains(&ComparisonFinding::AllowedIpsMismatch { identity: key(KEY_A) })
));
}
#[test]
fn partial_duplicate_and_unavailable_records_never_pass() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let duplicate = RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
[
(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
),
(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
),
],
);
assert!(matches!(
compare(
&expected,
&RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: duplicate,
},
}
),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::DuplicateRuntimePublicKey { identity: key(KEY_A) })
));
for completeness in [
PeerSetCompleteness::Unknown,
PeerSetCompleteness::Unavailable,
PeerSetCompleteness::Partial,
] {
let partial = RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: RuntimePeerSet {
peers: BTreeMap::from([(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]),
duplicate_public_keys: BTreeSet::new(),
completeness,
},
},
};
let TechnicalMatch::Unverifiable(findings) = compare(&expected, &partial) else {
panic!("an incomplete peer set cannot verify");
};
if completeness != PeerSetCompleteness::Complete {
assert!(!findings.iter().any(|finding| matches!(
finding,
ComparisonFinding::MissingRuntimePeer { .. }
)));
}
}
let no_routes = observation(vec![(key(KEY_A), runtime_peer(Observed::Unavailable))]);
assert!(matches!(
compare(&expected, &no_routes),
TechnicalMatch::Unverifiable(_)
));
}
#[test]
fn duplicate_saved_identities_are_unverifiable_even_if_the_map_would_match() {
let source = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
);
let expected = projection(&source);
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&expected, &actual),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::DuplicateSavedPublicKey { identity: key(KEY_A) })
));
}
#[test]
fn invalid_saved_peer_and_interface_states_fail_closed_but_metadata_does_not() {
let invalid = projection("[Peer]\nAllowedIPs = 10.0.0.2/32\n");
assert!(matches!(
compare(&invalid, &observation(Vec::new())),
TechnicalMatch::Unverifiable(_)
));
let valid_with_bad_alias = projection(&format!(
"# wireguard-admin:v1 alias=bad%0A date=unknown\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
assert!(!valid_with_bad_alias.document_unverifiable);
for interface in [
InterfaceObservation::Unknown,
InterfaceObservation::Unavailable,
InterfaceObservation::Missing,
] {
let result = compare(
&projection(""),
&RuntimeObservation {
observed_at: None,
interface,
},
);
assert!(!matches!(result, TechnicalMatch::Match));
}
}
#[test]
fn malformed_routes_and_structural_saved_issues_cannot_be_omitted_for_a_match() {
let malformed_route = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, not-a-network\n"
));
assert!(!malformed_route.unverifiable_peer_indices.is_empty());
let apparent_subset_match = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&malformed_route, &apparent_subset_match),
TechnicalMatch::Unverifiable(findings)
if findings.iter().any(|finding| matches!(finding, ComparisonFinding::SavedPeerUnverifiable { .. }))
));
let structural = projection(&format!(
"[Interface]\nMalformed Directive\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
assert!(structural.document_unverifiable);
let exact_runtime = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&structural, &exact_runtime),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::SavedDocumentUnverifiable)
));
}
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
//! Bounded, read-only Linux Generic Netlink transport for WireGuard.
//!
//! Replies are received into zeroizing buffers. The datagram sender address
//! must be kernel port 0; `nlmsg_pid` in request replies is the requester's
//! port ID, as Linux Generic Netlink constructs replies using that destination.
use std::time::{Duration, Instant};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::fd::OwnedFd;
use rustix::io::Errno;
use rustix::net::{
AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, bind, getsockname,
netlink::SocketAddrNetlink, recvfrom, sendto, socket_with,
};
use zeroize::Zeroizing;
use super::netlink::{DecodeContext, DecodeError, DecodeLimits, GetDeviceDecoder};
use super::{InterfaceObservation, PeerSetCompleteness, RuntimeObservation, RuntimePeerSet};
const NETLINK_GENERIC: u32 = 16;
const CTRL_FAMILY_ID: u16 = 0x10;
const CTRL_CMD_GETFAMILY: u8 = 3;
const CTRL_CMD_NEWFAMILY: u8 = 1;
const CTRL_ATTR_FAMILY_ID: u16 = 1;
const CTRL_ATTR_FAMILY_NAME: u16 = 2;
const WG_CMD_GET_DEVICE: u8 = 0;
const WG_GENL_VERSION: u8 = 1;
const WG_ATTR_IFNAME: u16 = 2;
const NLMSG_ERROR: u16 = 2;
const NLMSG_DONE: u16 = 3;
const NLMSG_REQUEST: u16 = 1;
const NLMSG_DUMP: u16 = 0x300;
const NLMSG_MULTI: u16 = 2;
const MAX_DATAGRAM: usize = 1024 * 1024;
const MAX_INTERFACE_NAME: usize = 15;
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum TransportError {
#[error("configured WireGuard interface was not found")]
NoDevice,
#[error("permission denied while reading WireGuard state")]
PermissionDenied,
#[error("WireGuard netlink response was malformed or incomplete")]
Malformed,
#[error("WireGuard netlink transport failed")]
Transport,
#[error("WireGuard netlink request timed out")]
Timeout,
#[error("WireGuard netlink response exceeded configured limits")]
LimitExceeded,
}
#[derive(Debug)]
struct Datagram {
bytes: Zeroizing<Vec<u8>>,
sender: u32,
}
trait Io {
fn now(&self) -> Duration;
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError>;
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError>;
fn port_id(&self) -> u32;
}
struct LinuxIo {
socket: OwnedFd,
port_id: u32,
origin: Instant,
}
impl LinuxIo {
fn new() -> Result<Self, TransportError> {
let socket = socket_with(
AddressFamily::NETLINK,
SocketType::DGRAM,
SocketFlags::CLOEXEC.union(SocketFlags::NONBLOCK),
Some(rustix::net::netlink::GENERIC),
)
.map_err(map_errno)?;
bind(&socket, &SocketAddrNetlink::new(0, 0)).map_err(map_errno)?;
let addr = SocketAddrNetlink::try_from(getsockname(&socket).map_err(map_errno)?)
.map_err(|_| TransportError::Transport)?;
Ok(Self {
socket,
port_id: addr.pid(),
origin: Instant::now(),
})
}
}
impl Io for LinuxIo {
fn now(&self) -> Duration {
self.origin.elapsed()
}
fn port_id(&self) -> u32 {
self.port_id
}
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
let sent = sendto(
&self.socket,
bytes,
SendFlags::empty(),
&SocketAddrNetlink::new(destination, 0),
)
.map_err(map_errno)?;
if sent == bytes.len() {
Ok(())
} else {
Err(TransportError::Transport)
}
}
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
let timeout = Timespec {
tv_sec: wait.as_secs().min(i64::MAX as u64) as i64,
tv_nsec: wait.subsec_nanos() as i64,
};
let mut fds = [PollFd::new(&self.socket, PollFlags::IN)];
if poll(&mut fds, Some(&timeout)).map_err(map_errno)? == 0 {
return Err(TransportError::Timeout);
}
let mut bytes = Zeroizing::new(vec![0; MAX_DATAGRAM]);
let (_, count, address) = recvfrom(
&self.socket,
&mut bytes[..],
RecvFlags::TRUNC.union(RecvFlags::DONTWAIT),
)
.map_err(map_errno)?;
if count > MAX_DATAGRAM {
return Err(TransportError::LimitExceeded);
}
let sender = address
.and_then(|a| SocketAddrNetlink::try_from(a).ok())
.ok_or(TransportError::Malformed)?
.pid();
bytes.truncate(count);
Ok(Datagram { bytes, sender })
}
}
fn map_errno(error: Errno) -> TransportError {
if error == Errno::ACCESS || error == Errno::PERM {
TransportError::PermissionDenied
} else if error == Errno::NOENT || error == Errno::NODEV {
TransportError::NoDevice
} else if error == Errno::TIMEDOUT {
TransportError::Timeout
} else {
TransportError::Transport
}
}
fn map_kernel_error(error: i32) -> TransportError {
if error == Errno::ACCESS.raw_os_error() || error == Errno::PERM.raw_os_error() {
TransportError::PermissionDenied
} else if error == Errno::NOENT.raw_os_error() || error == Errno::NODEV.raw_os_error() {
TransportError::NoDevice
} else {
TransportError::Transport
}
}
pub(crate) fn observe_interface(
interface: &str,
timeout: Duration,
limits: DecodeLimits,
) -> Result<RuntimeObservation, TransportError> {
let mut io = LinuxIo::new()?;
observe_with_io(&mut io, interface, timeout, limits)
}
fn observe_with_io(
io: &mut impl Io,
interface: &str,
timeout: Duration,
limits: DecodeLimits,
) -> Result<RuntimeObservation, TransportError> {
if interface.is_empty()
|| interface.len() > MAX_INTERFACE_NAME
|| interface.as_bytes().contains(&0)
|| timeout.is_zero()
{
return Err(TransportError::Malformed);
}
let start = io.now();
let deadline = start.checked_add(timeout).ok_or(TransportError::Timeout)?;
let family = resolve_family(io, deadline)?;
let seq = 2;
let request = get_device_request(family, seq, io.port_id(), interface);
io.send(0, &request)?;
let context = DecodeContext {
sequence: seq,
port_id: io.port_id(),
family_id: family,
};
let mut decoder = GetDeviceDecoder::new(context, interface, limits);
loop {
let datagram = receive_before(io, deadline)?;
if datagram.sender != 0 {
return Err(TransportError::Malformed);
}
decoder.push_datagram(&datagram.bytes).map_err(map_decode)?;
// Decoder only returns a device after its explicit terminal DONE.
if decoder.is_done() {
let device = decoder.finish().map_err(map_decode)?;
return Ok(RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: device.interface_name,
peers: RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
device.peers,
),
},
});
}
}
}
fn receive_before(io: &mut impl Io, deadline: Duration) -> Result<Datagram, TransportError> {
let remaining = deadline
.checked_sub(io.now())
.ok_or(TransportError::Timeout)?;
if remaining.is_zero() {
return Err(TransportError::Timeout);
}
io.receive(remaining)
}
fn resolve_family(io: &mut impl Io, deadline: Duration) -> Result<u16, TransportError> {
let seq = 1;
let mut request = message(CTRL_FAMILY_ID, NLMSG_REQUEST, seq, io.port_id());
request.extend_from_slice(&[CTRL_CMD_GETFAMILY, 1, 0, 0]);
push_attr(&mut request, CTRL_ATTR_FAMILY_NAME, b"wireguard\0");
set_len(&mut request);
io.send(0, &request)?;
let d = receive_before(io, deadline)?;
if d.sender != 0 {
return Err(TransportError::Malformed);
}
parse_family(&d.bytes, seq, io.port_id())
}
fn parse_family(bytes: &[u8], seq: u32, port: u32) -> Result<u16, TransportError> {
if bytes.len() < 20 {
return Err(TransportError::Malformed);
}
if read_u32(bytes, 0)? as usize != bytes.len()
|| read_u32(bytes, 8)? != seq
|| read_u32(bytes, 12)? != port
{
return Err(TransportError::Malformed);
}
let message_type = read_u16(bytes, 4)?;
if message_type == NLMSG_ERROR {
let status = read_u32(bytes, 16)? as i32;
if status == 0 {
return Err(TransportError::Malformed);
}
return Err(status
.checked_neg()
.map(map_kernel_error)
.ok_or(TransportError::Malformed)?);
}
if message_type != CTRL_FAMILY_ID {
return Err(TransportError::Malformed);
}
if bytes[16] != CTRL_CMD_NEWFAMILY || read_u16(bytes, 18)? != 0 {
return Err(TransportError::Malformed);
}
let mut off = 20;
while off < bytes.len() {
if bytes.len() - off < 4 {
return Err(TransportError::Malformed);
}
let len = read_u16(bytes, off)? as usize;
let kind = read_u16(bytes, off + 2)? & 0x3fff;
if len < 4 || off + len > bytes.len() {
return Err(TransportError::Malformed);
}
if kind == CTRL_ATTR_FAMILY_ID && len == 6 {
return read_u16(bytes, off + 4);
}
let aligned_len = len.checked_add(3).ok_or(TransportError::Malformed)? & !3;
off = off
.checked_add(aligned_len)
.filter(|&end| end <= bytes.len())
.ok_or(TransportError::Malformed)?;
}
Err(TransportError::NoDevice)
}
fn get_device_request(family: u16, seq: u32, port: u32, interface: &str) -> Vec<u8> {
let mut out = message(family, NLMSG_REQUEST | NLMSG_DUMP, seq, port);
out.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
let mut interface_name = interface.as_bytes().to_vec();
interface_name.push(0);
push_attr(&mut out, WG_ATTR_IFNAME, &interface_name);
set_len(&mut out);
out
}
fn message(kind: u16, flags: u16, seq: u32, port: u32) -> Vec<u8> {
let mut out = vec![0; 16];
out[4..6].copy_from_slice(&kind.to_ne_bytes());
out[6..8].copy_from_slice(&flags.to_ne_bytes());
out[8..12].copy_from_slice(&seq.to_ne_bytes());
out[12..16].copy_from_slice(&port.to_ne_bytes());
out
}
fn push_attr(out: &mut Vec<u8>, kind: u16, value: &[u8]) {
let len = (4 + value.len()) as u16;
out.extend_from_slice(&len.to_ne_bytes());
out.extend_from_slice(&kind.to_ne_bytes());
out.extend_from_slice(value);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
fn set_len(out: &mut [u8]) {
let len = out.len() as u32;
out[..4].copy_from_slice(&len.to_ne_bytes());
}
fn read_u16(bytes: &[u8], at: usize) -> Result<u16, TransportError> {
bytes
.get(at..at + 2)
.and_then(|b| b.try_into().ok())
.map(u16::from_ne_bytes)
.ok_or(TransportError::Malformed)
}
fn read_u32(bytes: &[u8], at: usize) -> Result<u32, TransportError> {
bytes
.get(at..at + 4)
.and_then(|b| b.try_into().ok())
.map(u32::from_ne_bytes)
.ok_or(TransportError::Malformed)
}
fn map_decode(error: DecodeError) -> TransportError {
match error {
DecodeError::KernelError(n) if n == Errno::NODEV.raw_os_error() => TransportError::NoDevice,
DecodeError::KernelError(n)
if n == Errno::ACCESS.raw_os_error() || n == Errno::PERM.raw_os_error() =>
{
TransportError::PermissionDenied
}
DecodeError::LimitExceeded => TransportError::LimitExceeded,
DecodeError::KernelError(_) | DecodeError::DumpFailed => TransportError::Transport,
_ => TransportError::Malformed,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctrl_family_message(sequence: u32, port: u32, family: u16) -> Vec<u8> {
let mut out = message(CTRL_FAMILY_ID, 0, sequence, port);
out.extend_from_slice(&[CTRL_CMD_NEWFAMILY, 2, 0, 0]);
push_attr(&mut out, CTRL_ATTR_FAMILY_ID, &family.to_ne_bytes());
set_len(&mut out);
out
}
#[test]
fn resolves_family_only_from_matching_kernel_reply() {
assert_eq!(parse_family(&ctrl_family_message(1, 55, 42), 1, 55), Ok(42));
assert_eq!(
parse_family(&ctrl_family_message(2, 55, 42), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&ctrl_family_message(1, 56, 42), 1, 55),
Err(TransportError::Malformed)
);
}
fn netlink_error(sequence: u32, port: u32, status: i32) -> Vec<u8> {
let mut out = message(NLMSG_ERROR, 0, sequence, port);
out.extend_from_slice(&status.to_ne_bytes());
set_len(&mut out);
out
}
#[test]
fn family_errors_map_kernel_errno_after_validating_reply_identity() {
for errno in [Errno::NOENT, Errno::NODEV] {
assert_eq!(
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
Err(TransportError::NoDevice)
);
}
for errno in [Errno::ACCESS, Errno::PERM] {
assert_eq!(
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
Err(TransportError::PermissionDenied)
);
}
assert_eq!(
parse_family(&netlink_error(1, 55, -Errno::IO.raw_os_error()), 1, 55),
Err(TransportError::Transport)
);
assert_eq!(
parse_family(&netlink_error(1, 55, i32::MIN), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(1, 55, 0), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(2, 55, -Errno::NOENT.raw_os_error()), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(1, 56, -Errno::NOENT.raw_os_error()), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(
&netlink_error(1, 55, -Errno::NOENT.raw_os_error())[..19],
1,
55
),
Err(TransportError::Malformed)
);
}
#[test]
fn family_resolution_propagates_kernel_errors_from_injected_transport() {
for (errno, expected) in [
(Errno::NODEV, TransportError::NoDevice),
(Errno::ACCESS, TransportError::PermissionDenied),
(Errno::IO, TransportError::Transport),
] {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(netlink_error(1, 55, -errno.raw_os_error()), 0));
assert_eq!(
resolve_family(&mut fake, Duration::from_secs(1)),
Err(expected)
);
assert_eq!(fake.sent.len(), 1);
}
}
#[test]
fn get_request_targets_only_named_interface_and_dump_flags() {
let request = get_device_request(42, 7, 55, "wg-test");
assert_eq!(read_u16(&request, 4), Ok(42));
assert_eq!(read_u16(&request, 6), Ok(NLMSG_REQUEST | NLMSG_DUMP));
assert_eq!(
&request[16..20],
&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]
);
assert_eq!(read_u16(&request, 20), Ok(12));
assert_eq!(&request[22..24], &2u16.to_ne_bytes());
assert_eq!(&request[24..32], b"wg-test\0");
assert_eq!(request.len(), 32);
assert_eq!(read_u32(&request, 0), Ok(request.len() as u32));
}
#[derive(Default)]
struct FakeIo {
clock: Duration,
port: u32,
sent: Vec<(u32, Vec<u8>)>,
incoming: std::collections::VecDeque<Datagram>,
}
impl Io for FakeIo {
fn now(&self) -> Duration {
self.clock
}
fn port_id(&self) -> u32 {
self.port
}
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
self.sent.push((destination, bytes.to_vec()));
Ok(())
}
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
if let Some(datagram) = self.incoming.pop_front() {
Ok(datagram)
} else {
self.clock += wait;
Err(TransportError::Timeout)
}
}
}
fn datagram(bytes: Vec<u8>, sender: u32) -> Datagram {
Datagram {
bytes: Zeroizing::new(bytes),
sender,
}
}
#[test]
fn injected_transport_rejects_non_kernel_sender_and_bounds_resolution_wait() {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(ctrl_family_message(1, 55, 42), 22));
assert_eq!(
resolve_family(&mut fake, Duration::from_secs(1)),
Err(TransportError::Malformed)
);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, 0);
let mut silent = FakeIo {
port: 55,
..FakeIo::default()
};
assert_eq!(
resolve_family(&mut silent, Duration::from_millis(5)),
Err(TransportError::Timeout)
);
assert_eq!(silent.clock, Duration::from_millis(5));
}
#[test]
fn injected_transport_returns_only_a_complete_public_empty_observation() {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(ctrl_family_message(1, 55, 42), 0));
let mut response = message(42, NLMSG_MULTI, 2, 55);
response.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
push_attr(&mut response, 2, b"wg0\0");
push_attr(&mut response, 8 | 0x8000, &[]);
set_len(&mut response);
let mut complete = response;
let done_start = complete.len();
complete.extend_from_slice(&message(NLMSG_DONE, 0, 2, 55));
complete.extend_from_slice(&0i32.to_ne_bytes());
set_len(&mut complete[done_start..]);
fake.incoming.push_back(datagram(complete, 0));
let observation = observe_with_io(
&mut fake,
"wg0",
Duration::from_secs(2),
DecodeLimits::default(),
)
.unwrap();
assert!(
matches!(observation.interface, InterfaceObservation::Present { ref name, ref peers } if name == "wg0" && peers.completeness == PeerSetCompleteness::Complete && peers.peers.is_empty())
);
assert_eq!(fake.sent.len(), 2);
assert!(fake.sent.iter().all(|(destination, _)| *destination == 0));
}
}
+469
View File
@@ -0,0 +1,469 @@
//! 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 delivered by the future D-Bus adapter.
#[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,
}
/// 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,
}
/// 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)
);
}
}
+680
View File
@@ -0,0 +1,680 @@
//! Bounded asynchronous systemd D-Bus restart adapter.
//!
//! zbus's `async-io` backend owns its socket-driving thread. This module uses
//! a small standard-library executor to poll each caller-side future only
//! until the shared monotonic deadline; it does not use zbus's blocking signal
//! iterator, whose `next` call has no application deadline.
use std::future::{Future, poll_fn};
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread;
use std::time::Instant;
use zbus::export::futures_core::Stream;
use zbus::message::Type;
use zbus::{Connection, MatchRule, MessageStream, Proxy};
use super::{
JobResult, RestartDeadline, RestartExecution, RestartFailure, RestartJobTracker,
RestartOutcome, ServiceEvent,
};
const SYSTEMD_NAME: &str = "org.freedesktop.systemd1";
const SYSTEMD_PATH: &str = "/org/freedesktop/systemd1";
const SYSTEMD_MANAGER: &str = "org.freedesktop.systemd1.Manager";
const DBUS_NAME: &str = "org.freedesktop.DBus";
const DBUS_INTERFACE: &str = "org.freedesktop.DBus";
/// Resolve the configured unit's primary ID, request a restart of exactly
/// `configured_unit`, and wait for its matching systemd `JobRemoved` result.
/// The caller owns backup ordering and subsequent runtime verification, and
/// must pass the same deadline to those stages.
/// Restart a configured unit and retain whether a timed-out request was submitted.
pub(crate) fn restart_systemd_unit_report(
configured_unit: &str,
deadline: RestartDeadline,
) -> RestartExecution {
restart_with_transport_report(&mut SystemdBus::default(), configured_unit, deadline)
}
trait RestartTransport {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure>;
fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure>;
fn resolve_primary_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn next_event(&mut self, deadline: RestartDeadline) -> Result<ServiceEvent, RestartFailure>;
}
#[cfg(test)]
fn restart_with_transport(
transport: &mut impl RestartTransport,
configured_unit: &str,
deadline: RestartDeadline,
) -> RestartOutcome {
restart_with_transport_report(transport, configured_unit, deadline).outcome
}
fn restart_with_transport_report(
transport: &mut impl RestartTransport,
configured_unit: &str,
deadline: RestartDeadline,
) -> RestartExecution {
if configured_unit.is_empty() || configured_unit.as_bytes().contains(&0) {
return execution(RestartOutcome::Failed(RestartFailure::RequestFailed), false);
}
if let Err(failure) = transport.connect(deadline) {
return execution(RestartOutcome::Failed(failure), false);
}
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
);
}
if let Err(failure) = transport.subscribe(deadline) {
return execution(RestartOutcome::Failed(failure), false);
}
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
);
}
let primary_unit = match transport.resolve_primary_unit(configured_unit, deadline) {
Ok(unit) if !unit.is_empty() => unit,
Ok(_) => {
return execution(
RestartOutcome::Failed(RestartFailure::UnitResolutionFailed),
false,
);
}
Err(failure) => return execution(RestartOutcome::Failed(failure), false),
};
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
);
}
let job_path = match transport.restart_unit(configured_unit, deadline) {
Ok(path) => path,
Err(failure) => {
let submitted = matches!(
failure,
RestartFailure::WaitTimedOutMayStillComplete | RestartFailure::BusDisconnected
);
return execution(RestartOutcome::Failed(failure), submitted);
}
};
let mut tracker = RestartJobTracker::new(primary_unit, deadline);
let mut outcome = tracker.job_submitted(job_path, Instant::now());
while outcome == RestartOutcome::Waiting {
let event = match transport.next_event(deadline) {
Ok(event) => event,
Err(failure) => return execution(RestartOutcome::Failed(failure), true),
};
outcome = tracker.observe(event, Instant::now());
}
execution(outcome, true)
}
fn execution(outcome: RestartOutcome, submitted: bool) -> RestartExecution {
RestartExecution { outcome, submitted }
}
#[derive(Default)]
struct SystemdBus {
connection: Option<Connection>,
manager: Option<Proxy<'static>>,
job_events: Option<zbus::proxy::SignalStream<'static>>,
owner_events: Option<zbus::MessageStream>,
}
impl RestartTransport for SystemdBus {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
let connection = drive(Connection::system(), deadline)?.map_err(map_bus_error)?;
let manager = drive(
Proxy::new_owned(
connection.clone(),
SYSTEMD_NAME,
SYSTEMD_PATH,
SYSTEMD_MANAGER,
),
deadline,
)?
.map_err(map_bus_error)?;
self.manager = Some(manager);
self.connection = Some(connection);
Ok(())
}
fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
let manager = self
.manager
.as_ref()
.ok_or(RestartFailure::SubscriptionFailed)?;
let job_events = drive(manager.receive_signal("JobRemoved"), deadline)?
.map_err(|_| RestartFailure::SubscriptionFailed)?;
let owner_rule = MatchRule::builder()
.msg_type(Type::Signal)
.sender(DBUS_NAME)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.interface(DBUS_INTERFACE)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.member("NameOwnerChanged")
.map_err(|_| RestartFailure::SubscriptionFailed)?
.add_arg(SYSTEMD_NAME)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.build();
let owner_events = drive(
MessageStream::for_match_rule(
owner_rule,
self.connection
.as_ref()
.ok_or(RestartFailure::SubscriptionFailed)?,
Some(1),
),
deadline,
)?
.map_err(|_| RestartFailure::SubscriptionFailed)?;
// Both signal matches are installed before Subscribe and RestartUnit.
self.job_events = Some(job_events);
self.owner_events = Some(owner_events);
drive(manager.call::<_, _, ()>("Subscribe", &()), deadline)?.map_err(map_bus_error)
}
fn resolve_primary_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let manager = self
.manager
.as_ref()
.ok_or(RestartFailure::UnitResolutionFailed)?;
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let unit_path: zbus::zvariant::OwnedObjectPath =
drive(manager.call("GetUnit", &(configured_unit,)), deadline)?
.map_err(map_unit_resolution_error)?;
let unit = drive(
Proxy::new_owned(
connection.clone(),
SYSTEMD_NAME,
unit_path,
"org.freedesktop.systemd1.Unit",
),
deadline,
)?
.map_err(map_unit_resolution_error)?;
drive(unit.get_property::<String>("Id"), deadline)?.map_err(map_unit_resolution_error)
}
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let manager = self.manager.as_ref().ok_or(RestartFailure::RequestFailed)?;
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let owner_events = self
.owner_events
.as_mut()
.ok_or(RestartFailure::BusDisconnected)?;
let request_body = (unit, "replace");
let request = manager.call("RestartUnit", &request_body);
let job_path: zbus::zvariant::OwnedObjectPath = drive(
async {
let mut request = pin!(request);
let mut closed = pin!(connection.closed());
poll_fn(|cx| {
if connection.is_closed() {
return Poll::Ready(Err(RestartFailure::BusDisconnected));
}
if let Poll::Ready(event) = Stream::poll_next(Pin::new(&mut *owner_events), cx)
{
return Poll::Ready(match event {
None => Err(RestartFailure::BusDisconnected),
Some(Err(error)) => Err(map_bus_error(error)),
Some(Ok(_)) => Err(RestartFailure::ManagerOwnerChanged),
});
}
if let Poll::Ready(result) = Future::poll(request.as_mut(), cx) {
return Poll::Ready(result.map_err(map_bus_error));
}
if Future::poll(closed.as_mut(), cx).is_ready() {
return Poll::Ready(Err(RestartFailure::BusDisconnected));
}
Poll::Pending
})
.await
},
deadline,
)??;
Ok(job_path.to_string())
}
fn next_event(&mut self, deadline: RestartDeadline) -> Result<ServiceEvent, RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let job_events = self
.job_events
.as_mut()
.ok_or(RestartFailure::BusDisconnected)?;
let owner_events = self
.owner_events
.as_mut()
.ok_or(RestartFailure::BusDisconnected)?;
drive(
async {
let mut closed = pin!(connection.closed());
poll_fn(|cx| {
if connection.is_closed() {
return Poll::Ready(Ok(ServiceEvent::Disconnected));
}
if let Poll::Ready(message) =
Stream::poll_next(Pin::new(&mut *owner_events), cx)
{
return Poll::Ready(match message {
None => Ok(ServiceEvent::Disconnected),
Some(Err(error)) => Err(map_bus_error(error)),
Some(Ok(message)) => {
let decoded: Result<(String, String, String), _> =
message.body().deserialize();
match decoded {
Ok((name, _, _)) if name == SYSTEMD_NAME => {
Ok(ServiceEvent::ManagerOwnerChanged)
}
Ok(_) => Ok(ServiceEvent::JobRemoved {
job_path: String::new(),
unit: String::new(),
result: JobResult::Other,
}),
Err(_) => Err(RestartFailure::RequestFailed),
}
}
});
}
if let Poll::Ready(message) = Stream::poll_next(Pin::new(&mut *job_events), cx)
{
return Poll::Ready(match message {
None => Ok(ServiceEvent::Disconnected),
Some(message) => {
let decoded: Result<
(u32, zbus::zvariant::OwnedObjectPath, String, String),
_,
> = message.body().deserialize();
match decoded {
Ok((_, path, unit, result)) => Ok(ServiceEvent::JobRemoved {
job_path: path.to_string(),
unit,
result: map_job_result(&result),
}),
Err(_) => Err(RestartFailure::RequestFailed),
}
}
});
}
if Future::poll(closed.as_mut(), cx).is_ready() {
return Poll::Ready(Ok(ServiceEvent::Disconnected));
}
Poll::Pending
})
.await
},
deadline,
)?
}
}
fn map_job_result(result: &str) -> JobResult {
match result {
"done" => JobResult::Done,
"failed" => JobResult::Failed,
"dependency" => JobResult::Dependency,
"timeout" => JobResult::Timeout,
"canceled" => JobResult::Canceled,
"skipped" => JobResult::Skipped,
_ => JobResult::Other,
}
}
fn map_bus_error(error: zbus::Error) -> RestartFailure {
match error {
zbus::Error::MethodError(name, _, _)
if name.as_str() == "org.freedesktop.DBus.Error.AccessDenied"
|| name.as_str() == "org.freedesktop.systemd1.AccessDenied" =>
{
RestartFailure::PermissionDenied
}
zbus::Error::InputOutput(_) | zbus::Error::Handshake(_) => RestartFailure::BusDisconnected,
_ => RestartFailure::RequestFailed,
}
}
fn map_unit_resolution_error(error: zbus::Error) -> RestartFailure {
match map_bus_error(error) {
RestartFailure::PermissionDenied => RestartFailure::PermissionDenied,
RestartFailure::BusDisconnected => RestartFailure::BusDisconnected,
_ => RestartFailure::UnitResolutionFailed,
}
}
struct ThreadWake(thread::Thread);
impl Wake for ThreadWake {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
/// Poll a future on this thread, parking only for the remaining operation
/// budget. zbus's async-io connection executor continues driving socket I/O
/// independently and wakes this thread when the future can make progress.
fn drive<F: Future>(future: F, deadline: RestartDeadline) -> Result<F::Output, RestartFailure> {
let waker = Waker::from(Arc::new(ThreadWake(thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = pin!(future);
loop {
if deadline.is_expired_at(Instant::now()) {
return Err(RestartFailure::WaitTimedOutMayStillComplete);
}
match Future::poll(future.as_mut(), &mut context) {
Poll::Ready(output) => return Ok(output),
Poll::Pending => {
let remaining = deadline.remaining_at(Instant::now());
if remaining.is_zero() {
return Err(RestartFailure::WaitTimedOutMayStillComplete);
}
thread::park_timeout(remaining);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::time::Duration;
struct FakeBus {
calls: Vec<&'static str>,
deadlines: Vec<(&'static str, RestartDeadline)>,
events: VecDeque<ServiceEvent>,
failure: Option<(&'static str, RestartFailure)>,
configured_unit: &'static str,
job_path: &'static str,
primary_unit: &'static str,
}
impl Default for FakeBus {
fn default() -> Self {
Self {
calls: Vec::new(),
deadlines: Vec::new(),
events: VecDeque::new(),
failure: None,
configured_unit: "wg-quick@wg0.service",
job_path: "",
primary_unit: "wg-quick@wg0.service",
}
}
}
impl FakeBus {
fn stage(&mut self, stage: &'static str) -> Result<(), RestartFailure> {
self.calls.push(stage);
match self.failure {
Some((failed_stage, failure)) if failed_stage == stage => Err(failure),
_ => Ok(()),
}
}
}
impl RestartTransport for FakeBus {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
self.deadlines.push(("connect", deadline));
self.stage("connect")
}
fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
self.deadlines.push(("subscribe", deadline));
self.stage("subscribe")
}
fn resolve_primary_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
assert_eq!(configured_unit, self.configured_unit);
self.deadlines.push(("resolve", deadline));
self.stage("resolve")?;
Ok(self.primary_unit.to_owned())
}
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
assert_eq!(unit, self.configured_unit);
self.deadlines.push(("restart", deadline));
self.stage("restart")?;
Ok(self.job_path.to_owned())
}
fn next_event(
&mut self,
deadline: RestartDeadline,
) -> Result<ServiceEvent, RestartFailure> {
self.deadlines.push(("event", deadline));
self.stage("event")?;
self.events
.pop_front()
.ok_or(RestartFailure::BusDisconnected)
}
}
fn deadline() -> RestartDeadline {
RestartDeadline::new(Instant::now(), Duration::from_secs(2))
}
fn removed(path: &str, unit: &str, result: JobResult) -> ServiceEvent {
ServiceEvent::JobRemoved {
job_path: path.to_owned(),
unit: unit.to_owned(),
result,
}
}
#[test]
fn subscribes_before_restart_and_uses_returned_job_path() {
let mut fake = FakeBus {
job_path: "/job/actual",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([
removed("/job/other", "wg-quick@wg0.service", JobResult::Done),
removed("/job/actual", "wg-quick@wg0.service", JobResult::Done),
]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()),
RestartOutcome::Succeeded
);
assert_eq!(
fake.calls,
[
"connect",
"subscribe",
"resolve",
"restart",
"event",
"event"
]
);
}
#[test]
fn fast_completion_and_failure_results_are_processed_without_a_bus() {
let mut fast = FakeBus {
job_path: "/job/fast",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([removed(
"/job/fast",
"wg-quick@wg0.service",
JobResult::Done,
)]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fast, "wg-quick@wg0.service", deadline()),
RestartOutcome::Succeeded
);
let mut failed = FakeBus {
job_path: "/job/failed",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([removed(
"/job/failed",
"wg-quick@wg0.service",
JobResult::Canceled,
)]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut failed, "wg-quick@wg0.service", deadline()),
RestartOutcome::Failed(RestartFailure::JobCanceled)
);
}
#[test]
fn setup_and_permission_failures_stop_before_restart() {
for (stage, failure) in [
("connect", RestartFailure::BusDisconnected),
("subscribe", RestartFailure::SubscriptionFailed),
("resolve", RestartFailure::UnitResolutionFailed),
("restart", RestartFailure::PermissionDenied),
] {
let mut fake = FakeBus {
job_path: "/job/never",
primary_unit: "wg-quick@wg0.service",
failure: Some((stage, failure)),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()),
RestartOutcome::Failed(failure)
);
if stage != "restart" {
assert!(!fake.calls.contains(&"restart"));
}
}
}
#[test]
fn resolves_alias_before_restart_and_uses_one_shared_deadline() {
let deadline = deadline();
let mut fake = FakeBus {
job_path: "/job/alias",
configured_unit: "wireguard-vpn.service",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([removed(
"/job/alias",
"wg-quick@wg0.service",
JobResult::Done,
)]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wireguard-vpn.service", deadline),
RestartOutcome::Succeeded
);
assert_eq!(
fake.calls,
["connect", "subscribe", "resolve", "restart", "event"]
);
assert!(fake.deadlines.iter().all(|(_, passed)| *passed == deadline));
}
#[test]
fn expired_shared_deadline_stops_between_setup_and_submission() {
let mut fake = FakeBus {
job_path: "/job/never",
..FakeBus::default()
};
let expired = RestartDeadline::new(Instant::now(), Duration::ZERO);
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", expired),
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete)
);
assert_eq!(fake.calls, ["connect"]);
}
#[test]
fn result_mapping_accepts_only_the_documented_done_value() {
assert_eq!(map_job_result("done"), JobResult::Done);
for value in ["failed", "dependency", "timeout", "canceled", "skipped"] {
assert_ne!(map_job_result(value), JobResult::Done);
}
assert_eq!(map_job_result("future-result"), JobResult::Other);
}
#[test]
fn std_wake_park_driver_rejects_an_already_expired_future() {
let expired = RestartDeadline::new(Instant::now(), Duration::ZERO);
assert_eq!(
drive(std::future::pending::<()>(), expired),
Err(RestartFailure::WaitTimedOutMayStillComplete)
);
}
#[test]
fn owner_change_and_disconnect_fail_the_attempt() {
for (event, failure) in [
(
ServiceEvent::ManagerOwnerChanged,
RestartFailure::ManagerOwnerChanged,
),
(ServiceEvent::Disconnected, RestartFailure::BusDisconnected),
] {
let mut fake = FakeBus {
job_path: "/job/actual",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([event]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()),
RestartOutcome::Failed(failure)
);
}
}
}
+138 -4
View File
@@ -1,11 +1,20 @@
use std::ffi::OsString;
use std::fmt;
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
use crate::app::Application;
use crate::config::{ResolvedSettings, SettingsFile};
use crate::persistence::SavedStore;
const MAX_SETTINGS_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StartupAction {
Help,
Version,
ManagementUnavailable,
Management,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -25,6 +34,64 @@ impl fmt::Display for StartupError {
impl std::error::Error for StartupError {}
/// Fixed, secret-free reasons management session startup can fail.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionLoadError {
RootRequired,
SettingsUnavailable,
SettingsInvalid,
StoreInvalid,
SavedStateInvalid,
}
impl fmt::Display for SessionLoadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::RootRequired => "management operations require root",
Self::SettingsUnavailable => "executable-relative settings are unavailable",
Self::SettingsInvalid => "executable-relative settings are invalid",
Self::StoreInvalid => "saved-state paths are unsafe or inconsistent",
Self::SavedStateInvalid => "saved state could not be loaded safely",
})
}
}
impl std::error::Error for SessionLoadError {}
/// Load a management session only after checking privilege. Settings are read
/// beside the executable, then SavedStore performs journal recovery and
/// validates the complete saved state before Application is exposed.
/// Terminal suitability is checked by the later terminal-startup boundary.
pub fn open_management_session(
executable_directory: &Path,
effective_uid_is_root: impl FnOnce() -> bool,
) -> Result<Application, SessionLoadError> {
if !effective_uid_is_root() {
return Err(SessionLoadError::RootRequired);
}
let settings_path = ResolvedSettings::settings_path(executable_directory);
let metadata =
fs::symlink_metadata(&settings_path).map_err(|_| SessionLoadError::SettingsUnavailable)?;
if !metadata.file_type().is_file() || metadata.len() > MAX_SETTINGS_BYTES {
return Err(SessionLoadError::SettingsUnavailable);
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
File::open(&settings_path)
.and_then(|file| file.take(MAX_SETTINGS_BYTES + 1).read_to_end(&mut bytes))
.map_err(|_| SessionLoadError::SettingsUnavailable)?;
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
return Err(SessionLoadError::SettingsUnavailable);
}
let text = std::str::from_utf8(&bytes).map_err(|_| SessionLoadError::SettingsInvalid)?;
let resolved = SettingsFile::parse_toml(text)
.and_then(SettingsFile::validate)
.map(|validated| validated.resolve_paths(executable_directory))
.map_err(|_| SessionLoadError::SettingsInvalid)?;
let store = SavedStore::new(executable_directory, resolved)
.map_err(|_| SessionLoadError::StoreInvalid)?;
Application::open_from_saved_store(store).map_err(|_| SessionLoadError::SavedStateInvalid)
}
/// Resolves the supported command line and checks management privilege lazily.
///
/// The injected root check keeps this boundary testable without running tests as
@@ -36,7 +103,7 @@ pub fn start(
match arguments {
[] => {
if effective_uid_is_root() {
Ok(StartupAction::ManagementUnavailable)
Ok(StartupAction::Management)
} else {
Err(StartupError::RootRequired)
}
@@ -72,9 +139,9 @@ mod tests {
}
#[test]
fn root_management_startup_reports_unimplemented_without_side_effects() {
fn root_management_startup_is_authorized_for_terminal_boundary() {
let result = start(&[], || true);
assert_eq!(result, Ok(StartupAction::ManagementUnavailable));
assert_eq!(result, Ok(StartupAction::Management));
}
#[test]
@@ -89,4 +156,71 @@ mod tests {
assert_eq!(result, Err(StartupError::UnknownArguments));
}
}
#[test]
fn management_session_checks_root_before_reading_settings() {
let result = super::open_management_session(
std::path::Path::new("/path/that/does/not/exist"),
|| false,
);
assert_eq!(result.err(), Some(super::SessionLoadError::RootRequired));
}
#[test]
fn management_session_loads_supported_state_and_keeps_unsupported_peers_visible() {
use std::os::unix::fs::PermissionsExt;
let directory = std::env::temp_dir().join(format!(
"wireguard-admin-startup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(
directory.join(crate::config::SETTINGS_FILE_NAME),
r#"interface_name = "wg0"
server_config_path = "wg0.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.org:51820"
ipv4_allocation_range = "10.80.0.0/24"
ipv6_allocation_range = "fd80::/64"
dns_addresses = ["10.80.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 10
"#,
)
.unwrap();
let server_path = directory.join("wg0.conf");
std::fs::write(
&server_path,
b"[Interface]\nAddress = 10.80.0.1/24\n# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xlYW4\n# wg-admin:end\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.80.0.2/32, fd80::2/128\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.80.0.0/24\n",
)
.unwrap();
std::fs::set_permissions(&server_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let app = super::open_management_session(&directory, || true)
.expect("valid synthetic installation should open");
let projection = app.display_projection();
assert_eq!(projection.clients.len(), 2);
assert_eq!(app.states().saved().clients().count(), 1);
assert!(projection.clients[0].mutable_identity.is_some());
assert!(projection.clients[1].mutable_identity.is_none());
assert_eq!(projection.clients[1].allowed_ips, ["10.80.0.0/24"]);
assert!(!format!("{projection:?}").contains("PrivateKey"));
let internal_state = directory.join("Backup/data");
std::fs::create_dir_all(&internal_state).unwrap();
std::fs::write(
internal_state.join("internal-state.toml"),
b"not valid state",
)
.unwrap();
assert_eq!(
super::open_management_session(&directory, || true).err(),
Some(super::SessionLoadError::SavedStateInvalid),
"corrupt persisted state must stop session loading rather than start empty"
);
std::fs::remove_dir_all(directory).unwrap();
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Native terminal workflow state and the serialized application worker.
//!
//! Terminal input/rendering adapters are added in the subsequent M7 tasks.
pub mod events;
pub mod forms;
pub mod render;
pub mod state;
pub mod terminal;
pub use events::{ApplicationWorker, UiCommand, WorkerConfig, WorkerEvent};
pub use state::{SessionView, UiState};
+1430
View File
File diff suppressed because it is too large Load Diff
+1065
View File
File diff suppressed because it is too large Load Diff
+690
View File
@@ -0,0 +1,690 @@
//! Responsive, secret-free rendering for the client list and detail screens.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use crate::app::ClientDisplay;
use crate::domain::PublicKey;
use crate::runtime::{InterfaceObservation, Observed, RuntimeObservation, RuntimePeer};
use super::forms::FormController;
use super::state::{Screen, SessionView, UiState};
pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
let area = frame.area();
if area.width < 34 || area.height < 8 {
let message = if forms.qr_output().is_some() {
"Terminal too small for QR. Resize to at least 34 × 8, or use the saved client file; Esc clears the QR."
} else {
"Terminal too small. Resize to at least 34 × 8."
};
frame.render_widget(
Paragraph::new(message)
.block(
Block::default()
.borders(Borders::ALL)
.title("WireGuard Admin"),
)
.wrap(Wrap { trim: true }),
area,
);
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(2),
])
.split(area);
let status = state
.view
.as_ref()
.map_or("Loading saved state".to_owned(), state_summary);
frame.render_widget(
Paragraph::new(status)
.block(
Block::default()
.borders(Borders::ALL)
.title("WireGuard Admin"),
)
.wrap(Wrap { trim: true }),
rows[0],
);
if forms.is_active() {
if let Some(qr) = forms.qr_output() {
render_qr_disclosure(frame, rows[1], qr, &forms.display_lines());
} else {
let lines = if forms.is_draft_review() {
draft_review_lines(state)
} else {
forms.display_lines()
};
frame.render_widget(
Paragraph::new(Text::from(
lines.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Management form"),
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
);
}
} else {
match state.screen {
Screen::Help => render_help(frame, rows[1]),
Screen::ClientDetails => render_details(frame, rows[1], state),
Screen::Progress => {
let text = state
.status_message
.as_deref()
.unwrap_or("Operation in progress…");
frame.render_widget(
Paragraph::new(safe_text(text))
.block(Block::default().borders(Borders::ALL).title("Progress"))
.wrap(Wrap { trim: false }),
rows[1],
);
}
Screen::Message => {
let text = state.status_message.as_deref().unwrap_or("Ready");
frame.render_widget(
Paragraph::new(safe_text(text))
.block(Block::default().borders(Borders::ALL).title("Status"))
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
);
}
Screen::ClientList => render_list(frame, rows[1], state),
}
}
frame.render_widget(
Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit")
.block(Block::default().borders(Borders::ALL)),
rows[2],
);
}
fn render_qr_disclosure(
frame: &mut Frame<'_>,
area: ratatui::layout::Rect,
qr: &crate::delivery::QrCodeOutput,
lines: &[String],
) {
let inner_width = area.width.saturating_sub(2) as usize;
let header_rows = lines.len();
let available_height = (area.height as usize).saturating_sub(2 + header_rows);
let side = qr.side();
let mut text = lines.iter().map(|line| safe_text(line)).collect::<Vec<_>>();
if !qr_fits(side, inner_width, available_height) {
text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned());
} else {
for y in 0..side {
let mut row = String::with_capacity(side * 2);
for x in 0..side {
row.push_str(if qr.module(x, y).unwrap_or(false) {
"██"
} else {
" "
});
}
text.push(row);
}
}
frame.render_widget(
Paragraph::new(Text::from(
text.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Secret disclosure · Esc clears"),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn qr_fits(side: usize, inner_width: usize, available_height: usize) -> bool {
side.saturating_mul(2) <= inner_width && side <= available_height
}
fn draft_review_lines(state: &UiState) -> Vec<String> {
let Some(view) = state.view.as_ref() else {
return vec!["Draft state is still loading.".to_owned()];
};
let mut lines = vec![
"Semantic draft preview".to_owned(),
format!(
"Pending draft: {}",
if view.has_draft { "yes" } else { "no" }
),
];
for (identity, row) in &view.client_states {
match (&row.saved, &row.draft) {
(None, Some(draft)) => lines.push(format!(
"Added {identity}: {} (IPv4 {}, IPv6 {})",
safe_text(&draft.alias),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
(Some(saved), None) => lines.push(format!(
"Removed {identity}: {} (saved removal remains unapplied until restart)",
safe_text(&saved.alias)
)),
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
safe_text(&saved.alias),
safe_text(&draft.alias),
saved.addresses.ipv4,
saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
_ => {}
}
}
if lines.len() == 2 && view.has_draft {
lines.push(
"A vault or managed metadata draft is pending; no client state differs.".to_owned(),
);
} else if lines.len() == 2 {
lines.push("No semantic client changes are pending.".to_owned());
}
lines.push("No raw server configuration is displayed.".to_owned());
lines
}
fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
let Some(view) = state.view.as_ref() else {
frame.render_widget(Paragraph::new("Loading client projection…"), area);
return;
};
if view.clients.is_empty() {
let text = if view.document_issues.is_empty() {
"No peers are configured."
} else {
"No peer rows. Document issues are listed below."
};
frame.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")),
area,
);
return;
}
let mut items: Vec<ListItem<'_>> = view
.clients
.iter()
.map(|client| {
let identity = client
.identity
.map(|key| short_key(&key))
.unwrap_or_else(|| format!("peer at line {}", client.line));
let supported = if client.mutable_identity.is_some() {
"supported"
} else {
"inspection only"
};
let addresses = safe_text(&client.allowed_ips.join(", "));
let row_state = client
.identity
.and_then(|identity| view.client_states.get(&identity))
.map_or_else(
|| "state: unavailable".to_owned(),
|row| match (&row.saved, &row.draft) {
(None, Some(_)) => "Draft added".to_owned(),
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(),
(Some(_), None) => "Draft removed".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(),
(None, None) => "State unavailable".to_owned(),
},
);
let anomaly_marker = if client.issues.is_empty() {
String::new()
} else {
" ! anomaly".to_owned()
};
let anomaly_details = if client.issues.is_empty() {
String::new()
} else {
format!(" {}", safe_text(&format!("{:?}", client.issues)))
};
ListItem::new(Line::from(vec![
Span::raw(format!("{row_state}{anomaly_marker} ")),
Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)),
Span::raw(format!(
" [{identity}] {supported} {addresses}{anomaly_details}"
)),
]))
})
.collect();
items.extend(view.client_states.iter().filter_map(|(identity, row)| {
let saved = row.saved.as_ref()?;
if row.draft.is_some()
|| view
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
let ipv6 = saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |address| address.to_string());
Some(ListItem::new(format!(
"Draft removed {} [{}] saved addresses: {}, {}",
safe_text(&saved.alias),
short_key(identity),
saved.addresses.ipv4,
ipv6
)))
}));
items.extend(view.document_issues.iter().map(|issue| {
ListItem::new(format!(
"Document issue: {}",
safe_text(&format!("{issue:?}"))
))
.style(Style::default().fg(Color::Yellow))
}));
let selected = state
.selected_peer_index
.and_then(|index| view.clients.iter().position(|row| row.peer_index == index))
.or_else(|| {
state.selected_identity.and_then(|identity| {
view.clients
.iter()
.position(|row| row.identity == Some(identity))
})
})
.unwrap_or(0);
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Clients and document issues"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut list_state = ListState::default().with_selected(Some(selected));
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_details(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
let Some(view) = state.view.as_ref() else {
frame.render_widget(Paragraph::new("Loading client projection…"), area);
return;
};
let selected = state
.selected_peer_index
.and_then(|index| view.clients.iter().find(|row| row.peer_index == index))
.or_else(|| {
state.selected_identity.and_then(|identity| {
view.clients
.iter()
.find(|row| row.identity == Some(identity))
})
});
let Some(client) = selected else {
frame.render_widget(
Paragraph::new("Select a peer from the client list.").block(
Block::default()
.borders(Borders::ALL)
.title("Client details"),
),
area,
);
return;
};
let lines = detail_lines(client, view);
frame.render_widget(
Paragraph::new(Text::from(lines))
.block(
Block::default()
.borders(Borders::ALL)
.title("Client details"),
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
area,
);
}
fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(format!("Label: {}", safe_text(&client.label))),
Line::from(format!(
"Public key: {}",
client
.identity
.map_or_else(|| "unavailable".to_owned(), |key| key.to_string())
)),
Line::from(format!(
"Allowed IPs: {}",
safe_text(&client.allowed_ips.join(", "))
)),
Line::from(format!(
"Mutation: {}",
if client.mutable_identity.is_some() {
"supported"
} else {
"disabled: ambiguous or unsupported peer"
}
)),
];
if let Some(identity) = client.identity {
if let Some(row) = view.client_states.get(&identity) {
lines.push(Line::from(format!(
"Draft: {}",
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str()))
)));
lines.push(Line::from(format!(
"Saved: {}",
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str()))
)));
if row.draft != row.saved {
lines.push(Line::from("Draft differs from saved state."));
if let Some(client) = &row.draft {
lines.push(Line::from(format!(
"Draft addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4,
client
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)));
}
if let Some(client) = &row.saved {
lines.push(Line::from(format!(
"Saved addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4,
client
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)));
}
}
}
if let Some(peer) = runtime_peer(&view.statistics, &identity) {
lines.extend(statistics_lines(peer));
} else {
lines.extend([
Line::from("Last handshake: unavailable"),
Line::from("Current received: unavailable"),
Line::from("Current transmitted: unavailable"),
]);
}
}
lines.push(Line::from(format!("Applied state: {:?}", view.applied)));
for issue in &client.issues {
lines.push(Line::from(format!(
"Peer issue: {}",
safe_text(&format!("{issue:?}"))
)));
}
for issue in &view.document_issues {
lines.push(Line::from(format!(
"Document issue: {}",
safe_text(&format!("{issue:?}"))
)));
}
lines
}
fn runtime_peer<'a>(
observation: &'a RuntimeObservation,
key: &PublicKey,
) -> Option<&'a RuntimePeer> {
match &observation.interface {
InterfaceObservation::Present { peers, .. } => peers.peers.get(key),
_ => None,
}
}
fn statistics_lines(peer: &RuntimePeer) -> [Line<'static>; 3] {
let handshake = match &peer.last_handshake {
Observed::Unavailable => "unavailable".to_owned(),
Observed::Available(None) => "never".to_owned(),
Observed::Available(Some(time)) => time
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "available (time formatting failed)".to_owned()),
};
[
Line::from(format!("Last handshake: {handshake}")),
Line::from(format!(
"Current received: {}",
counter(&peer.received_bytes)
)),
Line::from(format!(
"Current transmitted: {}",
counter(&peer.transmitted_bytes)
)),
]
}
fn counter(value: &Observed<u64>) -> String {
match value {
Observed::Available(bytes) => format!("{bytes} bytes"),
Observed::Unavailable => "unavailable".to_owned(),
}
}
fn state_summary(view: &SessionView) -> String {
format!(
"Draft / saved / applied: {} pending client changes / {} saved clients / {:?} Issues: {}",
view.client_states
.values()
.filter(|row| row.draft != row.saved)
.count(),
view.client_states
.values()
.filter(|row| row.saved.is_some())
.count(),
view.applied,
view.document_issues.len()
)
}
fn render_help(frame: &mut Frame<'_>, area: ratatui::layout::Rect) {
frame.render_widget(
Paragraph::new("↑/↓ selects a peer. Enter opens details. ! opens document and peer issues. Esc cancels a form or returns to the list. ? opens help. q requests exit and warns about a draft.\n\nn new client, r rename, d delete, p preview draft, c Commit, x discard draft, R preview explicit restart, o first file delivery, O password reissue, b list backups/restore, B create manual backup, s initial vault setup, i import one selected source file.\n\nPeer rows with malformed, duplicate, or unsupported configuration remain visible for inspection. Mutation requires an unambiguous supported full public key.\n\nCommit and restore only change saved files after backup. Restart is explicit, previewed, backed up, and verified. Statistics are read-only observations; a refresh never marks changes applied.")
.block(Block::default().borders(Borders::ALL).title("Help"))
.wrap(Wrap { trim: false }),
area,
);
}
fn short_key(key: &PublicKey) -> String {
let value = key.to_string();
format!("{}…{}", &value[..8], &value[value.len() - 6..])
}
/// Render control characters visibly so untrusted labels cannot move the cursor
/// or forge terminal output.
pub fn safe_text(input: &str) -> String {
input
.chars()
.flat_map(|character| {
if character.is_control() {
character
.escape_default()
.collect::<String>()
.chars()
.collect::<Vec<_>>()
} else {
vec![character]
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::collections::BTreeMap;
use std::net::Ipv4Addr;
#[test]
fn terminal_controls_are_escaped_for_display() {
assert_eq!(
safe_text("client\u{1b}[31m\nname"),
"client\\u{1b}[31m\\nname"
);
}
#[test]
fn zero_never_and_unavailable_have_distinct_text() {
assert_eq!(counter(&Observed::Available(0)), "0 bytes");
assert_eq!(counter(&Observed::Unavailable), "unavailable");
let now = time::OffsetDateTime::UNIX_EPOCH;
assert!(
statistics_lines(&RuntimePeer {
allowed_ips: Observed::Unavailable,
last_handshake: Observed::Available(None),
received_bytes: Observed::Available(0),
transmitted_bytes: Observed::Unavailable,
})[0]
.to_string()
.contains("never")
);
assert!(now.unix_timestamp() == 0);
}
#[test]
fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() {
assert!(qr_fits(25, 50, 25));
assert!(!qr_fits(25, 49, 25));
assert!(!qr_fits(25, 50, 24));
}
#[test]
fn client_rows_show_draft_state_and_escape_anomalous_unsupported_peers() {
let identity = PublicKey::from_bytes([7; 32]);
let saved = crate::domain::Client::new(
identity,
"old alias",
crate::domain::ClientAddresses {
ipv4: Ipv4Addr::new(10, 20, 0, 2),
ipv6: None,
},
)
.unwrap();
let draft =
crate::domain::Client::new(identity, "new alias", saved.addresses.clone()).unwrap();
let unsupported_key = PublicKey::from_bytes([8; 32]);
let removed_key = PublicKey::from_bytes([9; 32]);
let removed =
crate::domain::Client::new(removed_key, "removed alias", saved.addresses.clone())
.unwrap();
let view = SessionView {
generation: 1,
clients: vec![
ClientDisplay {
peer_index: 0,
line: 1,
identity: Some(identity),
label: "new alias".to_owned(),
allowed_ips: vec!["10.20.0.2/32".to_owned()],
issues: vec![],
mutable_identity: Some(identity),
},
ClientDisplay {
peer_index: 1,
line: 5,
identity: Some(unsupported_key),
label: "odd\u{1b}[31m peer".to_owned(),
allowed_ips: vec!["bad\nroute".to_owned()],
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
field: 0,
item: 1,
}],
mutable_identity: None,
},
],
client_states: BTreeMap::from([
(
identity,
super::super::state::ClientStateRow {
saved: Some(saved),
draft: Some(draft),
},
),
(
removed_key,
super::super::state::ClientStateRow {
saved: Some(removed),
draft: None,
},
),
]),
document_issues: vec![],
applied: super::super::state::AppliedState::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: true,
vault_initialized: true,
};
let state = UiState {
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(220, 12)).unwrap();
terminal
.draw(|frame| render_list(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let rendered = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Draft changed"));
assert!(rendered.contains("inspection only"));
assert!(rendered.contains("removed alias"));
assert!(rendered.contains("Draft removed"));
assert!(rendered.contains("UnsupportedAllowedIpShape"));
assert!(rendered.contains("odd\\u{1b}[31m peer"));
assert!(rendered.contains("bad\\nroute"));
let mut narrow = Terminal::new(TestBackend::new(34, 12)).unwrap();
narrow
.draw(|frame| render_list(frame, frame.area(), &state))
.unwrap();
let narrow_buffer = narrow.backend().buffer();
let narrow_rendered = (0..narrow_buffer.area.height)
.map(|y| {
(0..narrow_buffer.area.width)
.map(|x| narrow_buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(narrow_rendered.contains("Draft changed"));
assert!(narrow_rendered.contains("! anomaly"));
assert!(narrow_rendered.contains("Draft removed"));
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Secret-free navigation and session projections for the terminal interface.
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::app::{Application, ClientDisplay};
use crate::domain::{Client, PublicKey, RuntimeObservation};
use crate::runtime::RuntimeObservation as Statistics;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Screen {
ClientList,
ClientDetails,
Help,
Progress,
Message,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Focus {
ClientList,
Details,
Dialog,
Help,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DialogKind {
Rename,
Delete,
Create,
Commit,
Restart,
Exit,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppliedState {
Unknown,
Unavailable,
Verified,
}
/// Public client values for both states. No configuration bytes or secrets are
/// retained in a view snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStateRow {
pub saved: Option<Client>,
pub draft: Option<Client>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionView {
pub generation: u64,
pub clients: Vec<ClientDisplay>,
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub applied: AppliedState,
pub reserved_addresses: BTreeSet<IpAddr>,
pub statistics: Statistics,
pub has_draft: bool,
pub vault_initialized: bool,
}
impl SessionView {
pub fn from_application(app: &Application, generation: u64) -> Self {
let states = app.states();
let mut identities = BTreeSet::new();
identities.extend(states.saved().clients().map(|client| client.identity));
identities.extend(states.draft().clients().map(|client| client.identity));
let client_states = identities
.into_iter()
.map(|identity| {
(
identity,
ClientStateRow {
saved: states.saved().get(&identity).cloned(),
draft: states.draft().get(&identity).cloned(),
},
)
})
.collect();
let applied = match states.applied() {
RuntimeObservation::Unknown => AppliedState::Unknown,
RuntimeObservation::Unavailable => AppliedState::Unavailable,
RuntimeObservation::Verified => AppliedState::Verified,
};
let projection = app.display_projection();
Self {
generation,
clients: projection.clients,
client_states,
document_issues: projection.document_issues,
applied,
reserved_addresses: states.reserved_addresses().clone(),
statistics: Statistics::unknown(),
has_draft: app.has_draft_changes(),
vault_initialized: app.vault_initialized(),
}
}
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
self.clients
.iter()
.any(|client| client.identity == Some(*identity))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationKind {
CreateClient,
RenameClient,
DeleteClient,
DiscardDraft,
Commit,
CreateBackup,
ListBackups,
ImportSelectedFile,
InitialSetup,
PreviewRestart,
Restart,
DeliverClient,
Restore,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PendingOperation {
pub request_id: u64,
pub kind: OperationKind,
pub generation: u64,
}
/// Mutable UI navigation and latest accepted worker projection.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UiState {
pub screen: Screen,
pub focus: Focus,
pub dialog: Option<DialogKind>,
pub selected_identity: Option<PublicKey>,
/// Authoritative document index used only to inspect malformed peers that
/// have no public key. Mutating actions must always use selected_identity.
pub selected_peer_index: Option<usize>,
pub scroll_offset: usize,
pub generation: u64,
pub view: Option<SessionView>,
pub pending: Option<PendingOperation>,
pub status_message: Option<String>,
}
impl Default for UiState {
fn default() -> Self {
Self {
screen: Screen::ClientList,
focus: Focus::ClientList,
dialog: None,
selected_identity: None,
selected_peer_index: None,
scroll_offset: 0,
generation: 0,
view: None,
pending: None,
status_message: None,
}
}
}
impl UiState {
/// Replace the view only when it belongs to the current generation. The
/// selected full public key remains stable through sorting and refreshes.
pub fn apply_view(&mut self, view: SessionView) -> bool {
if view.generation != self.generation {
return false;
}
if self
.selected_identity
.is_some_and(|identity| !view.contains_identity(&identity))
{
self.selected_identity = None;
}
if let Some(identity) = self.selected_identity {
self.selected_peer_index = view
.clients
.iter()
.find(|client| client.identity == Some(identity))
.map(|client| client.peer_index);
} else if self
.selected_peer_index
.is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index))
{
self.selected_peer_index = None;
}
self.view = Some(view);
true
}
/// Apply only statistics sampled from the still-current saved/draft view.
/// This never changes the applied-state marker.
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
if generation != self.generation {
return false;
}
let Some(view) = self.view.as_mut() else {
return false;
};
if view.generation != generation {
return false;
}
view.statistics = statistics;
true
}
pub fn begin_operation(&mut self, operation: PendingOperation) -> bool {
if self.pending.is_some() || operation.generation < self.generation {
return false;
}
self.generation = operation.generation;
self.pending = Some(operation);
self.screen = Screen::Progress;
true
}
/// Close a form/dialog without cancelling work already submitted to the
/// application worker. The pending progress state remains visible.
pub fn cancel_dialog(&mut self) {
self.dialog = None;
if self.pending.is_some() {
self.screen = Screen::Progress;
} else {
self.screen = if self.selected_identity.is_some() {
Screen::ClientDetails
} else {
Screen::ClientList
};
}
}
pub fn finish_operation(&mut self, operation: PendingOperation) -> bool {
if self.pending != Some(operation) {
return false;
}
self.pending = None;
self.screen = if self.dialog.is_some() {
Screen::Message
} else if self.selected_identity.is_some() {
Screen::ClientDetails
} else {
Screen::ClientList
};
true
}
pub fn select(&mut self, identity: PublicKey) -> bool {
if !self
.view
.as_ref()
.is_some_and(|view| view.contains_identity(&identity))
{
return false;
}
self.selected_identity = Some(identity);
self.selected_peer_index = self.view.as_ref().and_then(|view| {
view.clients
.iter()
.find(|client| client.identity == Some(identity))
.map(|client| client.peer_index)
});
self.screen = Screen::ClientDetails;
true
}
pub fn select_peer_index(&mut self, peer_index: usize) -> bool {
let Some(client) = self.view.as_ref().and_then(|view| {
view.clients
.iter()
.find(|client| client.peer_index == peer_index)
}) else {
return false;
};
self.selected_peer_index = Some(peer_index);
self.selected_identity = client.identity;
self.screen = Screen::ClientDetails;
true
}
pub fn selected_client_mutable(&self) -> bool {
let Some(identity) = self.selected_identity else {
return false;
};
self.view.as_ref().is_some_and(|view| {
view.clients.iter().any(|client| {
client.identity == Some(identity) && client.mutable_identity == Some(identity)
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::PublicKey;
use crate::wireguard::PeerIssue;
fn row(identity: PublicKey, label: &str, mutable: bool) -> ClientDisplay {
ClientDisplay {
peer_index: 0,
line: 1,
identity: Some(identity),
label: label.to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
issues: if mutable {
Vec::new()
} else {
vec![PeerIssue::MissingIpv4HostRoute]
},
mutable_identity: mutable.then_some(identity),
}
}
fn view(generation: u64, clients: Vec<ClientDisplay>) -> SessionView {
SessionView {
generation,
clients,
client_states: Default::default(),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
reserved_addresses: BTreeSet::new(),
statistics: Statistics::unknown(),
has_draft: false,
vault_initialized: true,
}
}
#[test]
fn refresh_preserves_selection_by_public_key_not_alias_or_row_position() {
let a = PublicKey::from_bytes([1; 32]);
let b = PublicKey::from_bytes([2; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(
0,
vec![row(a, "same alias", true), row(b, "same alias", true)]
)));
assert!(state.select(b));
assert!(state.apply_view(view(
0,
vec![row(b, "renamed", true), row(a, "same alias", true)]
)));
assert_eq!(state.selected_identity, Some(b));
}
#[test]
fn stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable() {
let key = PublicKey::from_bytes([3; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![row(key, "legacy", false)])));
assert!(state.select(key));
assert!(!state.selected_client_mutable());
state.generation = 1;
assert!(!state.apply_view(view(0, vec![row(key, "stale", true)])));
assert_eq!(state.selected_identity, Some(key));
}
#[test]
fn malformed_peer_can_be_inspected_by_document_location_without_mutation_identity() {
let mut malformed = row(PublicKey::from_bytes([8; 32]), "malformed", false);
malformed.identity = None;
malformed.mutable_identity = None;
malformed.peer_index = 3;
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![malformed])));
assert!(state.select_peer_index(3));
assert_eq!(state.selected_identity, None);
assert_eq!(state.selected_peer_index, Some(3));
assert!(!state.selected_client_mutable());
}
#[test]
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
let mut state = UiState {
dialog: Some(DialogKind::Commit),
..UiState::default()
};
let operation = PendingOperation {
request_id: 4,
kind: OperationKind::Commit,
generation: 1,
};
assert!(state.begin_operation(operation));
state.cancel_dialog();
assert_eq!(state.pending, Some(operation));
assert_eq!(state.screen, Screen::Progress);
}
#[test]
fn statistics_refresh_is_generation_bound_and_never_changes_applied_state() {
let key = PublicKey::from_bytes([4; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![row(key, "client", true)])));
assert!(state.apply_statistics(0, Statistics::unknown()));
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown);
state.generation = 1;
assert!(!state.apply_statistics(0, Statistics::unknown()));
}
}
+1077
View File
File diff suppressed because it is too large Load Diff
+135 -7
View File
@@ -110,16 +110,25 @@ impl Drop for RecordPayload {
/// Password-authorized, short-lived client configuration data for later delivery.
pub struct RetrievedClientRecord {
private_key: Option<WireGuardPrivateKey>,
pub client_addresses: Vec<String>,
pub interface_dns: Option<String>,
pub interface_mtu: Option<String>,
pub server_public_key: Option<String>,
pub endpoint: Option<String>,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<String>,
provenance: RecordProvenance,
client_addresses: Vec<String>,
interface_dns: Option<String>,
interface_mtu: Option<String>,
server_public_key: Option<String>,
endpoint: Option<String>,
allowed_ips: Vec<String>,
persistent_keepalive: Option<String>,
preshared_key: Option<String>,
}
/// Provenance is assigned only after successful authenticated decryption and
/// validation of the retained field shape.
#[derive(Clone, Copy, Eq, PartialEq)]
pub(crate) enum RecordProvenance {
NewTemplate,
Imported,
}
impl RetrievedClientRecord {
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
self.private_key.as_ref()
@@ -130,9 +139,45 @@ impl RetrievedClientRecord {
self.preshared_key.as_deref()
}
pub fn client_addresses(&self) -> &[String] {
&self.client_addresses
}
pub fn interface_dns(&self) -> Option<&str> {
self.interface_dns.as_deref()
}
pub fn interface_mtu(&self) -> Option<&str> {
self.interface_mtu.as_deref()
}
pub fn server_public_key(&self) -> Option<&str> {
self.server_public_key.as_deref()
}
pub fn endpoint(&self) -> Option<&str> {
self.endpoint.as_deref()
}
pub fn allowed_ips(&self) -> &[String] {
&self.allowed_ips
}
pub fn persistent_keepalive(&self) -> Option<&str> {
self.persistent_keepalive.as_deref()
}
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
pub(crate) fn is_new_template(&self) -> bool {
self.provenance == RecordProvenance::NewTemplate
}
pub(crate) fn is_imported(&self) -> bool {
self.provenance == RecordProvenance::Imported
}
}
impl fmt::Debug for RetrievedClientRecord {
@@ -151,6 +196,38 @@ impl Drop for RetrievedClientRecord {
}
}
fn validated_record_provenance(payload: &RecordPayload) -> Result<RecordProvenance, VaultError> {
let key_only = payload.client_addresses.is_empty()
&& payload.interface_dns.is_none()
&& payload.interface_mtu.is_none()
&& payload.server_public_key.is_none()
&& payload.endpoint.is_none()
&& payload.allowed_ips.is_empty()
&& payload.persistent_keepalive.is_none()
&& payload.preshared_key.is_none();
if key_only {
return Ok(RecordProvenance::NewTemplate);
}
// Imported records always carry their source address and server identity.
// Missing either is a partial record, not permission to fall back to the
// new-client template.
if payload.client_addresses.is_empty()
|| payload.server_public_key.is_none()
|| payload
.client_addresses
.iter()
.any(|value| value.parse::<ipnet::IpNet>().is_err())
|| payload
.server_public_key
.as_deref()
.is_some_and(|value| value.parse::<PublicKey>().is_err())
{
return Err(VaultError::CorruptRecord);
}
Ok(RecordProvenance::Imported)
}
/// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the
/// password-protected identity only for the duration of that operation.
#[derive(Serialize, Deserialize)]
@@ -258,6 +335,14 @@ impl VaultBundle {
self.records.contains_key(&label.to_string())
}
/// Return the encrypted record bytes for session-local authorization binding.
/// The ciphertext is never exposed through the public vault API.
pub(crate) fn record_context(&self, label: PublicKey) -> Option<&str> {
self.records
.get(&label.to_string())
.map(|record| record.ciphertext.as_str())
}
/// Remove only the record identified by its canonical WireGuard public key.
/// The encrypted identity and every other record remain unchanged.
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
@@ -322,6 +407,7 @@ impl VaultBundle {
if payload.reissue_blocked {
return Err(VaultError::ReissueBlocked);
}
let provenance = validated_record_provenance(&payload)?;
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
.map_err(|_| VaultError::RecordMismatch)?;
if secret.public_key() != label {
@@ -329,6 +415,7 @@ impl VaultBundle {
}
Ok(RetrievedClientRecord {
private_key: Some(secret),
provenance,
client_addresses: payload.client_addresses.clone(),
interface_dns: payload.interface_dns.clone(),
interface_mtu: payload.interface_mtu.clone(),
@@ -679,6 +766,47 @@ mod tests {
);
}
#[test]
fn authenticated_retained_shape_never_falls_back_from_partial_import_to_new_template() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let identity = key.public_key();
bundle
.encrypt_payload(
identity,
RecordPayload {
version: 1,
label: identity.to_string(),
private_key: key.to_base64(),
client_addresses: vec!["10.0.0.2/32".to_owned()],
interface_dns: None,
interface_mtu: None,
server_public_key: None,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: None,
preshared_key: None,
reissue_blocked: false,
},
true,
)
.expect("malformed legacy record can be encrypted for regression test");
assert_eq!(
bundle.retrieve_client_record(identity, PASSWORD).err(),
Some(VaultError::CorruptRecord)
);
let clean_key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let mut clean_bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
clean_bundle
.encrypt_record(clean_key.public_key(), &clean_key)
.expect("key-only new-client record encrypts");
let retrieved = clean_bundle
.retrieve_client_record(clean_key.public_key(), PASSWORD)
.expect("fresh unlock validates key-only provenance");
assert!(retrieved.is_new_template());
}
#[test]
fn authenticated_record_with_unsupported_version_has_specific_error() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
+194 -2
View File
@@ -1,6 +1,6 @@
//! Bounded, read-only access to configured client source files.
use std::ffi::OsString;
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{Read, Take};
use std::path::{Path, PathBuf};
@@ -12,7 +12,7 @@ use thiserror::Error;
use crate::config::ResolvedSettings;
use crate::wireguard::ConfigDocument;
use super::import::{ImportReport, reconcile, reconcile_zeroizing};
use super::import::{CandidateStatus, ImportReport, reconcile, reconcile_zeroizing};
use super::{VaultBundle, VaultError};
use zeroize::Zeroizing;
@@ -68,12 +68,97 @@ pub struct PreparedImportSetup {
pub report: SourceImportReport,
}
/// Secret-free result from importing one explicitly selected source entry.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SelectedFileImportReport {
pub scan_complete: bool,
pub issues: Vec<SourceIssue>,
pub candidates: Vec<(String, CandidateStatus)>,
pub server_only: Vec<crate::domain::PublicKey>,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SourceImportError {
#[error("vault setup failed")]
Vault(#[from] VaultError),
#[error("source directory is invalid")]
InvalidDirectory,
#[error("selected source entry is invalid")]
InvalidSelection,
}
/// Read one explicitly selected immediate `*.conf` entry from the configured
/// source directory, reconcile it against the server document, and encrypt it
/// into the caller's draft bundle. The original entry is never modified.
pub fn import_selected_file(
bundle: &mut VaultBundle,
settings: &ResolvedSettings,
server: &ConfigDocument,
selected_name: &OsStr,
) -> Result<SelectedFileImportReport, SourceImportError> {
if !is_candidate_name(selected_name)
|| Path::new(selected_name).components().count() != 1
|| selected_name == OsStr::new(".")
|| selected_name == OsStr::new("..")
{
return Err(SourceImportError::InvalidSelection);
}
let display_name = safe_file_name(selected_name);
let empty_report = || reconcile(server, []);
let directory = match open_source_directory(&settings.client_source_directory) {
Ok(directory) => directory,
Err(_) => {
return Ok(selected_failure_report(
display_name,
SourceFailureKind::DirectoryUnavailable,
empty_report(),
));
}
};
let bytes = match read_candidate(&directory, selected_name, 0, MAX_SCAN_BYTES) {
Ok(bytes) => bytes,
Err(kind) => return Ok(selected_failure_report(display_name, kind, empty_report())),
};
let reconciliation = reconcile_zeroizing(server, [(display_name, bytes)]);
let summaries = reconciliation.candidate_summaries();
let mut issues = Vec::new();
if let Some((name, status)) = summaries.first()
&& !matches!(status, CandidateStatus::Invalid(_))
&& let Err(error) = reconciliation.encrypt_candidate(bundle, 0)
{
issues.push(SourceIssue {
file_name: Some((*name).to_owned()),
kind: match error {
VaultError::DuplicateRecord => SourceFailureKind::DuplicateRecordEqualityUnverified,
_ => SourceFailureKind::EncryptionFailed,
},
});
}
Ok(SelectedFileImportReport {
scan_complete: true,
issues,
candidates: summaries
.into_iter()
.map(|(name, status)| (name.to_owned(), status))
.collect(),
server_only: reconciliation.server_only().to_vec(),
})
}
fn selected_failure_report(
display_name: String,
kind: SourceFailureKind,
reconciliation: ImportReport,
) -> SelectedFileImportReport {
SelectedFileImportReport {
scan_complete: false,
issues: vec![SourceIssue {
file_name: Some(display_name),
kind,
}],
candidates: Vec::new(),
server_only: reconciliation.server_only().to_vec(),
}
}
/// Create a new password-protected vault and encrypt valid candidates from the
@@ -372,6 +457,113 @@ mod tests {
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
fn resolved_settings(source: &Path) -> ResolvedSettings {
crate::config::SettingsFile::parse_toml(
r#"interface_name = "wg0"
server_config_path = "wg0.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.org:51820"
ipv4_allocation_range = "10.8.0.0/24"
ipv6_allocation_range = "fd80::/64"
dns_addresses = ["10.8.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 10
"#,
)
.unwrap()
.validate()
.unwrap()
.resolve_paths(source)
}
#[test]
fn selected_file_import_is_bounded_read_only_and_stages_encryption() {
let root = tempfile::tempdir().unwrap();
let source_directory = root.path().join("clients");
fs::create_dir(&source_directory).unwrap();
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
server_key.to_base64(),
client_key.public_key()
)
.as_bytes(),
)
.unwrap();
let original = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.org:51820\n",
client_key.to_base64(),
server_key.public_key()
);
let selected_path = source_directory.join("client.conf");
fs::write(&selected_path, original.as_bytes()).unwrap();
make_private(&selected_path);
let mut bundle = VaultBundle::create("synthetic password").unwrap();
let report = import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("client.conf"),
)
.unwrap();
assert!(report.scan_complete);
assert!(report.issues.is_empty());
assert_eq!(bundle.record_count(), 1);
assert!(matches!(
report.candidates[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: super::super::import::ServerIdentityComparison::Matches,
..
}
));
assert_eq!(fs::read(&selected_path).unwrap(), original.as_bytes());
let duplicate = import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("client.conf"),
)
.unwrap();
assert_eq!(
duplicate.issues[0].kind,
SourceFailureKind::DuplicateRecordEqualityUnverified
);
assert_eq!(bundle.record_count(), 1);
assert!(
import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("../client.conf")
)
.is_err()
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&selected_path, source_directory.join("linked.conf"))
.unwrap();
let link_report = import_selected_file(
&mut bundle,
&resolved_settings(root.path()),
&server,
OsStr::new("linked.conf"),
)
.unwrap();
assert!(!link_report.scan_complete);
assert_eq!(
link_report.issues[0].kind,
SourceFailureKind::FileUnavailable
);
assert_eq!(bundle.record_count(), 1);
}
}
#[test]
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
let directory = tempfile::tempdir().unwrap();
+59
View File
@@ -457,6 +457,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 +530,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!(
+411
View File
@@ -0,0 +1,411 @@
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::{
Client, ClientAddresses, ClientStates, RuntimeObservation, 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, RuntimeObservation::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, RuntimeObservation::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);
}
+161
View File
@@ -0,0 +1,161 @@
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::{
Client, ClientAddresses, ClientStates, RuntimeObservation, 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, RuntimeObservation::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(), &RuntimeObservation::Unknown);
}
#[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(), &RuntimeObservation::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());
}
+985
View File
@@ -0,0 +1,985 @@
//! Synthetic end-to-end coverage for the serialized TUI application boundary.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use wireguard_admin::app::{FirstIssueAttempt, RestartChoice, RestartStage};
use wireguard_admin::backup::BackupReason;
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::{ClientStates, PublicKey, RuntimeObservation, Snapshot};
use wireguard_admin::persistence::SavedStore;
use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation as Statistics};
use wireguard_admin::tui::events::{
ApplicationWorker, CommandResult, FirstIssueStatus, QrStatus, SecretText, SensitiveCommand,
ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent,
};
use wireguard_admin::vault::WireGuardPrivateKey;
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 = 5
"#;
struct Fixture {
_root: tempfile::TempDir,
settings: ResolvedSettings,
store: SavedStore,
server_identity: PublicKey,
}
fn fixture() -> Fixture {
let root = tempfile::tempdir().expect("synthetic fixture directory");
let settings = SettingsFile::parse_toml(SETTINGS)
.expect("settings parse")
.validate()
.expect("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");
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let server_identity = server_key.public_key();
fs::write(
&settings.server_config_path,
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n\n# Fixture administrator note.\n",
server_key.to_base64()
),
)
.expect("server file");
fs::set_permissions(
&settings.server_config_path,
fs::Permissions::from_mode(0o600),
)
.expect("private synthetic server file");
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
Fixture {
_root: root,
settings,
store,
server_identity,
}
}
#[derive(Clone, Default)]
struct CountingStatistics(Arc<AtomicUsize>);
impl StatisticsReader for CountingStatistics {
fn read(&mut self, _interface: &str, _timeout: Duration) -> Statistics {
self.0.fetch_add(1, Ordering::SeqCst);
Statistics {
observed_at: None,
interface: InterfaceObservation::Unavailable,
}
}
}
fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker {
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown)
.expect("empty states");
let app = wireguard_admin::app::Application::open(fixture.store.clone(), states)
.expect("application opens on synthetic files");
ApplicationWorker::spawn(
app,
reader,
WorkerConfig {
refresh_interval: Duration::from_secs(60),
read_timeout: Duration::from_millis(5),
},
)
.expect("worker starts")
}
fn wait_for(worker: &mut ApplicationWorker, request_id: u64) -> WorkerEvent {
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if let Some(event) = worker.try_receive() {
if matches!(&event, WorkerEvent::OperationFinished { operation, .. } if operation.request_id == request_id)
{
return event;
}
} else {
thread::yield_now();
}
}
panic!("worker did not finish request {request_id}");
}
fn execute(worker: &mut ApplicationWorker, command: UiCommand) -> CommandResult {
let operation = worker.submit(command).expect("command accepted");
match wait_for(worker, operation.request_id) {
WorkerEvent::OperationFinished { result, .. } => result.expect("operation succeeds"),
_ => unreachable!("wait_for returns the requested completion"),
}
}
fn finish(mut worker: ApplicationWorker) {
assert_eq!(
worker.shutdown(Duration::from_secs(2)),
ShutdownProgress::Stopped
);
}
#[test]
fn terminal_pty_smoke() {
const CHILD: &str = "WG_ADMIN_SYNTHETIC_PTY_CHILD";
if std::env::var_os(CHILD).is_some() {
let fixture = fixture();
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown)
.expect("empty states");
let app = wireguard_admin::app::Application::open(fixture.store, states)
.expect("synthetic application opens");
let resize = thread::spawn(|| {
thread::sleep(Duration::from_millis(40));
assert!(
Command::new("stty")
.args(["cols", "40", "rows", "8"])
.status()
.expect("shrink synthetic PTY")
.success()
);
thread::sleep(Duration::from_millis(40));
assert!(
Command::new("stty")
.args(["cols", "80", "rows", "24"])
.status()
.expect("grow synthetic PTY")
.success()
);
});
let result = wireguard_admin::tui::terminal::run_with_statistics(
app,
CountingStatistics::default(),
WorkerConfig {
refresh_interval: Duration::from_secs(60),
read_timeout: Duration::from_millis(5),
},
);
resize.join().expect("resize sequence completes");
assert!(result.is_ok(), "synthetic terminal exits cleanly");
return;
}
if Command::new("script").arg("--version").output().is_err() {
eprintln!("PTY smoke incomplete: `script` utility is unavailable");
return;
}
let executable = std::env::current_exe().expect("test executable path");
let command = format!(
"stty cols 80 rows 24; env {CHILD}=1 {} --exact terminal_pty_smoke --nocapture",
shell_quote(&executable.to_string_lossy())
);
let mut child = Command::new("script")
.args(["-qefc", &command, "/dev/null"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("start isolated pseudo-terminal");
use std::io::Write as _;
let mut input = child.stdin.take().expect("script stdin");
let exit_key = thread::spawn(move || {
thread::sleep(Duration::from_millis(180));
input
.write_all(b"\x03")
.expect("send handled Ctrl-C interruption to synthetic TUI");
});
let output = child.wait_with_output().expect("collect PTY result");
exit_key.join().expect("exit input completes");
assert!(
output.status.success(),
"synthetic PTY child exits successfully"
);
let transcript = String::from_utf8_lossy(&output.stdout);
assert!(
transcript.contains("\u{1b}[?1049h"),
"alternate screen entered"
);
assert!(
transcript.contains("\u{1b}[?1049l"),
"alternate screen restored"
);
assert!(
transcript.contains("\u{1b}[?25l"),
"cursor hidden during TUI"
);
assert!(
transcript.contains("\u{1b}[?25h"),
"cursor restored after TUI"
);
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn path(parent: &Path, name: &str) -> PathBuf {
parent.join(name)
}
#[test]
fn worker_setup_create_commit_and_delivery_keep_saved_and_runtime_boundaries() {
let fixture = fixture();
let invalid_source = b"this fixture contains no valid client configuration\n";
let source_path = fixture.settings.client_source_directory.join("broken.conf");
fs::write(&source_path, invalid_source).expect("synthetic invalid source file");
let reader = CountingStatistics::default();
let reads = Arc::clone(&reader.0);
let mut worker = worker(&fixture, reader);
let setup = worker
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
"synthetic vault password".to_owned(),
)))
.expect("setup submitted");
match wait_for(&mut worker, setup.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
match result.unwrap() {
CommandResult::InitialSetupPrepared(report) => {
assert!(report.scan_complete);
assert!(matches!(
report.reconciliation.candidate_summaries()[0].1,
wireguard_admin::vault::import::CandidateStatus::Invalid(_)
));
}
_ => panic!("setup report returned"),
}
assert!(view.vault_initialized);
assert!(view.document_issues.is_empty());
}
_ => unreachable!(),
}
let created = execute(
&mut worker,
UiCommand::CreateClient {
alias: "synthetic laptop".to_owned(),
manual_addresses: None,
created_utc: Some("2026-10-21T12:00:00Z".to_owned()),
},
);
let identity = match created {
CommandResult::ClientCreated(identity) => identity,
_ => panic!("create returns identity"),
};
let renamed = execute(
&mut worker,
UiCommand::RenameClient {
identity,
alias: "renamed synthetic laptop".to_owned(),
},
);
assert!(matches!(renamed, CommandResult::ClientRenamed));
assert!(matches!(
execute(&mut worker, UiCommand::Commit),
CommandResult::Committed(_)
));
assert!(
fs::read_to_string(&fixture.settings.server_config_path)
.expect("saved server file")
.contains("# Fixture administrator note.")
);
assert_eq!(
fs::read(&source_path).expect("original source preserved"),
invalid_source
);
let failed_parent = path(fixture._root.path(), "failed-output");
fs::create_dir(&failed_parent).expect("failed output directory");
let failed_path = path(&failed_parent, "client.conf");
let prepared = execute(
&mut worker,
UiCommand::PrepareDelivery {
identity,
path: failed_path,
include_qr: false,
reissue: false,
attempt: FirstIssueAttempt::Initial,
},
);
let prepared_generation = match prepared {
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
_ => panic!("destination is prepared before publication"),
};
fs::set_permissions(&failed_parent, fs::Permissions::from_mode(0o500))
.expect("make fixture destination unwritable");
let failed = worker
.submit(UiCommand::DeliverFirstIssue {
attempt: FirstIssueAttempt::Initial,
overwrite_confirmed: false,
prepared_generation,
})
.expect("failed publication submitted");
match wait_for(&mut worker, failed.request_id) {
WorkerEvent::OperationFinished { result, .. } => match result.unwrap() {
CommandResult::FirstIssue(summary) => {
assert!(matches!(summary.status, FirstIssueStatus::Failed(_)));
assert_eq!(
summary.retry,
Some(wireguard_admin::app::FirstIssueRetry::Available)
);
}
_ => panic!("failure report returned"),
},
_ => unreachable!(),
}
fs::set_permissions(&failed_parent, fs::Permissions::from_mode(0o700))
.expect("restore fixture destination permissions");
let output = path(fixture._root.path(), "delivered.conf");
fs::write(&output, b"keep before overwrite confirmation").expect("synthetic existing output");
let attempt = FirstIssueAttempt::Retry {
confirm_possible_overwrite: false,
confirm_manual_cleanup: false,
};
let prepared = execute(
&mut worker,
UiCommand::PrepareDelivery {
identity,
path: output.clone(),
include_qr: true,
reissue: false,
attempt,
},
);
let prepared_generation = match prepared {
CommandResult::DeliveryDestination(summary) => {
assert!(summary.overwrite_required);
summary.prepared_generation
}
_ => panic!("overwrite target is prepared"),
};
let declined = worker
.submit(UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed: false,
prepared_generation,
})
.expect("declined overwrite is handled by worker");
assert!(matches!(
wait_for(&mut worker, declined.request_id),
WorkerEvent::OperationFinished { result: Err(_), .. }
));
assert_eq!(
fs::read(&output).unwrap(),
b"keep before overwrite confirmation"
);
let attempt = FirstIssueAttempt::Retry {
confirm_possible_overwrite: true,
confirm_manual_cleanup: false,
};
let prepared = execute(
&mut worker,
UiCommand::PrepareDelivery {
identity,
path: output.clone(),
include_qr: true,
reissue: false,
attempt,
},
);
let prepared_generation = match prepared {
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
_ => panic!("confirmed overwrite target is prepared"),
};
let delivered = execute(
&mut worker,
UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed: true,
prepared_generation,
},
);
match delivered {
CommandResult::FirstIssue(summary) => {
assert_eq!(summary.status, FirstIssueStatus::Delivered);
assert!(summary.activation_warning);
assert_eq!(summary.qr, QrStatus::Ready);
let mut disclosure = wireguard_admin::tui::forms::FormController::default();
disclosure.open_disclosure(
summary.qr_output.expect("QR matrix returned"),
"fixture".to_owned(),
);
assert!(disclosure.qr_output().is_some());
assert!(matches!(
disclosure.handle_key(crossterm::event::KeyCode::Esc),
wireguard_admin::tui::forms::FormAction::Cancel
));
assert!(disclosure.qr_output().is_none(), "Esc clears the QR owner");
}
_ => panic!("delivery result returned"),
}
assert!(
fs::read_to_string(output)
.expect("delivered file")
.contains("[Interface]")
);
let wrong_password_path = path(fixture._root.path(), "wrong-password.conf");
let prepared = execute(
&mut worker,
UiCommand::PrepareDelivery {
identity,
path: wrong_password_path.clone(),
include_qr: false,
reissue: true,
attempt: FirstIssueAttempt::Initial,
},
);
let prepared_generation = match prepared {
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
_ => panic!("reissue destination is prepared"),
};
let wrong_password = "synthetic wrong vault password";
let reissue = worker
.submit_sensitive(SensitiveCommand::ReissueClient {
identity,
password: SecretText::new(wrong_password.to_owned()),
overwrite_confirmed: false,
prepared_generation,
})
.expect("reissue submitted");
match wait_for(&mut worker, reissue.request_id) {
WorkerEvent::OperationFinished { result, .. } => {
let error = result.expect_err("wrong password rejected");
assert!(!error.message.contains(wrong_password));
}
_ => unreachable!(),
}
assert!(!wrong_password_path.exists());
let reissued_path = path(fixture._root.path(), "reissued.conf");
let prepared = execute(
&mut worker,
UiCommand::PrepareDelivery {
identity,
path: reissued_path.clone(),
include_qr: false,
reissue: true,
attempt: FirstIssueAttempt::Initial,
},
);
let prepared_generation = match prepared {
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
_ => panic!("authorized reissue destination prepared"),
};
let reissue = worker
.submit_sensitive(SensitiveCommand::ReissueClient {
identity,
password: SecretText::new("synthetic vault password".to_owned()),
overwrite_confirmed: false,
prepared_generation,
})
.expect("password-authorized reissue submitted");
match wait_for(&mut worker, reissue.request_id) {
WorkerEvent::OperationFinished { result, .. } => match result.unwrap() {
CommandResult::Reissued(summary) => {
assert!(summary.activation_warning);
assert_eq!(summary.qr, QrStatus::NotRequested);
}
_ => panic!("reissue result returned"),
},
_ => unreachable!(),
}
assert!(reissued_path.exists());
let deletion = worker
.submit(UiCommand::DeleteClient { identity })
.expect("delete selected full-key identity");
match wait_for(&mut worker, deletion.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
assert!(matches!(
result.unwrap(),
CommandResult::ClientDeleted { .. }
));
let row = &view.client_states[&identity];
assert!(row.saved.is_some(), "saved peer remains until Commit");
assert!(row.draft.is_none());
}
_ => unreachable!(),
}
let discarded = worker
.submit(UiCommand::DiscardDraft)
.expect("discard after draft deletion");
match wait_for(&mut worker, discarded.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
assert!(matches!(result.unwrap(), CommandResult::DraftDiscarded));
let row = &view.client_states[&identity];
assert!(row.saved.is_some() && row.draft.is_some());
}
_ => unreachable!(),
}
assert_eq!(
reads.load(Ordering::SeqCst),
0,
"no runtime adapter is called by Commit or delivery"
);
finish(worker);
}
#[test]
fn worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operations() {
let fixture = fixture();
let mut worker = worker(&fixture, CountingStatistics::default());
let setup = worker
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
"synthetic vault password".to_owned(),
)))
.expect("setup submitted");
let _ = wait_for(&mut worker, setup.request_id);
assert!(matches!(
execute(&mut worker, UiCommand::Commit),
CommandResult::Committed(_)
));
assert!(matches!(
execute(&mut worker, UiCommand::CreateBackup),
CommandResult::BackupCreated(_)
));
let backups = execute(&mut worker, UiCommand::ListBackups);
let archive_name = match backups {
CommandResult::Backups(entries) => {
let entry = entries
.iter()
.find(|entry| entry.reason == BackupReason::Manual)
.expect("manual archive listed with its reason");
assert!(!entry.file_name.is_empty());
entry.file_name.clone()
}
_ => panic!("backup list returned"),
};
let created = execute(
&mut worker,
UiCommand::CreateClient {
alias: "first-issue capability to invalidate".to_owned(),
manual_addresses: None,
created_utc: None,
},
);
let issued_identity = match created {
CommandResult::ClientCreated(identity) => identity,
_ => panic!("client created for restore invalidation test"),
};
assert!(matches!(
execute(&mut worker, UiCommand::Commit),
CommandResult::Committed(_)
));
let preview = execute(
&mut worker,
UiCommand::PreviewRestore {
file_name: archive_name.clone(),
},
);
let preview_generation = match preview {
CommandResult::RestorePreview(summary) => {
assert_eq!(summary.file_name, archive_name);
assert_eq!(summary.reason, BackupReason::Manual);
summary.preview_generation
}
_ => panic!("restore preview returned"),
};
assert!(matches!(
execute(&mut worker, UiCommand::CancelRestorePreview),
CommandResult::Cancelled
));
// Cancellation clears the worker-owned preview; confirmation cannot use it.
let confirmation = worker
.submit(UiCommand::ConfirmRestore { preview_generation })
.expect("confirmation is handled and rejected by worker");
assert!(matches!(
wait_for(&mut worker, confirmation.request_id),
WorkerEvent::OperationFinished { result: Err(_), .. }
));
let preview = execute(
&mut worker,
UiCommand::PreviewRestore {
file_name: archive_name.clone(),
},
);
let preview_generation = match preview {
CommandResult::RestorePreview(summary) => summary.preview_generation,
_ => panic!("restore preview returned"),
};
let saved_before_restore = fs::read(&fixture.settings.server_config_path).unwrap();
let archived_directory = fixture
.settings
.data_directory
.join("archives-before-restore-failure");
fs::rename(&fixture.settings.archives_directory, &archived_directory)
.expect("hold valid archive directory aside");
fs::write(&fixture.settings.archives_directory, b"not a directory")
.expect("block precautionary restore backup");
let restore = worker
.submit(UiCommand::ConfirmRestore { preview_generation })
.expect("restore confirmation reaches worker");
match wait_for(&mut worker, restore.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
assert!(
result.is_err(),
"precautionary backup failure blocks replacement"
);
assert!(!view.has_draft);
}
_ => unreachable!(),
}
fs::remove_file(&fixture.settings.archives_directory).expect("remove blocker file");
fs::rename(&archived_directory, &fixture.settings.archives_directory)
.expect("restore valid archive directory");
assert_eq!(
fs::read(&fixture.settings.server_config_path).unwrap(),
saved_before_restore
);
let preview = execute(
&mut worker,
UiCommand::PreviewRestore {
file_name: archive_name,
},
);
let preview_generation = match preview {
CommandResult::RestorePreview(summary) => summary.preview_generation,
_ => panic!("restore preview returned after backup failure"),
};
let restore = worker
.submit(UiCommand::ConfirmRestore { preview_generation })
.expect("confirmed restore accepted");
match wait_for(&mut worker, restore.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
assert!(matches!(result.unwrap(), CommandResult::Restored(_)));
assert_eq!(
view.applied,
wireguard_admin::tui::state::AppliedState::Unknown
);
assert!(!view.has_draft);
}
_ => unreachable!(),
}
let output = path(fixture._root.path(), "restored-client.conf");
let prepared = execute(
&mut worker,
UiCommand::PrepareDelivery {
identity: issued_identity,
path: output,
include_qr: false,
reissue: false,
attempt: FirstIssueAttempt::Initial,
},
);
let prepared_generation = match prepared {
CommandResult::DeliveryDestination(summary) => summary.prepared_generation,
_ => panic!("destination preparation is separate from issue authorization"),
};
let invalidated = worker
.submit(UiCommand::DeliverFirstIssue {
attempt: FirstIssueAttempt::Initial,
overwrite_confirmed: false,
prepared_generation,
})
.expect("delivery denial returned through worker");
assert!(matches!(
wait_for(&mut worker, invalidated.request_id),
WorkerEvent::OperationFinished { result: Err(_), .. }
));
finish(worker);
}
#[test]
fn worker_commit_stops_at_mandatory_backup_failure_and_keeps_draft_and_saved_bytes() {
let fixture = fixture();
let mut worker = worker(&fixture, CountingStatistics::default());
let setup = worker
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
"synthetic vault password".to_owned(),
)))
.expect("setup submitted");
let _ = wait_for(&mut worker, setup.request_id);
assert!(matches!(
execute(&mut worker, UiCommand::Commit),
CommandResult::Committed(_)
));
let original = fs::read(&fixture.settings.server_config_path).expect("saved server bytes");
let created = execute(
&mut worker,
UiCommand::CreateClient {
alias: "draft only".to_owned(),
manual_addresses: None,
created_utc: None,
},
);
assert!(matches!(created, CommandResult::ClientCreated(_)));
let archived_directory = fixture
.settings
.data_directory
.join("archives-before-commit-failure");
fs::rename(&fixture.settings.archives_directory, &archived_directory)
.expect("hold valid archive directory aside");
fs::write(&fixture.settings.archives_directory, b"not a directory")
.expect("block mandatory archive publication");
let commit = worker
.submit(UiCommand::Commit)
.expect("Commit dispatch accepts operation");
match wait_for(&mut worker, commit.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
assert!(result.is_err(), "backup error reaches the worker result");
assert!(view.has_draft, "draft survives failed Commit");
}
_ => unreachable!(),
}
fs::remove_file(&fixture.settings.archives_directory).expect("remove blocker file");
fs::rename(&archived_directory, &fixture.settings.archives_directory)
.expect("restore valid archive directory");
assert_eq!(
fs::read(&fixture.settings.server_config_path).expect("saved server bytes remain"),
original
);
assert!(matches!(
execute(&mut worker, UiCommand::PreviewRestart),
CommandResult::RestartPreview(summary) if summary.has_draft
));
match execute(&mut worker, UiCommand::Restart(RestartChoice::Cancel)) {
CommandResult::Restarted(report) => {
assert_eq!(report.stage, RestartStage::Cancelled);
assert!(!report.restart_submitted);
}
_ => panic!("restart cancel report returned"),
}
finish(worker);
}
#[test]
fn worker_restore_refreshes_operational_allocation_settings_before_next_create() {
let fixture = fixture();
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown)
.expect("empty states");
let mut original_app = wireguard_admin::app::Application::open(fixture.store.clone(), states)
.expect("original application opens");
original_app
.prepare_initial_setup_from_sources("synthetic vault password")
.expect("initial vault setup");
original_app
.commit()
.expect("initial saved state committed");
let changed_settings_text = SETTINGS
.replace("vpn.example.test:51820", "vpn2.example.test:51821")
.replace("10.20.0.0/24", "10.30.0.0/24")
.replace("fd20::/64", "fd30::/64");
fs::write(
ResolvedSettings::settings_path(fixture._root.path()),
&changed_settings_text,
)
.expect("install changed operational fixture settings");
let changed_settings = SettingsFile::parse_toml(&changed_settings_text)
.expect("changed settings parse")
.validate()
.expect("changed settings validate")
.resolve_paths(fixture._root.path());
let changed_store = SavedStore::new(fixture._root.path(), changed_settings)
.expect("changed settings preserve trusted paths");
let changed_app = wireguard_admin::app::Application::open_from_saved_store(changed_store)
.expect("changed settings app opens");
let changed_archive = changed_app
.create_manual_backup()
.expect("archive changed operational settings");
let changed_archive_name = changed_archive
.file_name()
.expect("archive filename")
.to_string_lossy()
.into_owned();
fs::write(
ResolvedSettings::settings_path(fixture._root.path()),
SETTINGS,
)
.expect("restore original current settings before worker session");
let mut worker = worker(&fixture, CountingStatistics::default());
let preview = execute(
&mut worker,
UiCommand::PreviewRestore {
file_name: changed_archive_name,
},
);
match preview {
CommandResult::RestorePreview(summary) => {
assert!(
summary
.changes
.settings_fields_changed
.contains(&"server endpoint")
);
assert!(
summary
.changes
.settings_fields_changed
.contains(&"IPv4 allocation range")
);
}
_ => panic!("worker returns changed-settings restore preview"),
}
let restored = worker
.submit(UiCommand::ConfirmRestore {
preview_generation: worker.current_generation(),
})
.expect("restore confirmation submitted");
assert!(matches!(
wait_for(&mut worker, restored.request_id),
WorkerEvent::OperationFinished {
result: Ok(CommandResult::Restored(_)),
..
}
));
let create = worker
.submit(UiCommand::CreateClient {
alias: "uses restored allocation".to_owned(),
manual_addresses: None,
created_utc: None,
})
.expect("create after restore submitted");
match wait_for(&mut worker, create.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => {
let identity = match result.unwrap() {
CommandResult::ClientCreated(identity) => identity,
_ => panic!("client creation result returned"),
};
let client = view.client_states[&identity]
.draft
.as_ref()
.expect("client is staged in draft");
assert!(client.addresses.ipv4.to_string().starts_with("10.30."));
assert!(
client
.addresses
.ipv6
.expect("dual-stack client IPv6")
.to_string()
.starts_with("fd30:")
);
}
_ => unreachable!(),
}
finish(worker);
}
#[test]
fn worker_setup_reconciliation_keeps_match_mismatch_server_only_file_only_and_invalid_visible() {
use wireguard_admin::vault::import::{CandidateStatus, ServerIdentityComparison};
let fixture = fixture();
let matched_secret = WireGuardPrivateKey::generate().expect("matched client key");
let mismatch_secret = WireGuardPrivateKey::generate().expect("mismatched client key");
let file_only_secret = WireGuardPrivateKey::generate().expect("file-only client key");
let wrong_server = WireGuardPrivateKey::generate().expect("mismatch fixture key");
let server_only = WireGuardPrivateKey::generate().expect("server-only public identity");
let matched = matched_secret.public_key();
let mismatch = mismatch_secret.public_key();
let server_only_identity = server_only.public_key();
let server_text = format!(
"{}\n[Peer]\nPublicKey = {matched}\nAllowedIPs = 10.20.0.2/32\n\n[Peer]\nPublicKey = {mismatch}\nAllowedIPs = 10.20.0.3/32\n\n[Peer]\nPublicKey = {server_only_identity}\nAllowedIPs = 10.20.0.5/32\n",
fs::read_to_string(&fixture.settings.server_config_path).unwrap()
);
fs::write(&fixture.settings.server_config_path, server_text).expect("server peer fixtures");
let source_files = [
(
"matched.conf",
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = vpn.example.test:51820\n",
matched_secret.to_base64(),
fixture.server_identity
),
),
(
"mismatch.conf",
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.3/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.test:51820\n",
mismatch_secret.to_base64(),
wrong_server.public_key()
),
),
(
"file-only.conf",
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.4/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.test:51820\n",
file_only_secret.to_base64(),
fixture.server_identity
),
),
("invalid.conf", "PrivateKey = malformed\n".to_owned()),
];
let originals = source_files
.iter()
.map(|(name, text)| {
let path = fixture.settings.client_source_directory.join(name);
fs::write(&path, text).expect("synthetic client source");
(path, text.as_bytes().to_vec())
})
.collect::<Vec<_>>();
let mut worker = worker(&fixture, CountingStatistics::default());
let setup = worker
.submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new(
"synthetic vault password".to_owned(),
)))
.expect("setup submitted");
match wait_for(&mut worker, setup.request_id) {
WorkerEvent::OperationFinished { result, .. } => match result.unwrap() {
CommandResult::InitialSetupPrepared(report) => {
let statuses = report
.reconciliation
.candidate_summaries()
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>();
assert!(matches!(
statuses.get("matched.conf"),
Some(CandidateStatus::Matched {
server_identity: ServerIdentityComparison::Matches,
..
})
));
assert!(matches!(
statuses.get("mismatch.conf"),
Some(CandidateStatus::Matched {
server_identity: ServerIdentityComparison::Mismatch,
..
})
));
assert!(matches!(
statuses.get("file-only.conf"),
Some(CandidateStatus::FileOnly)
));
assert!(matches!(
statuses.get("invalid.conf"),
Some(CandidateStatus::Invalid(_))
));
assert!(
report
.reconciliation
.server_only()
.contains(&server_only_identity)
);
}
_ => panic!("setup reconciliation returned"),
},
_ => unreachable!(),
}
for (path, expected) in originals {
assert_eq!(
fs::read(path).unwrap(),
expected,
"source files are read-only"
);
}
finish(worker);
}