Compare commits
32 Commits
86dbead893
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4299118f7 | |||
| e648bc4dc6 | |||
| 4c86472740 | |||
| 7123197ec4 | |||
| 9a143e975e | |||
| 7cef400b9a | |||
| ed602f0596 | |||
| c1d4f935c8 | |||
| 2766c6254a | |||
| 8413b382b1 | |||
| ede45d224a | |||
| 23d6180f24 | |||
| 4960c65f65 | |||
| d49721ff8e | |||
| 4b53aad20c | |||
| fe7e3d61c2 | |||
| 86c28a35b5 | |||
| 4bb975f122 | |||
| af2d061880 | |||
| 6d8d6d572d | |||
| ecc5c83d78 | |||
| e141c03c2c | |||
| 7d0f006601 | |||
| f4a6540df9 | |||
| 2ccd67d64c | |||
| f718f739b4 | |||
| 6ec593c783 | |||
| 5418841b92 | |||
| c879a8101b | |||
| 766059e6d1 | |||
| c2b899dd1e | |||
| 7b475bfcc8 |
@@ -0,0 +1,13 @@
|
||||
name = "explorer"
|
||||
description = "Read-only discovery of repository paths, APIs, prerequisites, and bounded task context before implementation."
|
||||
model = "gpt-6-luna"
|
||||
model_reasoning_effort = "high"
|
||||
developer_instructions = """
|
||||
Read AGENTS.md and the assigned PLANS.md context. Investigate only the requested
|
||||
repository paths, official API documentation, or disposable test prerequisites.
|
||||
Use targeted searches and report facts with file/symbol or primary-source links.
|
||||
Do not edit files, install dependencies, run mutating probes, inspect production
|
||||
secrets, start milestones, delegate, or change host services/network settings.
|
||||
Identify uncertain assumptions and concrete blockers separately from verified
|
||||
facts. Return a concise handoff with relevant interfaces and next checks.
|
||||
"""
|
||||
@@ -0,0 +1,18 @@
|
||||
name = "implementer"
|
||||
description = "Implement one bounded task in the active milestone, with focused tests and reproducible evidence."
|
||||
model = "gpt-6-luna"
|
||||
model_reasoning_effort = "high"
|
||||
developer_instructions = """
|
||||
You are the implementation worker, not the coordinator. Read AGENTS.md, PLANS.md,
|
||||
and the specification/ADRs relevant to the assigned task. Work only within the
|
||||
coordinator's file ownership and scope. Preserve unrelated changes and interfaces.
|
||||
Use only approved dependencies. Ask the coordinator to resolve missing scope or
|
||||
interface decisions; do not invent new product requirements or delegate further.
|
||||
Write meaningful behavioral tests and run the assigned available checks.
|
||||
Commit saves files only; restore never implicitly applies; restart is separate.
|
||||
Protect secrets and untouched server configuration. No production access or host
|
||||
service/network changes. Container tests use the approved isolated Alpine setup.
|
||||
Do not mark milestones accepted, change PLANS.md, create Git commits, or deploy.
|
||||
Report changed files, exact commands/results, assumptions, remaining limitations,
|
||||
and the next action. Redact secrets; never claim unexecuted tests passed.
|
||||
"""
|
||||
@@ -0,0 +1,17 @@
|
||||
name = "repairer"
|
||||
description = "Resolve specific review findings in assigned files and verify fixes without broadening milestone scope."
|
||||
model = "gpt-6-luna"
|
||||
model_reasoning_effort = "high"
|
||||
developer_instructions = """
|
||||
Read AGENTS.md, the active PLANS.md milestone, governing requirements, and the
|
||||
assigned review findings. Reproduce or establish each issue, fix its cause in
|
||||
owned files, and add focused regression coverage where useful. Preserve unrelated
|
||||
changes. Use approved dependencies only; do not silently change requirements,
|
||||
remove failing assertions to hide defects, or weaken security/integration gates.
|
||||
No production access, host service/network changes, further delegation, plan
|
||||
acceptance updates, Git commits, or deployment. Follow the isolated Alpine/VM
|
||||
methodology when integration checks are assigned and available.
|
||||
Report each finding ID as fixed, unresolved, or disputed with concrete evidence.
|
||||
Include changed files, exact checks/results, remaining risks, and re-review needs.
|
||||
Return to the coordinator/reviewer; your report does not close the milestone.
|
||||
"""
|
||||
@@ -0,0 +1,19 @@
|
||||
name = "reviewer"
|
||||
description = "Independently inspect implementation and repair results against milestone acceptance criteria; do not edit files."
|
||||
model = "gpt-6-luna"
|
||||
model_reasoning_effort = "high"
|
||||
developer_instructions = """
|
||||
Act as an independent reviewer. Read AGENTS.md, the assigned PLANS.md milestone,
|
||||
and governing specification/ADRs. Inspect the actual changes and affected paths;
|
||||
do not rely on an implementer's summary. Do not edit files, accept milestones,
|
||||
change plans, or delegate. Safe tests may create temporary/generated output only.
|
||||
Prioritize correctness, regressions, secret handling, byte preservation, backup
|
||||
failure behavior, saved/applied separation, and meaningful acceptance coverage.
|
||||
Check test claims against evidence; mocks and Alpine cannot establish real
|
||||
systemd behavior. Never access production or change host network/services.
|
||||
Return actionable findings with stable IDs, severity, file/line, impact,
|
||||
reproduction or evidence, and expected correction. Avoid speculative/style-only
|
||||
findings. Re-review repairs and affected regressions when asked. Report no
|
||||
findings explicitly when justified, together with checks and coverage limits.
|
||||
The coordinator alone decides milestone acceptance.
|
||||
"""
|
||||
@@ -0,0 +1,31 @@
|
||||
# Project-scoped defaults. Existing sessions may retain their model overrides.
|
||||
model = "gpt-6-sol"
|
||||
model_reasoning_effort = "medium"
|
||||
|
||||
developer_instructions = """
|
||||
Coordinate implementation using AGENTS.md and PLANS.md. Do not start milestones
|
||||
merely because this configuration was loaded; act on the user's current request.
|
||||
When milestone execution is requested, execute milestones in ascending order and
|
||||
advance only after acceptance. Own task boundaries, interface decisions, plan
|
||||
updates, and acceptance evidence. Delegate bounded implementation to implementer,
|
||||
independent read-only review to reviewer, targeted fixes to repairer, and optional
|
||||
read-only prerequisite research to explorer. All subagents use GPT-6-Luna/high;
|
||||
do not silently substitute models or reasoning levels. Do not review a worker's
|
||||
own implementation using that same worker as the independent reviewer.
|
||||
Use the loop prepare -> implement -> review -> repair -> re-review -> accept.
|
||||
Continue until acceptance checks pass or a concrete external blocker is recorded.
|
||||
Keep one implementation writer at a time unless tasks have disjoint ownership.
|
||||
Dispatch milestone/task IDs, relevant document sections, owned files, preserved
|
||||
interfaces, approved dependencies, exact checks, and required report contents.
|
||||
Verify actual changed files and evidence; worker confidence is not acceptance.
|
||||
Only the coordinator updates PLANS.md acceptance state. Report unavailable
|
||||
required checks as incomplete. Do not access production, use real private keys,
|
||||
or weaken sandbox/approval policies. Use the approved Alpine Testcontainers and
|
||||
disposable systemd VM strategy; Alpine results do not prove systemd behavior.
|
||||
"""
|
||||
|
||||
[agents]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 3
|
||||
default_subagent_model = "gpt-6-luna"
|
||||
default_subagent_reasoning_effort = "high"
|
||||
@@ -4,6 +4,9 @@
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Python bytecode from opt-in VM test helpers.
|
||||
__pycache__/
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
@@ -16,3 +19,8 @@ target/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Local deployment data and executable-relative settings; keep config examples tracked.
|
||||
/Backup/
|
||||
/wireguard-admin.toml
|
||||
/.obsidian/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Start here
|
||||
|
||||
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, 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) and [execution record rules](docs/EXECUTION_RECORDS.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence.
|
||||
- Read the relevant ADRs: [explicit restart](docs/adr/0001-explicit-restart.md), [client secrets](docs/adr/0002-client-secret-storage.md), [server configuration authority](docs/adr/0003-server-config-authority.md).
|
||||
- Read task-specific supporting documents below before editing that area. Their requirements remain applicable; moving them out of this file does not make them optional.
|
||||
- Preserve unrelated user changes. Resolve specific requirement conflicts before implementing affected behavior; continue independent work. Do not reopen settled decisions or seek approval for routine implementation choices.
|
||||
|
||||
## Non-negotiable behavior
|
||||
|
||||
- Native Rust TUI, root management, one existing WireGuard interface, configurable systemd service. No production experimentation, remote management, server bootstrap, concurrent edit reconciliation, or automatic restart rollback.
|
||||
- Public keys identify clients; aliases may repeat. The server configuration is authoritative. Modify only client `[Peer]` sections and managed metadata blocks; preserve all other settings and administrator comments byte-for-byte. Do not fully reserialize through a generic INI writer.
|
||||
- **Commit saves files/vault only. Restore also changes saved state only. Neither may reload, sync, restart, or mutate the running VPN.** Keep draft, saved, and applied states distinct.
|
||||
- Restart is explicit. Require successful systemd job completion and interface/client verification before marking applied or releasing deleted-client addresses.
|
||||
- Complete a backup before every Commit, restore, and restart; backup failure stops the operation before protected mutations. Restore is a previewed, confirmed full replacement without a vault password or implicit restart.
|
||||
- Preserve original imported files. Report anomalies rather than repairing them silently. New clients get IPv4/IPv6 and full-tunnel routes; preserve existing client parameters and IPv4-only clients.
|
||||
- Settings are executable-relative; default working data is `Backup/data`, archives `Backup/archives`. Never substitute working-directory or system-wide defaults.
|
||||
- Encrypt new secrets without a password using the vault public key. Protect the separate vault secret key with a password. Reissue requires a password per client; retained plaintext files must not bypass it. First delivery follows Commit and supports retry after file failure within the creation session.
|
||||
- Never expose secrets through logs, errors, Debug, or snapshots. Explicit file/QR delivery is the disclosure path. Use synthetic fixtures only; keep real keys/configs/output and working backups out of version control.
|
||||
|
||||
## Development rules and reference map
|
||||
|
||||
Use the pinned Rust toolchain and environment checks in [ENVIRONMENT.md](docs/ENVIRONMENT.md). System-packaged tools do not automatically honor rust-toolchain.toml.
|
||||
|
||||
| When working on | Read first |
|
||||
| --------------------------------------------------------- | ------------------------------------------- |
|
||||
| Module layout and boundaries | [PROJECT_LAYOUT.md](docs/PROJECT_LAYOUT.md) |
|
||||
| Dependencies, Cargo features, build/runtime requirements | [DEPENDENCIES.md](docs/DEPENDENCIES.md) |
|
||||
| Rust, persistence, secrets, archives, terminal behavior | [DEVELOPMENT.md](docs/DEVELOPMENT.md) |
|
||||
| Tests, acceptance evidence, Alpine containers, systemd VM | [TESTING.md](docs/TESTING.md) |
|
||||
| Codex model configuration and custom roles | [CODEX.md](docs/CODEX.md) |
|
||||
|
||||
- Start with one Cargo package, thin `main.rs`, and testable library modules. Separate domain/application logic from filesystem, cryptography, WireGuard, systemd, and TUI adapters. Create modules as needed, not as empty scaffolding.
|
||||
- **Dependencies outside the approved list require comparison of alternatives and explicit user agreement before changing manifests/lockfiles.** Approved crates may be added when needed without asking again. Apply the rule to runtime, build, and development dependencies.
|
||||
- Select compatible versions/minimal features using official documentation; retain Cargo.lock and avoid unrelated upgrades. Use established cryptography, not custom primitives.
|
||||
- Use typed errors/Result; avoid panics for recoverable input/I/O failures. Validate boundary inputs, escape terminal control text, and keep errors secret-free. Bound external operations; never execute configuration text through a shell.
|
||||
- Create secret files/private directories with `0600`/`0700`, including temporary files. Handle partial writes honestly; validate archive destinations and reject traversal/link escapes.
|
||||
- Keep TUI input/rendering responsive, restore the terminal on errors/exit, mask passwords, and identify destructive targets unambiguously. Cancel preserves drafts; never silently commit on exit. Help/version require neither root nor vault access.
|
||||
|
||||
## Agents and acceptance
|
||||
|
||||
Coordinator: **GPT-6-Sol / medium**. Implementation, independent review, repairs, and optional exploration: **GPT-6-Luna / high**, using the configured `implementer`, `reviewer`, `repairer`, and `explorer` roles. Configure actual models through the harness; do not impersonate or silently substitute them.
|
||||
|
||||
The coordinator owns task boundaries, PLANS.md updates, and acceptance. Keep PLANS.md as a current snapshot; put diagnostic attempts in `docs/diagnostics/`, detailed evidence in `docs/evidence/`, and superseded records in `docs/history/`, following [EXECUTION_RECORDS.md](docs/EXECUTION_RECORDS.md). Update statuses in place and keep the register, milestone outcome, and resume point consistent. Default to one writer; parallel writes require disjoint ownership. Use a separate reviewer from the implementer, repair actionable findings, and verify integrated results before advancing. Do not create Git commits, publish, or deploy merely because a milestone is accepted.
|
||||
|
||||
Build/test without root by default. Never read production private keys, modify production configuration, restart host services, or use the live VPN for testing. Alpine Testcontainers suites are opt-in and isolated; actual systemd/D-Bus acceptance requires a disposable VM. Unavailable prerequisites mean incomplete validation, not success.
|
||||
|
||||
After Rust scaffolding exists, run focused behavioral checks and these completion gates for Rust changes:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo test --locked
|
||||
cargo build --locked --release
|
||||
```
|
||||
|
||||
Do not assume all feature combinations are supported. Documentation-only edits require link/consistency checks, not an application rebuild. Report what changed, actual checks/results, and remaining limitations; distinguish fixture, Alpine, and VM evidence. Maintain the plan before handoff and never claim a milestone complete with unmet acceptance criteria.
|
||||
+42
@@ -7,6 +7,45 @@ Terminology for managing device access to a WireGuard VPN.
|
||||
**Draft**:
|
||||
Changes prepared by the administrator but not yet saved through Commit.
|
||||
|
||||
**Saved state**:
|
||||
The installed settings, server configuration, internal metadata, and encrypted
|
||||
vault after Commit or restoration. Saved state may differ from the running VPN.
|
||||
|
||||
**Applied state**:
|
||||
Current knowledge that the running interface has been verified against the
|
||||
saved peer configuration. A saved file, a successful historical restart, or a
|
||||
handshake alone does not establish current applied state. After reopening, the
|
||||
current state is unknown until checked again.
|
||||
|
||||
**Server peer record**:
|
||||
A `[Peer]` section in the authoritative server configuration, including its
|
||||
routes, comments, and any detected issues. A malformed record may have no usable
|
||||
public key and must remain available for inspection.
|
||||
|
||||
**Manageable client**:
|
||||
A server peer that can be represented by the supported client fields and
|
||||
identified unambiguously by its public key for an allowed management action.
|
||||
Some valid peer records, such as peers with subnet routes, remain verifiable
|
||||
without being manageable.
|
||||
|
||||
**Runtime observation**:
|
||||
A read-only measurement of the running interface and its peers, such as routes,
|
||||
last handshake, or current traffic counters. Missing observations are unknown or
|
||||
unavailable; they do not imply zero traffic or successful application.
|
||||
|
||||
**Reserved address**:
|
||||
An address retained so that it cannot be assigned to another client while a
|
||||
deleted peer may still exist on the running interface. It is released only
|
||||
after a confirmed restart has verified the complete saved peer set and the
|
||||
verification state has been durably saved.
|
||||
|
||||
**First delivery**:
|
||||
The one-time, password-free delivery of a newly created client's connection
|
||||
file after Commit in the same creation session. A failed file write can be
|
||||
retried in that session; later delivery requires the vault password. Until the
|
||||
saved configuration is verified as applied, delivery does not establish VPN
|
||||
access.
|
||||
|
||||
**Commit**:
|
||||
Saving prepared changes without applying them to the running VPN.
|
||||
|
||||
@@ -23,5 +62,8 @@ _Avoid_: User, account
|
||||
**Client alias**:
|
||||
An administrator-friendly name for a client. A client is identified by its public key rather than its alias.
|
||||
|
||||
An existing client's creation date remains unknown unless it is present in its
|
||||
managed metadata; an unknown date is not filled in with an estimated value.
|
||||
|
||||
**Anomalous client record**:
|
||||
A record with only a server configuration entry or only a client connection file, without its corresponding counterpart. Records are matched by public key and are not automatically repaired when discovered.
|
||||
|
||||
Generated
+4416
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "wireguard-admin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.97.1"
|
||||
description = "A native terminal application for managing one existing WireGuard interface"
|
||||
|
||||
[lib]
|
||||
name = "wireguard_admin"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "wireguard-admin"
|
||||
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"
|
||||
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.47", default-features = false, features = ["formatting", "parsing", "std"] }
|
||||
toml = "=1.1.8"
|
||||
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
|
||||
zeroize = "=1.9.0"
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "p2p"] }
|
||||
|
||||
[dev-dependencies]
|
||||
testcontainers = { version = "=0.28.0", default-features = false, features = ["blocking"] }
|
||||
@@ -1,2 +1,125 @@
|
||||
# wireguard-admin
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
cargo run -- --help
|
||||
cargo run -- --version
|
||||
```
|
||||
|
||||
`--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
|
||||
|
||||
Copy this example to `wireguard-admin.toml` beside the executable. Adapt every address, range, path, interface name, and service name to the existing server; `192.0.2.1` is a documentation address, not a usable public endpoint. This configuration manages an existing WireGuard interface and systemd service; it does not create either one.
|
||||
|
||||
```toml
|
||||
# Copy this file to wireguard-admin.toml beside the wireguard-admin executable.
|
||||
# Relative paths are resolved from that executable directory, never from the
|
||||
# process working directory. This example uses documentation-only addresses.
|
||||
|
||||
interface_name = "wg0"
|
||||
server_config_path = "/etc/wireguard/wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "37.26.136.232:51820"
|
||||
ipv4_allocation_range = "10.9.0.0/24"
|
||||
ipv6_allocation_range = "2001:db8:1::/64"
|
||||
dns_addresses = ["10.9.0.1", "192.168.0.200"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
|
||||
# Optional. Defaults to Backup beside the executable. The application stores
|
||||
# current data in <data_root>/data and archives in <data_root>/archives.
|
||||
# data_root = "Backup"
|
||||
```
|
||||
|
||||
The parser requires every uncommented field above and rejects unknown fields.
|
||||
The example values are **not defaults**. Only `data_root` has a default.
|
||||
|
||||
| Parameter | Required? | Purpose and validated constraints |
|
||||
| ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `interface_name` | Yes | Name of the existing WireGuard interface. Must be 1–15 ASCII letters, digits, `.`, `_`, or `-`; `.` and `..` are rejected. |
|
||||
| `server_config_path` | Yes | Path to the existing, authoritative server configuration. Must be non-empty. The settings parser does not check that the file exists; startup validates the saved installation. |
|
||||
| `client_source_directory` | Yes | Directory of original client configuration files used for initial vault import and explicit selected-file import. Must be non-empty. Scans consider immediate regular `.conf` files without following symlinks; originals are neither modified nor deleted. |
|
||||
| `server_endpoint` | Yes | Address and port written to generated client configurations: a hostname or IPv4 address followed by `:port`, or an IPv6 address in brackets followed by `:port`. Port must be 1–65535. Hostnames are syntax-checked but not resolved during settings validation. |
|
||||
| `ipv4_allocation_range` | Yes | IPv4 CIDR range from which new-client IPv4 addresses are allocated. Must parse as an IPv4 CIDR network. |
|
||||
| `ipv6_allocation_range` | Yes | IPv6 CIDR range from which new-client IPv6 addresses are allocated. Must parse as an IPv6 CIDR network. |
|
||||
| `dns_addresses` | Yes | Non-empty list of IPv4 and/or IPv6 literal DNS server addresses included in generated client configurations. This does not configure the host's DNS settings. Hostnames are rejected. |
|
||||
| `systemd_unit` | Yes | Existing systemd service targeted by an explicitly requested restart. Must end in `.service`, be at most 255 ASCII bytes, and use only letters, digits, `.`, `_`, `-`, `@`, or `:`; paths and whitespace are rejected. |
|
||||
| `restart_timeout_seconds` | Yes | Positive integer number of seconds for the shared restart deadline. After the mandatory backup, it covers systemd connection, subscription, unit resolution, request and job completion, then the runtime interface read and verification deadline check. It does not time the precautionary backup or durable verification-state write. |
|
||||
| `data_root` | No | Non-empty root directory for application data. Defaults to `Backup` beside the executable. Current internal data and the encrypted vault are stored in `<data_root>/data`; archives are stored in `<data_root>/archives`. |
|
||||
|
||||
The settings file name and location are fixed: `wireguard-admin.toml` must sit beside the executable, regardless of the process working directory. Relative values for `server_config_path`, `client_source_directory`, and `data_root` resolve from that executable directory; absolute paths stay absolute. Settings validation checks syntax and values without checking referenced paths or the service for existence. Commit and Restore change saved state only. Restart is separate and explicit; only successful service completion followed by matching interface/client verification and durable bookkeeping marks the saved state applied. The separate [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml) is another example, with different illustrative values.
|
||||
|
||||
For installation, backup custody, restart failure handling and manual recovery, see [`docs/OPERATIONS.md`](docs/OPERATIONS.md). The qualified evidence and the current host release-candidate assessment are in [`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md); those records do not claim a completed systemd VM run or real IPv6 qualification.
|
||||
|
||||
## Terminal workflows
|
||||
|
||||
The bordered **Keys** panel at the bottom of the screen shows shortcuts for the current screen and form. On narrow list and details screens it prioritizes navigation and Help, and indicates when additional actions are omitted; resize the terminal to see more. Forms, QR disclosure and progress show only their applicable controls. Press `?` from the list or details screen to open Help, which contains the complete shortcut reference. The client list supports arrow-key selection, Enter for details, and `q` to exit. Exit warns when a draft would be discarded; it never commits implicitly. Uppercase shortcuts are distinct from lowercase shortcuts (`R` previews restart, while `r` renames; `B` creates a backup, while `b` lists backups):
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `n` | Create a client with automatic or manual dual-stack addresses |
|
||||
| `r` / `d` | Rename or delete the selected supported peer by full public key |
|
||||
| `p` / `x` | Review or discard the draft |
|
||||
| `c` | Commit saved files after the mandatory backup |
|
||||
| `R` | Preview explicit restart; confirm only after reviewing the choice |
|
||||
| `o` / `O` | First file delivery / password-per-action reissue |
|
||||
| `b` / `B` | List and preview backups / create a manual backup |
|
||||
| `s` / `i` | Initial vault setup / import one selected client file |
|
||||
| `!` | Inspect document and peer anomalies |
|
||||
|
||||
The list header labels **Draft**, **Saved**, and **Applied** separately. Draft reports pending changes even when only vault or managed metadata changed. Saved counts peer sections in the saved server file, including peers this manager cannot edit, and separately counts managed clients. Applied reports verification of the saved configuration; a draft is excluded. “Not verified” does not claim that a restart is pending when that divergence is unknown. A saved peer row does not prove active access, and handshake or traffic counters are observations, not proof that the saved configuration was applied. Details distinguish document or peer problems from valid configuration shapes that are currently outside the management model.
|
||||
|
||||
In Help, Up/Down scroll by one displayed row and PageUp/PageDown move by the visible page. The Help panel shows the current row range and clamps navigation when the terminal is resized. Press `?` to return to the beginning or Esc to return to the client list without changing the selected peer or application state.
|
||||
|
||||
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to cancel. Backup restore previews identify the selected archive and require an explicit confirmation. Restore does not prompt for the vault password and does not restart WireGuard. Client configuration output is a disclosure: first delivery requires a successful Commit in the same session; QR appears only after file publication. Esc closes and clears a QR disclosure. Help in the interface contains the full current shortcut list.
|
||||
|
||||
## Library configuration API
|
||||
|
||||
The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8 configuration bytes supplied by the caller. It retains the source and renders unmodified input byte-for-byte, including line endings and a missing final newline. Peer inspection exposes public identities, parsed `AllowedIPs`, spans, and redacted issues; arbitrary directive values are not included in the view. The edit methods `add_client`, `delete_client`, `rename_client`, and `insert_legacy_metadata` return new in-memory documents. They do not read or write files, save changes, or alter the running VPN. See [`docs/CONFIG_FORMAT.md`](docs/CONFIG_FORMAT.md) for the managed metadata block format.
|
||||
|
||||
Example:
|
||||
|
||||
```rust,ignore
|
||||
use wireguard_admin::wireguard::ConfigDocument;
|
||||
|
||||
let document = ConfigDocument::parse(config_bytes)?;
|
||||
let updated = document.rename_client(&public_key, "office laptop")?;
|
||||
let bytes_to_review = updated.render(); // no persistence or VPN operation
|
||||
```
|
||||
|
||||
Runtime and restart behavior has synthetic fixture and fake-adapter coverage; the accepted Alpine milestone also exercised real IPv4 WireGuard traffic and the Rustix reader. The Alpine fixture did not use systemd/D-Bus, and the retired systemd VM milestone did not establish real-system acceptance. Real IPv6 capability and traffic remain unverified.
|
||||
|
||||
## Saved state, Commit, and backups
|
||||
|
||||
wireguard_admin::app::Application opens saved files through wireguard_admin::persistence::SavedStore, stages client and vault changes, creates manual and mandatory operation backups, and previews/confirms a full restore. Commit writes saved files only. Restore also changes saved files only; neither operation reloads or restarts WireGuard. The server file is kept at its trusted configured path, while internal state and the encrypted vault are kept under Backup/data by default. Archives are stored under Backup/archives and contain fixed logical members, not source client files or prior archives.
|
||||
|
||||
The persistence journal recovers interrupted file replacement on reopen and returns a recovery error while earlier work remains unresolved. A confirmed restart marks the saved generation applied only after a successful service job, a matching complete runtime observation, and journaled verification-state write. Restoring a backup does not restore runtime state: the running interface is unknown until a later explicit restart and verification. Pending deleted-client addresses remain reserved through Commit, reopen, and restore. See [docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and [docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details, and [docs/RUNTIME.md](docs/RUNTIME.md) for observation and restart boundaries.
|
||||
|
||||
## Vault and existing-client import library
|
||||
|
||||
`wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned vault bundle, caller-buffer client configuration parsing, and reconciliation against a supplied server document. The source adapter can prepare an initial encrypted bundle or explicitly import candidates into an existing prepared bundle. These operations return in-memory data only: they do not install or save a vault, edit source files or server configuration, Commit, or touch the VPN.
|
||||
|
||||
Directory scans use the resolved `client_source_directory` only. A candidate is an immediate regular file whose name ends in `.conf` (case-insensitive); the adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total input. Per-file failures are redacted and do not prevent other files from being examined. Enumeration, read, or limit failures mark the scan incomplete. The original files remain untouched. Adapter examples and behavior are covered with synthetic temporary directories in `tests/vault_import.rs`.
|
||||
|
||||
## Client delivery and reissue
|
||||
|
||||
`Application::prepare_new_client` stages a dual-stack peer and encrypted key. After a successful `commit`, `deliver_first_issue_file` can publish its first configuration without a vault password during that same session; a failed file publication can be retried to a newly selected destination. The first file is mandatory. `deliver_first_issue_file_and_qr` optionally returns a QR matrix from the same authorized bytes after file publication. Both success results carry an activation warning unless this session has verified the exact installed saved generation and its matching applied peer snapshot; historical verification and unknown runtime state do not suppress it.
|
||||
|
||||
Later delivery uses `Application::reissue_client_file`, which takes the client public key, a password for that action, a prepared destination, and an optional QR choice. It freshly unlocks the installed saved vault each time, reads saved server/settings bytes rather than draft state, preserves validated imported parameters, and rejects blocked or stale records. It never reads original plaintext client files as a fallback. The result reports a secret-free failure stage and warns when the current saved peer set has not been verified as applied in this live session. Historical verification timestamps do not suppress that warning after reopen. File output uses mode `0600`, explicit overwrite confirmation, and separate QR failure reporting. These APIs and the TUI password prompt/terminal QR display have synthetic fixture coverage. No production configuration or key is included in the repository.
|
||||
|
||||
## Test commands and integration limits
|
||||
|
||||
Run the default synthetic workflow and UI checks as an unprivileged user:
|
||||
|
||||
```sh
|
||||
cargo test --locked --test tui_workflow
|
||||
cargo test --locked tui::
|
||||
```
|
||||
|
||||
The first exercises setup, client staging, Commit, delivery, backup listing, and restore through the serialized worker with temporary files and a fake statistics reader. The second covers navigation/forms/rendering, delayed worker behavior, terminal lifecycle seams, and QR sizing. Neither command starts the management UI or uses host service/runtime mutation. See [`docs/TESTING.md`](docs/TESTING.md) for full gates and the isolated Alpine (M8) and disposable systemd VM (M9) boundaries. An actual PTY smoke run using synthetic application/runtime adapters is included in `tui_workflow`; it uses `script` and a fake unavailable-statistics reader, without root or host settings. It checks an 80×24 to 40×8 to 80×24 resize sequence and terminal cleanup after `q`. The workflow also checks that Esc clears a returned QR disclosure; the
|
||||
focused renderer test checks complete quiet-zone fit. Do not use the development host's settings or VPN as a substitute. M1–M8 qualification evidence and the remaining M9 limitations are summarized in [`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copy this file to wireguard-admin.toml beside the wireguard-admin executable.
|
||||
# Relative paths are resolved from that executable directory, never from the
|
||||
# process working directory. This example uses documentation-only addresses.
|
||||
|
||||
interface_name = "wg0"
|
||||
server_config_path = "etc/wireguard/wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.invalid:51820"
|
||||
ipv4_allocation_range = "192.0.2.0/24"
|
||||
ipv6_allocation_range = "2001:db8:1::/64"
|
||||
dns_addresses = ["192.0.2.1", "2001:db8:1::1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
|
||||
# Optional. Defaults to Backup beside the executable. The application stores
|
||||
# current data in <data_root>/data and archives in <data_root>/archives.
|
||||
# data_root = "Backup"
|
||||
@@ -0,0 +1,116 @@
|
||||
# Backup archive format
|
||||
|
||||
Backups are uncompressed tar archives with a versioned TOML manifest. Version 1
|
||||
uses only fixed logical member names and is parsed without extracting files.
|
||||
The archive is an opaque saved-state copy; its tar checksum detects accidental
|
||||
header damage but does not authenticate contents or make the archive tamper-proof.
|
||||
|
||||
## Members and limits
|
||||
|
||||
The first member is always `manifest.toml`. The only permitted payload names are
|
||||
`settings.toml`, `server.conf`, `internal-state.toml`, and `vault.toml`, in that
|
||||
order when present. Settings and server configuration are required. Internal
|
||||
state and vault are optional components: a missing component is recorded as
|
||||
`present = false` and `length = none`, which means restore removes that owned
|
||||
component. Absence is not represented by an empty placeholder file.
|
||||
|
||||
| Member | Maximum bytes | Presence |
|
||||
| --- | ---: | --- |
|
||||
| `manifest.toml` | 64 KiB | Required, first |
|
||||
| `settings.toml` | 1 MiB | Required |
|
||||
| `server.conf` | 4 MiB | Required |
|
||||
| `internal-state.toml` | 2 MiB | Optional |
|
||||
| `vault.toml` | 16 MiB | Optional |
|
||||
|
||||
The archive is limited to 26 MiB and five entries. Only checksummed UStar regular
|
||||
files are accepted. Absolute names, path separators, traversal, links, device or
|
||||
special entries, sparse entries, GNU/PAX extension headers, nonzero path prefixes,
|
||||
unexpected metadata fields, duplicate/missing/unexpected members, size mismatches,
|
||||
truncation, nonzero padding, and nonzero trailing data are rejected. The reader
|
||||
checks declared sizes before allocating payload buffers and never follows a
|
||||
tar-supplied path. It does not restore files or validate their application
|
||||
semantics; restoration must run settings, configuration, state, and vault
|
||||
validators before mutation.
|
||||
|
||||
## Manifest v1
|
||||
|
||||
The manifest is UTF-8 TOML with unknown fields denied:
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
created_at_utc = "2026-10-19T12:30:00Z"
|
||||
reason = "pre-commit"
|
||||
|
||||
[historical_apply]
|
||||
status = "unknown"
|
||||
|
||||
[[members]]
|
||||
name = "settings.toml"
|
||||
present = true
|
||||
length = 420
|
||||
|
||||
[[members]]
|
||||
name = "server.conf"
|
||||
present = true
|
||||
length = 640
|
||||
|
||||
[[members]]
|
||||
name = "internal-state.toml"
|
||||
present = false
|
||||
|
||||
[[members]]
|
||||
name = "vault.toml"
|
||||
present = true
|
||||
length = 8192
|
||||
```
|
||||
|
||||
`reason` is one of `manual`, `pre-commit`, `pre-restore`, or `pre-restart`.
|
||||
Timestamps are canonical RFC 3339 UTC values with whole-second precision. The
|
||||
historical apply record is either `status = "unknown"` or
|
||||
`status = "verified"` with a `verified_at_utc` timestamp. It is historical
|
||||
metadata only: a pre-operation reason or a verified marker does not establish
|
||||
that these bytes match the currently running interface. Application-created
|
||||
backups derive this record from the timestamp stored in the internal-state bytes
|
||||
captured with the saved set. A successful Commit or restore clears that timestamp
|
||||
for its new saved generation; a failed operation leaves it intact. Reopening may
|
||||
show the saved generation's historical verification time, but runtime observation
|
||||
always begins as unknown.
|
||||
|
||||
The inventory always has exactly four records in the specified order. Required
|
||||
members have `present = true` and an exact byte `length`; optional absence has
|
||||
`present = false` and omits `length` (TOML has no null value). Present optional members follow the required
|
||||
members in fixed order. The manifest itself is excluded from its payload
|
||||
inventory. Readers reject unsupported versions and any discrepancy between
|
||||
inventory and tar entries.
|
||||
|
||||
## Publication and trust boundary
|
||||
|
||||
Creation receives exact saved bytes from a trusted caller. It does not discover
|
||||
files, inspect the current working directory, recurse into the archive directory,
|
||||
or read client source/output directories. The caller must supply installed bytes,
|
||||
never a proposed draft or decrypted vault records. New archives are fully
|
||||
serialized and synced in a private temporary file, then published without
|
||||
replacement under a timestamped collision-safe name; the archive directory is
|
||||
synced after publication. The archive file is mode `0600` from temporary-file
|
||||
creation. A write or file-sync failure removes the unpublished temporary file;
|
||||
a publication or directory-sync failure returns an error and blocks the protected
|
||||
operation. A complete archive may already be visible if failure occurs after its
|
||||
atomic publication, and a failed temporary cleanup may leave a hidden `0600`
|
||||
temporary file. Neither condition is reported as backup success. Readers should
|
||||
expose the list and preview without passwords, but must keep payload bytes out of
|
||||
Debug output and logs.
|
||||
|
||||
The application restore preview validates both the archive structure and its
|
||||
settings, server document, optional internal state, and opaque vault before it
|
||||
returns an opaque confirmation value. That value owns the validated payload
|
||||
bytes, so confirmation applies the exact content shown during preview even if
|
||||
the selected archive path changes afterward. Confirmation first completes a
|
||||
`pre-restore` archive and then uses the saved-file transaction journal for the
|
||||
full replacement. Dropping the preview is cancellation and does not discard a
|
||||
draft. Historical verification metadata is informational; opening or restoring
|
||||
an archive always leaves current runtime state unknown.
|
||||
|
||||
This module only creates, lists, and validates archives. It does not write saved
|
||||
state, apply WireGuard changes, or restore content. A backup is not encrypted:
|
||||
it can contain the server's private configuration and the encrypted vault, so
|
||||
filesystem access must remain restricted to root.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Codex Implementation Flow
|
||||
|
||||
## Project configuration
|
||||
|
||||
The project configuration is `.codex/config.toml`. It selects GPT-6-Sol with
|
||||
medium reasoning for the primary coordinator and GPT-6-Luna with high reasoning
|
||||
as the default for spawned agents. Custom project agents are discovered from
|
||||
`.codex/agents/`:
|
||||
|
||||
| Agent | File | Responsibility |
|
||||
| ------------- | -------------------------------- | --------------------------------------------------- |
|
||||
| `implementer` | `.codex/agents/implementer.toml` | Implement a bounded task and focused tests |
|
||||
| `reviewer` | `.codex/agents/reviewer.toml` | Independently review actual changes without editing |
|
||||
| `repairer` | `.codex/agents/repairer.toml` | Resolve findings and provide regression evidence |
|
||||
| `explorer` | `.codex/agents/explorer.toml` | Optional read-only prerequisite research |
|
||||
|
||||
All four custom agents explicitly select GPT-6-Luna/high. There is no separate
|
||||
coordinator subagent: the primary session is the coordinator. At most three
|
||||
spawned threads run concurrently, but file ownership defaults to one writer.
|
||||
|
||||
## Starting work
|
||||
|
||||
Open a fresh Codex session in this project, using a client that supports the
|
||||
documented project configuration and custom agents. Project trust, managed
|
||||
requirements, and explicit client/session overrides can affect which settings
|
||||
are applied. Verify the coordinator and spawned-agent model/effort settings in
|
||||
the client before implementation; report unavailable models rather than silently
|
||||
substituting alternatives. Existing sessions are not reconfigured by these files.
|
||||
|
||||
An example execution request is:
|
||||
|
||||
> Execute the next pending milestone in PLANS.md. Use implementer for bounded
|
||||
> implementation, a fresh reviewer for independent review, and repairer for
|
||||
> findings. Repeat review and repair until the acceptance gates pass, update
|
||||
> PLANS.md with evidence, and stop after accepting this milestone or recording
|
||||
> a concrete blocker. Use explorer only when prerequisite research is needed.
|
||||
|
||||
For an explicitly authorized full-plan run, continue to the next milestone only
|
||||
after the current one is accepted. Do not confuse this project's Commit action
|
||||
with permission to create Git commits.
|
||||
|
||||
## Flow and limitations
|
||||
|
||||
The root instructions define prepare, implement, review, repair, re-review, and
|
||||
acceptance. PLANS.md is the durable record of milestone/task IDs, ownership,
|
||||
progress, findings, validation, and blockers. Follow [execution record rules](EXECUTION_RECORDS.md): update current state in place, keep detailed investigations in `docs/diagnostics/`, detailed evidence in `docs/evidence/`, and superseded history in `docs/history/`. Include these boundaries in worker dispatches; reassess repeated failed repairs before another expensive run. The TOML files configure model
|
||||
selection and agent instructions; they do not implement a deterministic workflow
|
||||
engine or automatically start execution.
|
||||
|
||||
Reviewer/explorer read-only behavior is an instruction boundary, not a distinct
|
||||
OS sandbox. Custom agents inherit the parent's permission controls. This setup
|
||||
does not change sandbox, approvals, provider, MCP access, or user-level trust.
|
||||
|
||||
Container tests require a disposable Alpine Testcontainers environment, and real
|
||||
systemd validation requires a separate disposable VM; see [TESTING.md](TESTING.md).
|
||||
No role is authorized to experiment on production.
|
||||
|
||||
## Verification status
|
||||
|
||||
The files were parsed as TOML and checked for consistent role names and requested
|
||||
model/effort values. A live model session and subagent dispatch were not started.
|
||||
The installed CLI reports version 0.161.0. Its `features` command does not support
|
||||
`--strict-config`, so that command cannot establish complete schema validation.
|
||||
|
||||
Official references:
|
||||
|
||||
- [Subagents and custom agent files](https://learn.chatgpt.com/docs/agent-configuration/subagents)
|
||||
- [Configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference)
|
||||
@@ -0,0 +1,98 @@
|
||||
# Managed metadata in the WireGuard configuration
|
||||
|
||||
WireGuard Admin stores a client's display alias and optional creation time in a
|
||||
versioned comment block immediately before that client's `[Peer]` header. The
|
||||
server configuration remains the source of truth. The block contains no public
|
||||
key copy, private key, preshared key, password, or other connection secret.
|
||||
|
||||
## Version 1 grammar
|
||||
|
||||
The canonical LF form is:
|
||||
|
||||
```ini
|
||||
# wg-admin:begin
|
||||
# wg-admin:version=1
|
||||
# wg-admin:alias=<base64url-utf8-without-padding>
|
||||
# wg-admin:created-utc=2026-10-18T19:00:00Z
|
||||
# wg-admin:end
|
||||
[Peer]
|
||||
```
|
||||
|
||||
`created-utc` is optional. If it is unknown, it is omitted; an existing peer's
|
||||
unknown creation date is never inferred from import time, file time, or edit time.
|
||||
The timestamp grammar is exactly `YYYY-MM-DDTHH:MM:SSZ`, with a real Gregorian
|
||||
calendar date, a four-digit nonzero year, and seconds from `00` through `59`.
|
||||
Leap seconds and fractional seconds are not represented.
|
||||
|
||||
The alias line contains the canonical URL-safe Base64 encoding of the alias's
|
||||
UTF-8 bytes, with no `=` padding. This representation is a single comment line
|
||||
and makes characters such as `#`, `=`, quotes, non-ASCII text, and strings that
|
||||
look like block markers unambiguous. An alias must pass the application's shared
|
||||
alias validation: it cannot be empty or contain Unicode control characters.
|
||||
The encoded representation is decoded and re-encoded canonically when read.
|
||||
|
||||
The v1 block has exactly one begin marker, one version field, one alias field,
|
||||
optional single creation field, and one end marker, in the displayed order. No
|
||||
other lines or fields are permitted. Unknown fields, duplicate fields, malformed
|
||||
encoding, or invalid timestamps make the block invalid. A version other than 1
|
||||
is reported as unsupported and is not interpreted or rewritten.
|
||||
|
||||
## Ownership and attachment
|
||||
|
||||
The exact reserved marker lines are `# wg-admin:begin` and `# wg-admin:end`.
|
||||
Marker lines with trailing whitespace or extra text are treated as malformed
|
||||
reserved markers. Other administrator comments, including comments that merely
|
||||
mention `wg-admin`, do not become managed blocks.
|
||||
|
||||
A valid block owns only its own comment lines and line endings. It is attached to
|
||||
one peer only when its end marker line, including its line ending, ends at the
|
||||
first byte of that peer's `[Peer]` header. No blank line, administrator comment,
|
||||
or other text may intervene. A block elsewhere is reported as misattached. A
|
||||
missing block is the legacy state: no alias or creation date is inferred, and no
|
||||
block span is available to edit. Multiple adjacent blocks before one peer are a
|
||||
duplicate and block metadata edits. Malformed, misattached, duplicate, and
|
||||
unsupported-version blocks are retained as supplied and block unsafe metadata
|
||||
edits; they are not treated as absent metadata.
|
||||
|
||||
The parser exposes the decoded alias only for a valid, uniquely attached v1
|
||||
block. It exposes an owned byte span only for that valid block. Issues contain
|
||||
line numbers and categories but never raw source text. Parsing and rendering are
|
||||
caller-buffer operations and do not read or write configuration files.
|
||||
|
||||
## Examples
|
||||
|
||||
An alias containing syntax characters and Unicode is encoded, not escaped in
|
||||
WireGuard syntax. For example, the alias `# café = "laptop"` is represented as:
|
||||
|
||||
```ini
|
||||
# wg-admin:begin
|
||||
# wg-admin:version=1
|
||||
# wg-admin:alias=IyBjYWbDqSA9ICJsYXB0b3Ai
|
||||
# wg-admin:end
|
||||
[Peer]
|
||||
```
|
||||
|
||||
A marker-looking alias is handled the same way. Applications should use the
|
||||
canonical renderer rather than hand-constructing the encoded value.
|
||||
|
||||
An existing client with unknown creation time has no `created-utc` field:
|
||||
|
||||
```ini
|
||||
# wg-admin:begin
|
||||
# wg-admin:version=1
|
||||
# wg-admin:alias=V29ya2xhcGU
|
||||
# wg-admin:end
|
||||
[Peer]
|
||||
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
```
|
||||
|
||||
An unrelated administrator comment is preserved and does not describe managed
|
||||
metadata:
|
||||
|
||||
```ini
|
||||
# Added during the 2024 network migration; wg-admin is not the owner of this note.
|
||||
[Peer]
|
||||
PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=
|
||||
AllowedIPs = 10.0.0.3/32
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Dependency Policy and Approved Choices
|
||||
|
||||
Read this document before changing dependencies, features, or build/runtime requirements. The approved choices may be introduced without renewed approval; unapproved direct dependencies require prior user agreement.
|
||||
|
||||
## Approved dependencies
|
||||
|
||||
The user has approved the following choices. They may be introduced when their implementation stage needs them without requesting approval again. Approval does not mean they are already installed. Select compatible maintained versions and minimal features, and record exact resolved versions in `Cargo.lock`.
|
||||
|
||||
| Purpose | Approved crates | Intended use |
|
||||
| --------------------------- | ---------------------- | -------------------------------------------------------------------- |
|
||||
| Terminal UI | `ratatui`, `crossterm` | Widgets, layout, keyboard events, terminal lifecycle |
|
||||
| Serialization and settings | `serde`, `toml` | Typed application settings and metadata; TOML backup manifests |
|
||||
| Typed errors | `thiserror` | Recoverable domain and adapter errors |
|
||||
| Address ranges | `ipnet` | IPv4/IPv6 subnet validation and allocation support |
|
||||
| Dates | `time` | UTC metadata, backup timestamps, handshake display |
|
||||
| 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, 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` 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.
|
||||
- `wireguard-uapi` remains an approved option but is not selected for the bounded runtime reader: its public read API does not expose a receive deadline or a complete-dump guarantee. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application.
|
||||
- Runtime observation uses the already approved `rustix` 1.1.5 safe `net` and `event` APIs for a nonblocking Linux Generic Netlink socket, polling and datagram I/O. This keeps one monotonic deadline and exact truncation/sender checks around the checked WireGuard decoder. It does not add a direct dependency or use a subprocess.
|
||||
- The systemd adapter pins `zbus` 5.19.0 with default features disabled and `async-io` plus `p2p` enabled. It first uses the system bus, then selects `/run/systemd/private` peer mode only when the bus connection reports a missing/refused/disconnected endpoint, before submitting any restart request. Permission errors, authentication/protocol failures, and deadline expiry do not trigger fallback. Private mode uses EXTERNAL authentication and checks the peer UID is root. Bus calls use the systemd well-known destination; private calls omit the destination and avoid bus-daemon owner monitoring. zbus's local peer signal matching tracks `JobRemoved` after systemd `Subscribe`. Each call uses zbus's asynchronous APIs driven by a small standard-library wake/park loop so operations share the caller's monotonic deadline. zbus's async-io backend owns internal I/O executor thread(s); callers must allow the connection to be dropped after each operation. Signal polling uses zbus 5.19.0's doc-hidden `export::futures_core` re-export to avoid another direct dependency; keep zbus pinned until that implementation is deliberately migrated. The private systemd socket is an internal systemd interface and may change between releases.
|
||||
- Avoid incompatible duplicate versions of secret-handling types where library APIs exchange them. Use supported re-exports where appropriate rather than adding redundant direct dependencies.
|
||||
- Additional direct dependencies still require the comparison and prior agreement required by [AGENTS.md](../AGENTS.md), including any test/build helpers. Transitive dependencies of approved crates are resolved through Cargo; review their compatibility and runtime requirements. Do not add a direct `tokio` dependency merely because an approved library uses an async runtime internally.
|
||||
- `clap`, `chrono`, SQLite, a generic INI writer, a mocking framework, and archive compression libraries are not part of the approved initial set. Use the standard library for the small startup argument surface and background threads/channels where sufficient.
|
||||
|
||||
### External runtime and build requirements
|
||||
|
||||
- Production requires Linux with WireGuard support and the existing configured systemd service. Restart uses the system D-Bus when available and can use systemd's root-only `/run/systemd/private` peer socket when the bus endpoint is unavailable. Permission or authentication failures and timeouts stop the operation without trying the private socket. The private socket is not a stable public systemd API. Existing `wg-quick` service dependencies remain necessary if that is how the service manages the interface.
|
||||
- The application uses library APIs rather than invoking `wg`, `systemctl`, `age`, `qrencode`, `tar`, or `openssl` for its own operations. Test fixture setup and the existing WireGuard service may use their required system tools; that does not create an application subprocess fallback.
|
||||
- Rust/Cargo and the target toolchain/linker are build requirements, not production application requirements. Verify the release binary's actual dynamic-library requirements rather than promising an automatically static executable.
|
||||
- A Docker-API-compatible runtime, test images, and container tools belong to the integration environment only. Production does not need Docker or Testcontainers.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Engineering Guidelines
|
||||
|
||||
Read the relevant sections before implementing filesystem, secret-handling, terminal, or system operations. These detailed rules supplement [AGENTS.md](../AGENTS.md); they remain applicable after being moved out of the automatically loaded instructions.
|
||||
|
||||
## Product boundaries
|
||||
|
||||
- Build a native Rust TUI for root on a Linux VPN server, managing one existing WireGuard interface and a configurable systemd service.
|
||||
- Use public keys as client identities. Aliases may repeat; selection and destructive actions must resolve to a public key, not an alias or row position alone.
|
||||
- Keep the server configuration as the source of truth. Modify only client `[Peer]` sections and associated managed metadata comments. Preserve all other settings and administrator comments byte-for-byte. Do not fully reserialize the file through a generic INI writer.
|
||||
- Model draft, saved, and applied state explicitly. **Commit writes files and the vault only. It must never reload, sync, restart, or otherwise change the running VPN.** Restore also changes saved state only.
|
||||
- Restart is a separate explicit action. Check systemd completion, interface presence, and saved client correspondence before marking changes applied or releasing deleted clients' addresses.
|
||||
- Allocate IPv4 and IPv6 from configured ranges and generate full-tunnel client configurations. Preserve existing IPv4-only clients and existing connection parameters.
|
||||
- Preserve original client files during import. Report anomalies; do not silently repair discrepancies or invent unknown creation dates.
|
||||
- Do not add multi-interface support, remote management, initial VPN server setup, concurrent edit reconciliation, automatic restart rollback, traffic history, or additional connection settings to version 1.
|
||||
|
||||
## Architecture and implementation order
|
||||
|
||||
Follow [IMPLEMENTATION.md](IMPLEMENTATION.md): model/settings, preserving configuration edits, keys/vault, backups/Commit/restore, runtime adapter, delivery/TUI, then acceptance documentation and target build.
|
||||
|
||||
- Separate pure domain logic from filesystem, cryptography, WireGuard, systemd, and terminal adapters. The TUI invokes application operations rather than implementing their rules in key handlers.
|
||||
- Keep changes and state transitions testable without root, a terminal, a running VPN, or a real service manager. Prefer narrow interfaces and simple types over speculative frameworks.
|
||||
- Resolve settings relative to the executable, not the working directory. Default data paths are `Backup/data` and `Backup/archives` alongside the executable. Honor configured paths; do not substitute system-wide installation directories.
|
||||
- Distinguish current data from archives. Never include previous archives recursively in a backup.
|
||||
- Identify and document versioned formats for managed comments, the vault, and backup manifests before persisting them. Reject unsupported versions with actionable errors rather than guessing.
|
||||
- Keep runtime statistics separate from desired configuration. Missing runtime data is unknown/unavailable, not zero traffic, a successful apply, or proof of a disconnected client.
|
||||
|
||||
## Rust and dependency practices
|
||||
|
||||
- Prefer maintained Rust libraries where appropriate. A TUI library, QR library, and established cryptographic format are legitimate dependencies. A single native executable does not mean a dependency-free build.
|
||||
- New dependencies outside the approved list in [DEPENDENCIES.md](DEPENDENCIES.md) are allowed, but require prior agreement with the user. Before adding one, establish the need, compare suitable alternatives (including existing dependencies or the standard library where applicable), and recommend the best fit with its trade-offs. Add the dependency only after the user agrees to the choice; do not modify dependency manifests or lockfiles to introduce it before that agreement. This applies to runtime, build, and development dependencies.
|
||||
- Check official crate documentation and compatibility before choosing versions. Add only needed features and dependencies; record any unavoidable runtime system utility and why it is necessary.
|
||||
- Commit `Cargo.lock` for the application once the project is initialized. Keep builds reproducible; do not force dependency updates unrelated to the task.
|
||||
- Use typed errors and `Result` for recoverable failures. Add operation/path context without exposing secrets. Avoid `unwrap`, `expect`, and panics in production paths that handle files, user input, archives, cryptography, or services.
|
||||
- Validate input at boundaries: keys, aliases, endpoint, address families/ranges, paths, and service name. Reject aliases containing line breaks or control sequences that could inject configuration or terminal content. Do not enumerate an entire IPv6 subnet to find an address.
|
||||
- Avoid unnecessary `unsafe`. If required by a low-level interface, isolate it and document its safety invariants. Keep privileged operations out of build scripts.
|
||||
- Use a direct supported API where practical. If an external program is necessary, pass arguments explicitly without a shell, check its exit status, bound its execution time, and redact sensitive output. Never execute arbitrary configuration text as a shell command.
|
||||
|
||||
## Files, secrets, and backups
|
||||
|
||||
- Require root for actual management operations; ordinary builds and tests must not require root or change host services.
|
||||
- Use established asymmetric encryption and password protection with operating-system randomness. Do not design cryptographic primitives or silently weaken password protection for convenience.
|
||||
- Encrypt new client secrets with the vault public key without prompting. Require a password separately for each client reissue; do not retain an unlocked vault across unrelated operations.
|
||||
- Keep plaintext secrets short-lived and use secret-handling/zeroization facilities where supported. Exclude passwords, private keys, complete client configurations, and secret-bearing command output from logs, errors, `Debug` output, snapshots, and fixtures. Explicit file/QR delivery is the intended disclosure path.
|
||||
- Do not bypass reissue authentication by falling back to retained plaintext source files. Original files remain untouched, but their presence is not an alternative password mechanism.
|
||||
- First delivery is allowed only after a successful Commit in the client's creation session. Preserve retry eligibility after failed file output, including a change of directory. After successful delivery or application exit, require the password for reissue.
|
||||
- Always save a normal client configuration file when delivering; QR is additional. Confirm before overwriting an existing file. Create secret-bearing files with mode `0600` and private directories with mode `0700` from the start, including temporary files.
|
||||
- Use same-directory temporary files and atomic replacement where applicable. Handle write, flush, and rename errors. For operations involving multiple files, define failure/recovery behavior explicitly; a partially written state must not be reported as a successful Commit. This is write integrity, not concurrent editing support.
|
||||
- Guard privileged file operations against unintended symlink traversal and unsafe archive paths. Validate archive contents and permitted destinations before replacement; reject traversal, link escapes, and unexpected entries. Do not blindly extract an archive over the filesystem.
|
||||
- Create a completed backup before every Commit, restore, and restart. Backup failure must stop that operation before its protected mutations. Backups include settings, server configuration, internal metadata, and the complete encrypted vault; exclude drafts and original client files.
|
||||
- Restoring is a previewed, confirmed full replacement with a precautionary backup, not a merge. It requires no vault password and never implicitly restarts WireGuard. Server paths are invariant; do not add migration machinery.
|
||||
- Do not label the newest archive as known-working merely because it exists. Track verified application status separately. Do not delete old archives automatically.
|
||||
- The vault password does not encrypt the entire backup or server private key. Keep this distinction accurate in documentation and UI.
|
||||
|
||||
## Terminal and console behavior
|
||||
|
||||
- Keep the TUI responsive during filesystem, cryptographic, and service operations. Show progress and actionable failures; do not block the input/render loop indefinitely or allow duplicate submission of an operation in progress.
|
||||
- Support keyboard navigation, visible focus, discoverable shortcuts, cancellation, terminal resizing, and readable empty/error states. Do not convey draft, unapplied, or anomalous state by color alone.
|
||||
- Restore raw mode, alternate screen, and cursor visibility on normal exit, errors, and handled interruptions, including panic cleanup where feasible. Avoid mixed diagnostic output corrupting the active screen.
|
||||
- Require a suitable terminal before entering interactive mode. Provide plain diagnostics and a nonzero exit status if interactive startup is impossible. Support conventional `--help` and `--version` without requiring root or reading the vault.
|
||||
- Keep diagnostics on stderr outside the TUI, reserve stdout for intentional output, and avoid terminal escape sequences when output is redirected. Do not invent a separate scripting interface as part of version 1.
|
||||
- Treat aliases, imported comments, filenames, and external error strings as untrusted display text; escape terminal control characters. Mask password input.
|
||||
- Make destructive confirmations identify the selected client or archive unambiguously. Before restart/exit, offer the specified draft-discard confirmation; cancellation keeps the current draft. Never silently commit on exit.
|
||||
- Display last-handshake time and current byte counters, not an inferred online flag or historical totals. Bound periodic refresh work and report unavailable statistics without terminating the management session.
|
||||
|
||||
## Completion report and maintenance
|
||||
|
||||
For each delivered change, state what works, which specification behavior it covers, what was checked, and any remaining limitations. Separate unit/fixture results from dedicated real-system integration results. Do not call a milestone complete while its acceptance criteria remain unverified.
|
||||
|
||||
Keep examples synthetic and documentation synchronized with actual commands, formats, paths, and recovery behavior. Keep AGENTS.md concise; maintain detailed guidance in these supporting documents, requirements in the specification, and implementation rationale in ADRs.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Rust Development Environment
|
||||
|
||||
## Pinned baseline
|
||||
|
||||
[rust-toolchain.toml](../rust-toolchain.toml) pins Rust **1.97.1**, the minimal
|
||||
profile, and `rustfmt`/`clippy`. The native development host is
|
||||
`x86_64-unknown-linux-gnu`; the additional `x86_64-unknown-linux-musl` target is
|
||||
for executables running in Alpine integration containers. The host target is
|
||||
implicitly included by rustup. Do not force all Cargo builds to musl.
|
||||
|
||||
Use Rust edition 2024 when creating Cargo.toml. Set its `rust-version` to
|
||||
`1.97.1` as the initial supported compiler floor; this is distinct from the
|
||||
exact toolchain pin. Do not claim compatibility with older compilers without
|
||||
testing it. Keep Cargo.lock tracked once the package exists.
|
||||
|
||||
## Toolchain selection
|
||||
|
||||
With rustup-managed Cargo/rustc proxies, entering the repository selects the
|
||||
toolchain file and may download missing components. Check `rustup show` and
|
||||
`rustup target list --installed`; explicit overrides may take precedence.
|
||||
Do not automatically install a global toolchain or modify the user's PATH.
|
||||
|
||||
This host currently uses system-packaged tools under `/usr/bin`, without
|
||||
`rustup`. Such binaries do not enforce rust-toolchain.toml. Before execution,
|
||||
verify the installed compiler and Cargo match the pin, and that formatter and
|
||||
Clippy are available. If versions differ, stop the affected build and report
|
||||
the mismatch rather than silently updating the pin.
|
||||
|
||||
Checks available before Cargo scaffolding:
|
||||
|
||||
```sh
|
||||
rustc -Vv
|
||||
cargo -V
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
```
|
||||
|
||||
After scaffolding, use the completion commands in [AGENTS.md](../AGENTS.md).
|
||||
The first dependency resolution generates Cargo.lock; subsequent checks use
|
||||
`--locked`. Registry access or a populated cache is required for dependencies.
|
||||
|
||||
## Alpine target
|
||||
|
||||
The toolchain file requests the musl Rust standard library; it does not supply
|
||||
all native compilers/linkers needed by a cross build. Establish the musl linker
|
||||
and any C dependency requirements before milestone 8, or build in a dedicated
|
||||
Alpine/musl builder. Keep build tools out of the small runtime test image.
|
||||
|
||||
The Alpine suite has two separate execution environments:
|
||||
|
||||
- The host runs the Rust Testcontainers harness and must have the pinned Rust
|
||||
toolchain, approved development dependencies, and access to an explicitly
|
||||
selected disposable Docker-compatible Linux daemon. Testcontainers manages
|
||||
only scenario-owned networks/containers. Do not silently use a remote daemon
|
||||
or the default local daemon before it has been selected as disposable.
|
||||
- The daemon's Linux host must already provide kernel WireGuard support and
|
||||
IPv4 networking. Server and client containers share that kernel but receive
|
||||
only scoped `NET_ADMIN` in their own network namespaces. The suite verifies
|
||||
support in disposable probe containers on a private internal IPv4 network. It
|
||||
must not load modules, alter host sysctls, or create host interfaces. Missing
|
||||
kernel, daemon, IPv4, or namespace prerequisites are an incomplete explicit
|
||||
run, not a passing skip. IPv6 capability and traffic are intentionally not
|
||||
tested by M8; production dual-stack behavior remains unchanged.
|
||||
|
||||
The fixture image is pinned to an Alpine release or digest and records the
|
||||
resolved image identity and complete installed package closure. It contains
|
||||
runtime utilities only (`wireguard-tools`, `iproute2`, BusyBox tools actually
|
||||
used, and any justified additions). Driver
|
||||
and fixture inputs are staged per fresh scenario container; keys and configs
|
||||
must never enter the image build context or layers. The test-only server driver
|
||||
must be built for `x86_64-unknown-linux-musl` or inside a pinned Alpine/musl
|
||||
builder matching the repository toolchain, then checked for actual dynamic
|
||||
requirements and executed in the fixture image. A successful cross build alone
|
||||
does not establish Alpine compatibility.
|
||||
|
||||
The current host observation above records that the musl standard library is
|
||||
missing. Do not install it or a linker implicitly. The fixture recipe now lives
|
||||
in `tests/containers/alpine/` and the exact image commands are in
|
||||
[TESTING.md](TESTING.md). The base is pinned to Alpine 3.22.7 for linux/amd64;
|
||||
the selected disposable daemon built and cached the image, and the exact
|
||||
installed closure is recorded in `packages.lock`. Compiler/toolchain layers stay
|
||||
out of this runtime image.
|
||||
|
||||
M8.4 provides an in-crate `#[cfg(test)]` library test driver so that its fixture
|
||||
command and restart seams stay inaccessible to the production executable. It
|
||||
includes an ignored test named `container_driver::alpine_readiness`, selected
|
||||
with libtest's `--ignored --exact` arguments. This harmless test checks only its
|
||||
Linux x86_64 execution environment. Once the musl standard library and a
|
||||
compatible linker are available, build the library test harness from the
|
||||
repository root with:
|
||||
|
||||
```sh
|
||||
cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
The host cannot run this build directly because its musl target is absent.
|
||||
The digest-pinned official Rust 1.97.1 Alpine builder command in
|
||||
[TESTING.md](TESTING.md) built the library test harness on 2026-10-23. `file`
|
||||
identified a static PIE and `readelf -l` showed no interpreter; the ignored
|
||||
readiness selector passed 1/1 in the pinned Alpine runtime using a read-only
|
||||
bind of the nonsecret binary. The selected-daemon M8.4 Testcontainers smoke
|
||||
copied the current binary into a fresh server container and passed the
|
||||
test-only Create/Commit/first delivery/fixture Apply sequence with real runtime
|
||||
observation. M8.5 and M8.7 subsequently completed and M8 was accepted for the
|
||||
documented IPv4 scope. The current integrated host gates and three fresh
|
||||
selected-daemon lifecycle runs are recorded in
|
||||
[evidence/milestone-8.md](evidence/milestone-8.md). The runs covered IPv4
|
||||
traffic, deletion, restore, import/reissue and mandatory-backup failure. The
|
||||
fixture Apply callback is not a systemd job. IPv6 capability/traffic and real
|
||||
systemd/D-Bus remain unverified. The readiness wrapper invokes the harness as
|
||||
`PATH --ignored --exact container_driver::alpine_readiness --test-threads=1`.
|
||||
Cargo printed the executable under
|
||||
`/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-<hash>`
|
||||
for the builder command. Discover the current hash from each build; rebuild the
|
||||
driver after relevant code changes. A target name or successful compilation
|
||||
alone is not Alpine runtime evidence. Musl readiness is an M8 fixture build
|
||||
prerequisite; it is not a milestone 1 acceptance gate.
|
||||
|
||||
## Observed preparation state
|
||||
|
||||
During preparation, local commands reported:
|
||||
|
||||
- rustc: `1.97.1`, host `x86_64-unknown-linux-gnu`.
|
||||
- Cargo: `1.97.1`.
|
||||
- rustfmt: `1.9.0-stable`.
|
||||
- Clippy: `0.1.97`.
|
||||
- `rustup` and `musl-gcc`: not found in PATH.
|
||||
- The musl standard library directory reported by rustc does not exist.
|
||||
|
||||
No toolchain, component, target, or native package installation was performed.
|
||||
These observations establish tool availability, not a successful project build.
|
||||
Recheck the environment at execution time.
|
||||
|
||||
Reference: [rustup toolchain files and overrides](https://rust-lang.github.io/rustup/overrides.html#the-toolchain-file).
|
||||
@@ -0,0 +1,111 @@
|
||||
# Execution records and diagnostic workflow
|
||||
|
||||
This document governs development records, not application logging. It supplements
|
||||
[the implementation order](IMPLEMENTATION.md) and [agent instructions](../AGENTS.md).
|
||||
Product requirements, independent review and acceptance gates are unchanged.
|
||||
|
||||
## Record boundaries
|
||||
|
||||
| Record | Purpose | Contents |
|
||||
| --- | --- | --- |
|
||||
| [PLANS.md](../PLANS.md) | Current execution contract and resume point | Scope, acceptance checklist, task state/owner, open findings, concise closed-finding disposition, latest applicable evidence, limitations and next action. |
|
||||
| `docs/diagnostics/milestone-N.md` | Investigation notebook, created only when needed | Failed attempts, hypotheses, bounded diagnostics, observations, cause and repair verification. |
|
||||
| `docs/evidence/milestone-N.md` | Reproducible evidence report, created when evidence no longer fits a concise plan table | Exact commands, source/build identity, environment, outcomes, review disposition and acceptance mapping. |
|
||||
| `docs/history/` | Preserved superseded records | Historical snapshots with an explicit non-current notice and a link back to the plan. |
|
||||
|
||||
The plan is a current snapshot, not an append-only diary. Do not copy worker
|
||||
reports or full diagnostic sequences into it. Keep the detailed report in one
|
||||
place and link it. Evidence and diagnostic reports never override the plan's
|
||||
scope or the specification. Read only the relevant investigation/report when
|
||||
resuming; reading all historical logs is not a prerequisite for implementation.
|
||||
|
||||
## Updating the plan
|
||||
|
||||
The coordinator updates a task row in place when its result, owner, next action,
|
||||
review disposition or blocker changes, and before handoff. A failed command alone
|
||||
does not need a plan entry unless it changes current status or invalidates evidence.
|
||||
For an active failure keep one concise entry: affected behavior, known facts,
|
||||
owner, next discriminating check and acceptance impact. Replace it as facts change.
|
||||
|
||||
Keep the milestone register, milestone status/outcome and top-level resume point
|
||||
consistent in the same edit. Use the existing lifecycle states; distinguish a
|
||||
repairable defect (`needs_changes`) from an unavailable prerequisite (`blocked`).
|
||||
Task completion does not imply milestone acceptance. Closed findings retain their
|
||||
stable ID, effect, final resolution and review/evidence reference; intermediate
|
||||
repair attempts belong in diagnostics. Superseded passing runs are historical,
|
||||
not additional proof for the current revision.
|
||||
|
||||
Aim for at most one short paragraph per task/finding and one row per evidence
|
||||
scope. This is a readability target, not permission to omit acceptance criteria,
|
||||
required evidence, unresolved findings or safety constraints. If detail exceeds
|
||||
that target, move the detail to the appropriate linked report.
|
||||
|
||||
## Investigating failures
|
||||
|
||||
1. Classify the failure: prerequisite, fixture/transport, application behavior,
|
||||
assertion, or currently unknown. Do not assume the application or fixture is
|
||||
correct before checking.
|
||||
2. Record a concrete hypothesis and the smallest bounded check that distinguishes
|
||||
it from alternatives. Reuse the same task/finding ID across attempts.
|
||||
3. Run that check and record the observation. Retry unchanged commands only for
|
||||
an evidenced transient condition or deliberate repeatability check; record why.
|
||||
4. After two failed repair attempts for the same symptom, reassess the cause and
|
||||
task boundary before another expensive full run. Obtain a focused independent
|
||||
review or reduce to a minimal reproduction when useful. This is an escalation
|
||||
of investigation, not an arbitrary acceptance cutoff or a reason to ask the
|
||||
user to resolve routine implementation choices.
|
||||
5. Repair the cause, run focused checks, then rerun affected integration behavior.
|
||||
Rebuild the container driver when its inputs changed; reuse a compatible pinned
|
||||
builder/cache. Do not run the full Rust gates for every diagnostic edit. Run
|
||||
them at the required completion gates and again if subsequent changes invalidate
|
||||
them, following [TESTING.md](TESTING.md).
|
||||
6. Review the actual repair and affected behavior independently. Consolidate the
|
||||
plan to the final result, remaining uncertainty and next action.
|
||||
|
||||
A diagnostic entry contains: ISO timestamp with offset; task/finding ID; symptom;
|
||||
hypothesis; exact safe command or reproduction reference; observed result; cause
|
||||
if established; next action or closure/evidence reference. Record only useful
|
||||
redacted excerpts. Raw logs, secrets, configuration payloads, private keys and
|
||||
working backups must not be committed. An agent stall or usage limit needs a
|
||||
brief handoff note only when it affects ownership or the next action.
|
||||
|
||||
## Evidence and handoff
|
||||
|
||||
An evidence entry identifies:
|
||||
|
||||
- Task/criterion and run timestamp with offset.
|
||||
- Tested commit plus dirty-state description, or a reproducible fingerprint of
|
||||
relevant tracked/untracked source inputs; identify the built artifact and its
|
||||
source inputs when a separate container binary is used. A commit alone does
|
||||
not identify a dirty working tree. Never fingerprint secret-bearing data into
|
||||
a report or claim a source identity that was not captured.
|
||||
- Exact command, toolchain/target and required environment or immutable image
|
||||
identity, excluding secrets. Mark missing historical identity as unavailable.
|
||||
- Actual outcome: exit/result counts, fixture versus Alpine versus VM scope,
|
||||
cleanup outcome, limitations and which later edits invalidate the evidence.
|
||||
- Independent reviewer disposition and remaining findings where applicable.
|
||||
|
||||
Do not manufacture missing evidence when migrating old records. Carry forward
|
||||
reported results as historical evidence and state whether they were rerun.
|
||||
Before accepting a milestone, verify the integrated files, close actionable
|
||||
findings, run required gates on that revision and update the acceptance mapping.
|
||||
|
||||
A handoff consists of current task/state/owner, open issue, latest applicable
|
||||
checks, environment prerequisites and one concrete next action. Link diagnostics
|
||||
only if needed to avoid repeating an investigation. It must be possible to resume
|
||||
from the current plan without reconstructing chronology.
|
||||
|
||||
## Migration and ownership
|
||||
|
||||
Preserve the old record in `docs/history/` before compacting existing diagnostic
|
||||
history. Keep scope, acceptance criteria, stable finding IDs, final outcomes,
|
||||
limitations and source links. Clearly label contradictory historical statuses as
|
||||
superseded. Migrate older accepted milestones when they need maintenance; do not
|
||||
rewrite their acceptance merely for uniform formatting.
|
||||
|
||||
The coordinator owns PLANS.md and evidence consolidation. Assign a diagnostics
|
||||
file to one writer at a time, or have workers return concise entries for the
|
||||
coordinator to integrate. Include these record boundaries in dispatch briefs;
|
||||
workers still report actual checks and unresolved issues. New agent configuration
|
||||
may not affect an existing session: supply this document explicitly at its next
|
||||
authorized dispatch/handoff. Do not message other chats without user authorization.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Implementation Plan
|
||||
|
||||
Basis: the approved [specification](SPEC.md). This document defines the order of work without changing the requirements. Specific libraries and serialization formats remain implementation decisions.
|
||||
Basis: the approved [specification](SPEC.md). This document defines the order of work without changing the requirements. Specific libraries and serialization formats remain implementation decisions. Maintain current state, evidence, and diagnostics according to [Execution records and diagnostic workflow](EXECUTION_RECORDS.md); PLANS.md must not become an append-only debugging diary.
|
||||
|
||||
## 1. Model and configuration
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Operator setup, backup and recovery
|
||||
|
||||
This guide describes the supported single-interface installation and recovery
|
||||
boundaries. Use it only after qualifying the application for the target host;
|
||||
the current evidence and gaps are listed in
|
||||
[`RELEASE_READINESS.md`](RELEASE_READINESS.md). Do not trial the application on
|
||||
a production service as a substitute for the pending disposable systemd VM
|
||||
qualification.
|
||||
|
||||
## Installation prerequisites and placement
|
||||
|
||||
The application manages one already-configured Linux WireGuard interface and
|
||||
its existing systemd service. The host must provide working WireGuard kernel
|
||||
support, systemd and its system D-Bus, plus the required service setup (such as
|
||||
`wg-quick` when the selected unit uses it). Version 1 does not provision a
|
||||
server, install a service, or manage multiple interfaces. Do not mount the
|
||||
development host's D-Bus into a test environment.
|
||||
|
||||
Build with the pinned Rust 1.97.1 toolchain and locked dependencies. Before
|
||||
installing an artifact, verify its target and dynamic library requirements
|
||||
against the chosen Linux distribution. The current x86-64 GNU release candidate
|
||||
is host-assessed only; it has not been started in a guest. Keep the executable
|
||||
and `wireguard-admin.toml` together in a dedicated root-owned directory that
|
||||
ordinary users cannot modify. Keep the executable outside `PATH`; no installer,
|
||||
service unit, or automatic privilege wrapper is supplied.
|
||||
|
||||
Copy [`../config/wireguard-admin.example.toml`](../config/wireguard-admin.example.toml)
|
||||
to `wireguard-admin.toml` beside the executable and edit it for the existing
|
||||
interface, server configuration, client source directory, address pools,
|
||||
endpoint, DNS, systemd unit, and timeout. The server config path and interface
|
||||
name are installation invariants. Relative paths resolve beside the executable,
|
||||
not from the shell's working directory. By default working data is
|
||||
`Backup/data` and archives are `Backup/archives`, also beside the executable.
|
||||
Test the resolved paths before running management. Preserve the existing
|
||||
configuration and imported client files; import is read-only and anomaly
|
||||
reports require operator decisions.
|
||||
|
||||
Restrict the settings file, server configuration, vault, internal state, and
|
||||
backup tree to root. Existing server configuration and vault files must already
|
||||
be mode `0600` or stricter when opened. Keep private directories at `0700` and
|
||||
secret-bearing files at `0600`; the application creates new owned outputs with
|
||||
these modes but does not silently chmod an unsafe existing server/vault file.
|
||||
Do not make the executable or settings writable by unprivileged users. The
|
||||
application requires root for management; `--help` and `--version` are
|
||||
unprivileged.
|
||||
|
||||
## Initial vault, import and delivery
|
||||
|
||||
At initial setup, import scans the configured source directory without changing
|
||||
or deleting its original files. Review every reported match, mismatch, invalid
|
||||
file, or server-only/client-only anomaly. The server peer set remains
|
||||
authoritative; do not treat an imported file as permission to rewrite server
|
||||
settings.
|
||||
|
||||
Choose and protect the vault password during initial vault creation. The
|
||||
password protects the separate vault secret key; it does not encrypt the
|
||||
server configuration, original client files, or backup archives. Losing it
|
||||
prevents future reissue of stored client private keys. A backup restores the
|
||||
encrypted vault but cannot recover a lost password. Store credentials under
|
||||
your organization's separate secret-custody process; never place the password
|
||||
in settings, a command line, logs, or evidence.
|
||||
|
||||
For a new client, Commit first, then complete first file delivery in the same
|
||||
application session. The initial file is mandatory and can be retried at a
|
||||
different destination after a publication failure while that creation session
|
||||
is still active. Optional QR output represents the same configuration and is
|
||||
available only after file publication. The delivered file is plaintext
|
||||
WireGuard configuration, mode `0600`; treat terminal QR output as a disclosure.
|
||||
If publication reports uncertain output or cleanup is required, follow the
|
||||
exact manual-cleanup instruction before retrying; do not assume an error means
|
||||
no plaintext file was created.
|
||||
Until the saved peer set has been explicitly applied and verified, treat the
|
||||
new client's access as inactive. After that session ends, each reissue requires
|
||||
the vault password for that client and reads installed saved state. The
|
||||
application does not use retained original plaintext files as a password-free
|
||||
fallback. Overwriting a selected output file requires confirmation.
|
||||
|
||||
## Commit, backups, restore and restart
|
||||
|
||||
Keep the three states distinct:
|
||||
|
||||
1. **Draft** is the current session's proposed edit. Cancel preserves it in
|
||||
that session; exiting does not save it.
|
||||
2. **Saved** is what Commit installed on disk. Commit creates a mandatory
|
||||
pre-Commit backup, then saves settings, server configuration, internal state
|
||||
and encrypted vault. Commit never reloads, syncs, or restarts WireGuard.
|
||||
3. **Applied** is saved state verified against the running interface after an
|
||||
explicit restart. A historical verification marker after reopen is not a
|
||||
live observation.
|
||||
|
||||
Every Commit, restore and restart first creates a complete mandatory backup.
|
||||
If that backup fails, the protected operation stops before changing saved files
|
||||
or submitting a restart. Backups are uncompressed archives, not encrypted
|
||||
archives. They can contain the server's private configuration and encrypted
|
||||
vault records. Restrict them to root, include them in the site's protected
|
||||
backup custody, and remember that a backup beside the executable is lost with
|
||||
that disk. The application has no automatic retention or remote-copy feature.
|
||||
Deleting a client from the current vault does not erase old backups.
|
||||
|
||||
Restore is a previewed, confirmed full replacement of saved state. It restores
|
||||
the settings and opaque vault without a vault password, and it does not change
|
||||
the running interface. A restored archive's old verification marker is
|
||||
historical; runtime state becomes unknown. To apply the restored state, start a
|
||||
separate explicit restart and inspect its result. Never describe restore or
|
||||
Commit as a VPN rollback.
|
||||
|
||||
Before confirming restart, review the configured interface/service and the
|
||||
saved-versus-applied differences. The application creates a pre-restart backup,
|
||||
submits the configured systemd job, waits for its terminal result, compares the
|
||||
complete observed peer and route sets, then journals verification state. A
|
||||
successful systemd job alone does not establish that the expected peers are
|
||||
active. A pre-restart backup captures saved files and is not necessarily a
|
||||
known-good working VPN state. Deleted-client addresses remain reserved until
|
||||
verification and its saved-state write both succeed.
|
||||
|
||||
If restart reports failure, timeout, a runtime mismatch, or a persistence
|
||||
failure, read the stage and whether submission occurred or may still complete.
|
||||
A timeout does not cancel a submitted job. Do not immediately issue repeated
|
||||
restarts while a submitted job may still be running. No automatic restart,
|
||||
retry, VPN rollback, or reservation release occurs on these failure paths.
|
||||
Preserve the pre-restart backup and inspect the service and configured
|
||||
interface through the host's normal, independently controlled administration
|
||||
path. Do not infer success from log text alone. When the application remains
|
||||
usable, select a known-good archive through its preview/confirmation flow to
|
||||
restore saved files, then decide separately whether to restart and verify them.
|
||||
|
||||
## Saved-file journal recovery
|
||||
|
||||
The persistence journal recovers interrupted file replacement; it is not
|
||||
runtime rollback. On reopen, an uncommitted prepared journal rolls saved files
|
||||
back to the recorded prior bytes, while a committed journal is cleaned up. If
|
||||
recovery or cleanup fails, the application returns `RecoveryRequired` and
|
||||
rejects mutations. Correct the underlying storage problem (for example,
|
||||
available space or directory access), preserve the journal, and retry opening
|
||||
the application so its recovery can run. Do not delete, hand-edit, or rename
|
||||
journal files to force startup.
|
||||
|
||||
If a journal remains invalid or recovery continues to fail, stop making
|
||||
changes. Preserve the complete data directory and operation backups for a
|
||||
careful, separately reviewed recovery. A known-good archive is the documented
|
||||
saved-state recovery source when the application can safely preview and restore
|
||||
it; it does not repair the runtime automatically. After saved state is recovered,
|
||||
inspect the live interface and use a separate explicit restart only when its
|
||||
target is understood. If the application cannot open to perform a safe
|
||||
previewed restore, do not improvise by copying partial archive members over
|
||||
installation paths.
|
||||
|
||||
Never remove old archives or original client files as an automatic response to
|
||||
an error. Keep secret-bearing files and working backups out of source control,
|
||||
support tickets, screenshots, logs, and evidence reports.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Saved state persistence
|
||||
|
||||
The persistence adapter reads and installs the application's saved files at the
|
||||
paths in `config::ResolvedSettings`. Settings remain beside the executable,
|
||||
the server configuration stays at its configured invariant path, and internal
|
||||
state and the opaque encrypted vault live in `data_directory`. No path is
|
||||
resolved from the process working directory. Archive member names never become
|
||||
filesystem paths.
|
||||
|
||||
## Internal state v1
|
||||
|
||||
`internal-state.toml` is optional. Its absence is valid only during initial
|
||||
setup; once an installed state file exists, malformed or unsupported data is an
|
||||
error and cannot be treated as an empty reservation set. Version 1 contains no
|
||||
alias registry and does not duplicate server client metadata:
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
reserved_addresses = ["10.0.0.8", "fd00::8"]
|
||||
last_verified_at_utc = "2026-10-19T12:30:00Z"
|
||||
```
|
||||
|
||||
`reserved_addresses` records addresses retained after saved deletion until a
|
||||
later explicit runtime verification. It is canonically sorted and duplicate
|
||||
free. `last_verified_at_utc` records when this saved generation was last verified
|
||||
against the runtime. It is historical bookkeeping only: loading it always
|
||||
produces `RuntimeObservation::Unknown` and never establishes that the current
|
||||
interface matches saved files. The timestamp is stored in the same journaled
|
||||
internal-state component as the saved set it describes. Successful Commit and
|
||||
restore write a new generation with this timestamp cleared; failed operations do
|
||||
not advance or clear it. M5 records verification only after a completed
|
||||
pre-restart backup, successful runtime verification, and successful journaled
|
||||
state write. If that write fails, the in-memory applied state remains unchanged.
|
||||
This binding follows the application's single-administrator save boundary; it is
|
||||
not a digest that detects manual edits made outside the application.
|
||||
Unknown fields, duplicate addresses, invalid addresses/timestamps, and
|
||||
unsupported versions are rejected.
|
||||
|
||||
Settings bytes are parsed and semantically validated, server bytes are parsed
|
||||
with the byte-preserving `ConfigDocument`, and a present vault is parsed by
|
||||
`VaultBundle::from_toml` without unlocking it. Saved reads are bounded and
|
||||
return exact bytes; their `Debug` representations redact content. The server
|
||||
parser reports its existing anomalies without rewriting or normalizing bytes.
|
||||
The server configuration and a present vault must already have mode `0600` or
|
||||
stricter when read. A broader mode fails closed with an actionable permission
|
||||
error; the application does not silently chmod an existing secret-bearing file.
|
||||
The operator must restrict its mode and retry. Newly installed replacements
|
||||
are created as `0600`.
|
||||
During restore, operational settings such as endpoint, DNS, systemd unit, and
|
||||
timeout may come from the archive. The configured interface name remains fixed
|
||||
for this installation, along with the resolved server configuration, client
|
||||
source, and data-root paths (including their derived data/archive directories).
|
||||
This preserves the single-interface mapping while allowing an archive to restore
|
||||
other settings without redirecting writes.
|
||||
|
||||
## Destination and path safety
|
||||
|
||||
The adapter accepts only the resolved settings path, configured server path,
|
||||
`data_directory/internal-state.toml`, and `data_directory/vault.toml`. These
|
||||
paths are checked for overlap/aliasing before a write. Every existing path
|
||||
component is checked without following symlinks; saved files must be regular
|
||||
files. Destination directories are private (`0700`), and replacement and
|
||||
journal files are created `0600` from their creation. Symlinks, non-regular
|
||||
files, unsafe directory components, and path aliasing fail closed. No archive
|
||||
metadata controls destination names, permissions, or ownership.
|
||||
|
||||
## Multi-file installation and recovery
|
||||
|
||||
Installation is not a cross-directory atomic operation. The caller must first
|
||||
complete the mandatory operation backup and supply the adapter's
|
||||
`BackupComplete` capability. The adapter then validates the complete proposed
|
||||
saved set and checks that no earlier transaction needs recovery. It creates a
|
||||
fixed-name private transaction directory under the trusted data directory and
|
||||
writes the prior bytes (including explicit absence) and proposed bytes under
|
||||
fixed component names. Each payload and the transaction directory are synced,
|
||||
then the journal metadata and a `prepared` marker are atomically published and
|
||||
the journal and parent data directory are synced. Only then may destination
|
||||
files be changed. An interrupted journal without a valid prepared marker is
|
||||
safe to clean because the adapter cannot have started replacements yet.
|
||||
|
||||
Replacement order is settings, server configuration, internal state, then
|
||||
vault. A deletion of an optional state or vault is part of that order. Each
|
||||
new file is written completely to a same-directory `0600` temporary file,
|
||||
flushed and synced, atomically renamed, and followed by a parent-directory
|
||||
sync. After all replacements, the adapter syncs all affected parents, records
|
||||
the transaction as committed, syncs that marker and its parent, then removes
|
||||
the transaction directory and syncs the data directory. Only a fully completed
|
||||
sequence returns success.
|
||||
|
||||
On reopen, an uncommitted journal means rollback: prior bytes are reinstalled
|
||||
in reverse replacement order, previously absent optional files are removed,
|
||||
and all affected parent directories are synced. If rollback or cleanup fails,
|
||||
the adapter returns `RecoveryRequired` and rejects every new mutation. Recovery
|
||||
can be retried after correcting the filesystem problem. A committed journal
|
||||
means all replacements finished; reopening performs cleanup only and never
|
||||
rolls back the completed set. If writing/syncing the committed marker or
|
||||
cleaning the journal fails during installation, the result is explicitly
|
||||
`RecoveryRequired`, not success. The next open determines rollback versus
|
||||
cleanup from the durable marker.
|
||||
|
||||
This journal repairs interrupted saved-file writes only. It never invokes a
|
||||
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`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Project Layout
|
||||
|
||||
This is the integrated package layout after M26. Paths below exist in the
|
||||
repository; scenario descriptions name current owners. Add modules when a
|
||||
cohesive responsibility needs them, not as placeholders.
|
||||
|
||||
The repository has one Cargo package with a binary (`src/main.rs`) and a library
|
||||
(`src/lib.rs`). Startup and process exit remain in `main.rs`; testable
|
||||
application logic and the TUI are library modules. The library target is not a
|
||||
separately published package.
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
Cargo.lock
|
||||
rust-toolchain.toml
|
||||
AGENTS.md
|
||||
CONTEXT.md
|
||||
PLANS.md
|
||||
README.md
|
||||
src/
|
||||
main.rs # Startup, arguments, process exit
|
||||
lib.rs # Library modules; test-only container driver gate
|
||||
startup.rs # Root boundary, settings/store loading, session entry
|
||||
config.rs # Settings validation and executable-relative paths
|
||||
domain.rs # Client projection, address allocation/reservations, apply knowledge
|
||||
app.rs # Application facade, session, and public operation types
|
||||
app/
|
||||
draft.rs # Coordinated document/client/vault draft replacements
|
||||
first_delivery.rs # Session-local one-use capability and retry state
|
||||
first_file.rs # First-delivery file and optional QR scenario
|
||||
reissue.rs # Password-per-action saved-client reissue
|
||||
read_model.rs # Secret-free application facts and per-action eligibility
|
||||
commit.rs # Backup, saved install, and Commit publication
|
||||
restore.rs # Preview, reservation rebuild, and confirmed saved replacement
|
||||
backups.rs # Manual/pre-operation backup and archive listing
|
||||
restart.rs # Restart preview, backup/job/verify/publication sequence
|
||||
wireguard.rs # Server configuration module boundary
|
||||
wireguard/
|
||||
document.rs # Lossless parse, peer inspection and document authority
|
||||
edit.rs # Byte-preserving peer and metadata edits
|
||||
metadata.rs # Managed comment grammar and metadata validation
|
||||
vault.rs # Vault format, encryption, key matching and retrieval
|
||||
vault/
|
||||
source.rs # Read-only configured client-source access
|
||||
import.rs # Client-file reconciliation and encrypted import
|
||||
persistence.rs # Saved-file installation, recovery and state codec
|
||||
backup.rs # Versioned archive encoding, parsing and preview
|
||||
runtime.rs # Runtime observations, peer projections and comparison
|
||||
runtime/
|
||||
netlink.rs # Bounded Generic Netlink WireGuard reader
|
||||
transport.rs # Read-only runtime transport
|
||||
service.rs # Systemd request and job completion adapter
|
||||
service/
|
||||
dbus.rs # D-Bus systemd implementation
|
||||
delivery.rs # Client configuration rendering and adapter boundary
|
||||
delivery/
|
||||
files.rs # Secure client file publication
|
||||
qr.rs # Terminal QR generation
|
||||
tui.rs # TUI entry and session lifecycle
|
||||
tui/
|
||||
state.rs # Screens, selection, UI generations and navigation
|
||||
events.rs # Typed commands, worker queue and lifecycle
|
||||
events/
|
||||
executor.rs # Application command adaptation and prepared contexts
|
||||
forms.rs # Short-lived form inputs and sensitive commands
|
||||
render.rs # Visual strings, styles, wrapping and layout
|
||||
terminal.rs # Terminal setup, event loop and cleanup
|
||||
container_driver.rs # Test-only Alpine scenario driver (`cfg(test)`)
|
||||
tests/
|
||||
config_preservation.rs # Synthetic byte-preservation behavior
|
||||
commit_restore.rs # Persistence, backups, restore and runtime isolation
|
||||
client_delivery.rs # First delivery, retry, reissue and lifecycle behavior
|
||||
runtime_restart.rs # Restart scenario behavior using controlled adapters
|
||||
vault_import.rs # Import and reconciliation behavior
|
||||
tui_workflow.rs # Worker-level TUI workflows and synthetic PTY checks
|
||||
testcontainers_wireguard.rs # Explicitly selected, ignored Alpine integration suite
|
||||
support/ # Shared fixtures, adapters and container harness
|
||||
fixtures/wireguard/ # Synthetic server configuration
|
||||
containers/alpine/ # Pinned Alpine runtime image and readiness support
|
||||
vm/ # Guarded disposable-VM preparation/test scripts
|
||||
config/
|
||||
wireguard-admin.example.toml # Safe distributable example settings
|
||||
docs/
|
||||
SPEC.md
|
||||
PROJECT_LAYOUT.md
|
||||
architecture/domain-refactoring.md
|
||||
adr/
|
||||
target/ # Cargo output; never commit
|
||||
```
|
||||
|
||||
## Ownership rules
|
||||
|
||||
- `Application` is the public facade. Scenario ordering belongs in `src/app/`
|
||||
owners; the facade delegates without transferring authority to the TUI.
|
||||
- The server `ConfigDocument` is authoritative for peers and metadata.
|
||||
`ClientStates` is only the manageable-client projection. Editing preserves
|
||||
unowned bytes and comments.
|
||||
- Domain logic does not perform file, crypto, runtime, service, or rendering
|
||||
work. Persistence owns installed state; backup owns archive encoding;
|
||||
runtime owns observations/comparison; service owns systemd calls.
|
||||
- `app/read_model.rs` supplies secret-free facts and action eligibility.
|
||||
TUI state owns selection/navigation/generation; the executor binds prepared
|
||||
continuations and calls application operations; renderers own presentation.
|
||||
- Keep module internals private or `pub(crate)` where possible. Public facade
|
||||
APIs retained for integration fixture setup are documented with their caller
|
||||
audit in [the architecture map](architecture/domain-refactoring.md).
|
||||
- Keep focused unit tests beside their modules and cross-module behavior in
|
||||
`tests/`. `src/container_driver.rs` is included only in the library test
|
||||
harness. Its opt-in Alpine application fixture is not systemd acceptance.
|
||||
- `config/` holds safe examples, not live settings. Runtime data and archives
|
||||
remain outside the source tree. Do not commit live configuration, keys,
|
||||
delivered client files, or generated `Backup/` data.
|
||||
- Keep this map aligned with actual files and responsibility. Do not add empty
|
||||
directories or speculative traits/modules.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Release readiness and qualification matrix
|
||||
|
||||
This is a scope-qualified M9.4 record, not a release approval. M1–M8 are
|
||||
accepted in [`PLANS.md`](../PLANS.md); M9 real-system acceptance remains
|
||||
incomplete because no explicitly selected disposable systemd VM is available.
|
||||
No production host, VPN, service, key, or configuration was accessed. The
|
||||
operator procedure is in [`OPERATIONS.md`](OPERATIONS.md).
|
||||
|
||||
## Current host release candidate
|
||||
|
||||
The following native artifact was built from the current Rust sources with the
|
||||
pinned compiler and locked dependency set. Its `--help` and `--version` paths
|
||||
were executed on the development host. This assessment does not establish
|
||||
startup in a guest or supported compatibility with another distribution.
|
||||
|
||||
| Property | Observed value |
|
||||
| --- | --- |
|
||||
| Source Git HEAD | `f4a6540df9621c9c5e5d1430243f92e749356a59` |
|
||||
| Working-tree state at build | Pre-existing dirty documentation only: `PLANS.md` modified and `docs/VM_ACCEPTANCE.md` untracked; no source, manifest or lockfile changes |
|
||||
| Relevant input fingerprint | SHA-256 `02ace329ca2ed17aa0b47112a3fcaa090b7d9a7eaa334f293aea9792e35e668a` over sorted `src/`, `Cargo.toml`, and `Cargo.lock` file hashes |
|
||||
| Compiler and target | rustc 1.97.1, host `x86_64-unknown-linux-gnu`; Cargo 1.97.1 |
|
||||
| Build command | `cargo build --locked --release` — exit 0 |
|
||||
| Artifact | `target/release/wireguard-admin`, x86-64 ELF PIE, dynamically linked |
|
||||
| Artifact SHA-256 | `d52d43cd02332352ffbd09c97e3b8095a710359128260c1286006e1c76c1b283` |
|
||||
| Runtime requirements observed | ELF interpreter `/lib64/ld-linux-x86-64.so.2`; `libc.so.6` (GLIBC symbols through `GLIBC_2.39`) and `libgcc_s.so.1`; `ldd` resolved these on the development host |
|
||||
| Artifact smoke | `./target/release/wireguard-admin --help` and `--version` — both exit 0; no settings, terminal, root management, service or interface exercised |
|
||||
|
||||
Commands used to inspect the artifact were `sha256sum target/release/wireguard-admin`,
|
||||
`file target/release/wireguard-admin`, `readelf -h`, `readelf -l`, `readelf -d`,
|
||||
`readelf --version-info`, and `ldd target/release/wireguard-admin`. The linked
|
||||
GLIBC requirement is an observed lower symbol-version bound, not proof that a
|
||||
distribution satisfying it is otherwise compatible. No installer, package,
|
||||
signature, reproducible-build claim, or supported distribution baseline is
|
||||
provided. This native GNU artifact is not an Alpine/musl artifact.
|
||||
|
||||
The source-input fingerprint was calculated from the repository root with:
|
||||
|
||||
```sh
|
||||
rg --files src Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum
|
||||
```
|
||||
|
||||
## Specification-to-evidence matrix
|
||||
|
||||
“Accepted” refers to the repository's existing milestone result. It does not
|
||||
mean every production platform or dual-stack behavior has been qualified. For
|
||||
M1–M7, the concise milestone scope, review outcome and commands are in the
|
||||
corresponding accepted sections of [`PLANS.md`](../PLANS.md). M8's detailed
|
||||
source, artifact, run and scope record is
|
||||
[`evidence/milestone-8.md`](evidence/milestone-8.md).
|
||||
|
||||
| Requirement area | Existing evidence | Qualification and limit |
|
||||
| --- | --- | --- |
|
||||
| Settings validation, executable-relative paths, draft/saved/applied model | M1 accepted record in `PLANS.md`; M7 startup/workflow record there | Synthetic/unit and TUI workflow coverage; no guest startup or production configuration. |
|
||||
| Server configuration authority, client identity by public key, byte preservation and metadata | M2 accepted record in `PLANS.md`; specification in `SPEC.md` | Fixture/behavioral evidence for preserving non-client bytes and comments; no real server file was used. |
|
||||
| Secret-safe keys, encrypted vault, read-only imports and originals | M3 accepted record in `PLANS.md`; M8 import/reissue scenario in `evidence/milestone-8.md` | Synthetic import plus accepted Alpine lifecycle scenario; source originals were fixture files and retained. Vault password loss remains unrecoverable by design. |
|
||||
| Backups, mandatory backup boundary, journaled Commit, recovery and previewed restore | M4 accepted record in `PLANS.md`; M8 restore and backup-failure runs in `evidence/milestone-8.md` | Saved-state operations and test-fixture behavior covered. Restore did not apply runtime changes; real-VM manual recovery remains unverified. Archives are not encrypted. |
|
||||
| Read-only runtime observations, restart orchestration, deadline and failure reporting | M5 accepted record in `PLANS.md`; runtime section of `docs/RUNTIME.md`; M8 Rustix observations | Fake-adapter and real Alpine WireGuard reader/IPv4 observations exist. No actual systemd/D-Bus job or `Application::restart` against real systemd has been run. |
|
||||
| Delivery, first issue, password-per-action reissue, QR and private output | M6 accepted record and M7 synthetic PTY/workflow records in `PLANS.md`; `docs/DELIVERY.md` | Synthetic behavior covers file publication/reissue and QR rendering/terminal handling. No user device scan or actual client device qualification is recorded. |
|
||||
| Native TUI, root/startup boundary and exit/terminal cleanup | M7 accepted record in `PLANS.md`, including synthetic PTY and workflow checks | Not a real guest root/non-root startup smoke. The release binary was only exercised for help/version on the build host. |
|
||||
| Alpine real WireGuard lifecycle and IPv4 traffic | M8.7 in `evidence/milestone-8.md` | Accepted M8 used the pinned Alpine 3.22.7 fixture and selected disposable Docker daemon; repeated fresh integrated IPv4 scenarios passed on the recorded source fingerprint. Fixture Apply is a test-only callback, not systemd evidence. |
|
||||
| Dual-stack behavior | M1–M7 synthetic client/configuration tests; M8 scope declaration | Production IPv4+IPv6 creation/full-tunnel requirements remain in force. Real IPv6 capability and traffic were excluded from M8 and are unverified; an IPv4 result does not qualify IPv6. |
|
||||
| Real systemd job, alias/fast completion, failure/timeout, verified apply, restore and release-worker startup | No M9 VM result exists; `docs/VM_ACCEPTANCE.md` is a reviewed safety/scenario contract | **Unverified / M9 incomplete.** No selected disposable guest or VM run. Do not infer this from fake service adapters, Alpine or the host `--help` check. |
|
||||
| Native artifact identity and dynamic dependencies | This document's host release-candidate record | Host-only assessment. Guest compatibility and startup are unverified; no universal Linux support claim. |
|
||||
|
||||
## Qualification status and next action
|
||||
|
||||
- M1–M8 remain accepted at their recorded scope. Later M9 documentation does
|
||||
not change or broaden their evidence.
|
||||
- M9.1 has a reviewed, non-runnable VM safety/scenario contract. M9.2–M9.3
|
||||
real-system work and M9.5–M9.6 review/acceptance remain incomplete.
|
||||
- The release candidate was built and its ELF dependencies inspected on the
|
||||
host, but has not been copied to or started in a guest. Actual guest
|
||||
architecture/distribution compatibility, root management startup,
|
||||
systemd/D-Bus behavior and service recovery are unverified.
|
||||
- Real IPv6 capability and tunnel traffic are unverified. No M8 IPv4 or
|
||||
synthetic dual-stack test is a substitute for that qualification.
|
||||
- No release, installation, production action, Git commit, or deployment is
|
||||
authorized by this report.
|
||||
|
||||
Next action: select and explicitly identify a disposable VM/guest satisfying
|
||||
[`VM_ACCEPTANCE.md`](VM_ACCEPTANCE.md), then complete its target guard and
|
||||
real-system scenarios before independent M9 review. Keep systemd/guest results
|
||||
separate from the existing Alpine and synthetic evidence. Any final release
|
||||
statement must retain the real IPv6 qualification gap unless that coverage is
|
||||
explicitly authorized and completed.
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Runtime observation and verification boundary
|
||||
|
||||
`runtime` defines public, read-only observation values, a bounded Linux
|
||||
WireGuard Generic Netlink reader, and a pure comparison between an observed
|
||||
interface and the authoritative saved server document. The reader accesses
|
||||
only the configured interface and never changes the running interface.
|
||||
|
||||
## Observation values
|
||||
|
||||
An observation records an optional UTC sample time and distinguishes an unknown
|
||||
interface, an unavailable read, a missing interface, and a present interface.
|
||||
A present interface carries its observed interface name and peers keyed by
|
||||
`PublicKey`; duplicate keys are retained as an explicit ambiguity signal. The
|
||||
comparison requires the currently configured interface name and rejects a
|
||||
different observed interface, even when its peer set happens to match. The peer
|
||||
set states whether it is unknown, unavailable, partial, or complete. Only a
|
||||
complete set can prove that a saved peer is absent or that an extra runtime peer
|
||||
exists.
|
||||
|
||||
Each peer has AllowedIPs and statistics with their own availability. A valid
|
||||
zero byte counter is `Available(0)`. A peer with no recorded handshake is
|
||||
`Available(None)`. Unavailable statistics do not become zero or a disconnected
|
||||
state. Counters are current values from the server interface's perspective and
|
||||
may reset after restart; they are not history and do not establish online or
|
||||
offline status.
|
||||
|
||||
## Saved technical projection
|
||||
|
||||
`SavedPeerProjection::from_document` includes every `[Peer]` section from the
|
||||
byte-preserving `ConfigDocument`, including valid routes unsupported by the
|
||||
narrow `domain::Client` address model and legacy IPv4-only peers. Routes are
|
||||
normalized and compared as complete sets, independent of ordering and duplicate
|
||||
entries. Aliases and managed creation dates are not technical runtime fields;
|
||||
metadata anomalies alone do not prevent comparison. Missing or ambiguous keys,
|
||||
malformed routes, structural document issues, and duplicate identities remain
|
||||
unverifiable and cannot be omitted to obtain a matching reduced set.
|
||||
|
||||
`compare_peers(saved, expected_interface, runtime)` checks interface presence
|
||||
and identity, the complete public-key set, and each peer's complete AllowedIPs
|
||||
set. It reports a wrong interface, missing, extra, route-mismatched, duplicate,
|
||||
partial, and unverifiable cases. An empty saved peer set can match only a
|
||||
present expected interface with an explicitly complete empty runtime peer set.
|
||||
Handshake values, counters (including resets), endpoint changes, ordering, and
|
||||
sample time do not affect technical equivalence. The comparison returns only a
|
||||
technical result; it does not create online status, applied state, or history.
|
||||
|
||||
This result is separate from M4's `domain::Snapshot` and applied marker. A
|
||||
successful technical comparison is necessary evidence for applying state, but
|
||||
it is not itself a durable M4 transition. Application restart orchestration
|
||||
joins saved aliases and representable addresses by public key only after full
|
||||
technical equivalence succeeds. If any authoritative peer cannot be safely
|
||||
represented for that narrower durable view, verification remains unverifiable
|
||||
and reservations are retained instead of supplying a fabricated subset.
|
||||
|
||||
## WireGuard Generic Netlink decoder
|
||||
|
||||
`runtime::netlink::GetDeviceDecoder` is a pure decoder for one WireGuard
|
||||
`GET_DEVICE` dump. It consumes caller supplied datagrams and expected request
|
||||
sequence, local netlink port ID, family ID, and interface name. It has no socket
|
||||
or host access. The transport rejects truncated datagrams, enforces its
|
||||
monotonic receive deadline, and wipes its receive buffer when its lifetime ends.
|
||||
|
||||
The decoder checks every netlink and attribute length/alignment, message type,
|
||||
sequence and port ID, Generic Netlink command/version, configured interface,
|
||||
nested peer and AllowedIPs records, duplicate fields and aggregate limits. It
|
||||
returns data only after an explicit `NLMSG_DONE` with an embedded zero error
|
||||
code. It rejects `NLMSG_ERROR`, interrupted/overrun dumps, malformed or
|
||||
incomplete data, and limits exceeded. Peer fragments are coalesced only when a
|
||||
repeated public key is adjacent across multipart messages and the fragment
|
||||
contains only that key and AllowedIPs; duplicate routes remain invalid. A
|
||||
complete empty peer list is valid.
|
||||
|
||||
Server private keys and peer preshared keys are length-checked through borrowed
|
||||
input slices and are neither copied into decoder-owned structures nor returned.
|
||||
The result contains only public keys, AllowedIPs, last-handshake timestamps and
|
||||
current received/transmitted counters. Errors carry stable categories without
|
||||
raw message data. The decoder uses configurable aggregate limits with bounded
|
||||
defaults; future transport code must set request-specific limits explicitly.
|
||||
|
||||
Protocol details follow the [WireGuard Generic Netlink specification](https://docs.kernel.org/netlink/specs/wireguard.html)
|
||||
and the [Linux Netlink message specification](https://docs.kernel.org/6.1/userspace-api/netlink/intro.html).
|
||||
|
||||
## Systemd restart job tracking boundary
|
||||
|
||||
`service::RestartJobTracker` is the pure event state machine for one resolved
|
||||
primary systemd unit ID. The bounded zbus 5.19.0 adapter is internal and
|
||||
crate-private; public runtime mutation is exposed only through
|
||||
`Application::restart`, after an explicit restart choice. The adapter installs
|
||||
`JobRemoved` and systemd name-owner observation, calls Manager `Subscribe`, and
|
||||
resolves the configured target with read-only Manager `GetUnit` followed by the
|
||||
unit's `Id` property. It then submits `RestartUnit` using the original
|
||||
configured target and tracks the returned job object path together with the
|
||||
resolved primary ID, which is the unit name reported by `JobRemoved`. If
|
||||
`GetUnit` cannot resolve a loaded unit, or reading its ID fails, the operation
|
||||
stops with a redacted resolution failure; it does not load a unit or guess its
|
||||
canonical name. The signal stream is installed before resolution and restart,
|
||||
so a fast completion queued before the method reply remains available once the
|
||||
reply supplies the path. The pure tracker also supports a fixed bound of early
|
||||
events for transports that deliver events before the method reply; overflow
|
||||
fails closed.
|
||||
|
||||
Only systemd's `done` job result is successful. Failed, canceled, timed-out,
|
||||
skipped, dependency, or unknown job results remain failures. Bus disconnection,
|
||||
manager owner change, subscription failure, and permission/request failures are
|
||||
separate redacted outcomes. `RestartDeadline` carries the operation's monotonic
|
||||
start and timeout so connection, match registration, Subscribe, unit
|
||||
resolution, RestartUnit, job wait, and later runtime verification can share one
|
||||
budget. The adapter drives zbus async futures with a standard-library thread
|
||||
waker and parks only for the remaining budget. zbus's `async-io` backend
|
||||
continues socket I/O on its own executor; its connection is short lived and is
|
||||
dropped after the adapter returns. Deadline expiry stops the application's
|
||||
wait; a submitted job may still complete. The adapter never cancels or retries
|
||||
the job, checks unit active state, or verifies the WireGuard interface.
|
||||
Ordinary adapter tests inject a fake event transport and never connect to the
|
||||
system bus. Real D-Bus behavior remains for the disposable systemd VM
|
||||
acceptance at M9. Protocol
|
||||
references: [systemd Manager D-Bus API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html)
|
||||
and [zbus 5.19.0 API](https://docs.rs/zbus/5.19.0/zbus/).
|
||||
## Application restart orchestration
|
||||
|
||||
`Application::preview_restart` binds a read-only preview to the current saved
|
||||
generation and resolves the configured interface, systemd unit, and timeout
|
||||
from that generation. `Application::restart` accepts an explicit choice. Cancel
|
||||
returns before backup, service, runtime, or persistence adapters run. A
|
||||
confirmed operation completes a `pre-restart` backup, submits a systemd restart,
|
||||
waits for a successful terminal job, reads and compares the complete runtime
|
||||
peer set within the shared monotonic deadline, then writes verification state
|
||||
through the saved-file journal. Only after that write succeeds does in-memory
|
||||
applied state advance and deleted-client reservations release.
|
||||
|
||||
Failure reports identify the stage, whether a restart was submitted, whether it
|
||||
may still complete, the backup path when available, and whether manual recovery
|
||||
is required. A timeout does not cancel a submitted systemd job. A runtime mismatch
|
||||
or verification-state write failure does not roll back the VPN or release
|
||||
reservations. Commit and restore remain saved-file operations and never invoke
|
||||
the runtime or service adapters.
|
||||
|
||||
Application unit tests use private fake adapters and an injected private
|
||||
monotonic clock. Public integration tests exercise cancellation, preview,
|
||||
restore, and saved-setting selection without confirming a host restart. These
|
||||
are synthetic fixture results. M7 implemented the TUI and management startup
|
||||
wiring, with synthetic workflow and PTY coverage. M8 also validated the Linux
|
||||
Rustix transport against a real WireGuard interface in the isolated Alpine
|
||||
fixture, including IPv4 peer, route, handshake, and counter observations (see
|
||||
[M8 evidence](evidence/milestone-8.md)). That fixture does not exercise the
|
||||
real zbus/systemd path: systemd/D-Bus behavior and end-to-end restart acceptance
|
||||
still require the disposable VM at M9. Real IPv6 capability and traffic remain
|
||||
unverified.
|
||||
|
||||
## Bounded Linux transport
|
||||
|
||||
`runtime::transport::observe_interface` resolves the WireGuard Generic Netlink
|
||||
family through `nlctrl`, then sends only `WG_CMD_GET_DEVICE` for the supplied
|
||||
interface. It uses rustix 1.1.5's safe nonblocking socket, `poll`, and datagram
|
||||
APIs; no `SET_DEVICE`, subprocess, unsafe block, or host socket is used by the
|
||||
default tests. Family lookup and the complete multipart dump share one
|
||||
monotonic deadline. The datagram size is capped, `MSG_TRUNC` is detected using
|
||||
the returned full datagram length, the sender socket address must be kernel
|
||||
port 0, and decoder aggregate limits apply across the dump.
|
||||
|
||||
Linux's Generic Netlink reply header `nlmsg_pid` carries the requester port ID
|
||||
(the destination); the actual kernel sender port ID is 0 in the datagram
|
||||
address. The transport validates both identities. A complete read is returned
|
||||
only after the decoder consumes successful `NLMSG_DONE`; kernel errno values
|
||||
map to distinct no-device and permission outcomes. Malformed data, transport
|
||||
errors, timeouts, and limit excess remain separate, redacted categories.
|
||||
Receive buffers are zeroized on every return path because device replies may
|
||||
include private and preshared key attributes, although the decoder borrows and
|
||||
does not retain those attributes. Runtime observation does not itself perform
|
||||
saved-peer comparison or mark state applied. M8 exercised this Rustix reader
|
||||
against a real Alpine WireGuard interface for IPv4 observations; real IPv6
|
||||
capability and traffic remain unverified. See [M8 evidence](evidence/milestone-8.md)
|
||||
for the accepted scope.
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
# Testing and Acceptance
|
||||
|
||||
Read this document when writing tests, verifying a milestone, or preparing an integration environment. Required behavior and acceptance criteria remain governed by [the specification](SPEC.md).
|
||||
|
||||
## Validation
|
||||
|
||||
Use fixtures, temporary directories, and replaceable system adapters by default. Never modify `/etc/wireguard`, restart a host service, read production private keys, or test recovery against the live VPN during ordinary development. Real integration tests must be explicitly selected for a dedicated disposable environment; installing WireGuard on the development host is not enough.
|
||||
|
||||
Before Cargo scaffolding exists, validate documentation links and consistency only; do not report build/test commands as passing. Once the Rust project exists, run the relevant focused tests during development and these completion checks for Rust changes:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo test --locked
|
||||
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.
|
||||
- Commit/restore do not invoke runtime mutation; draft cancellation, saved/applied differences, and address reservation survive the intended transitions.
|
||||
- Allocation respects both ranges, occupied/server addresses, exhaustion, and deferred address reuse without IPv6-wide iteration.
|
||||
- Import verifies keys, preserves source files, and distinguishes missing counterparts from parameter conflicts.
|
||||
- Encryption works without a password; reissue handles correct/incorrect passwords, corruption, and first-delivery retry boundaries without leaking secrets.
|
||||
- Backup failures prevent mutation; restoration works without decryption, excludes recursive archives, and rejects unsafe archive entries.
|
||||
- Systemd success without a matching interface/client set is not marked applied; timeouts and failed restarts remain visible without automatic rollback.
|
||||
- File and QR delivery encode the same configuration; file failures, overwrite confirmation, non-TTY startup, resizing, and terminal cleanup behave correctly.
|
||||
|
||||
Test externally observable behavior and high-impact failure paths. Avoid tests that merely repeat implementation details, introduce root requirements, or assert real secrets in snapshots. Broaden testing when failures or new risks justify it.
|
||||
|
||||
### Terminal smoke coverage
|
||||
|
||||
`cargo test --locked --test tui_workflow terminal_pty_smoke -- --nocapture`
|
||||
launches the terminal loop through `script` with a synthetic `Application` and
|
||||
an injected fake unavailable-statistics reader. It enters at 80×24, changes to
|
||||
40×8 and back to 80×24, sends Ctrl-C (handled as the normal exit request), and
|
||||
asserts the alternate screen and cursor are restored. The setup is not root
|
||||
management startup and does not read host settings or query host WireGuard.
|
||||
The test reports itself incomplete if the
|
||||
`script` utility is unavailable. In the workflow test, an actual first-delivery
|
||||
QR result is transferred into the disclosure owner and Esc clears it; the
|
||||
renderer fit test separately checks complete quiet-zone dimensions. These
|
||||
checks are synthetic PTY/UI evidence, not phone/decoder interoperability.
|
||||
The PTY test exercises handled interruption and normal terminal restoration;
|
||||
`cargo test --locked tui::terminal::tests::lifecycle_failures_restore_terminal_modes_in_isolated_pty -- --nocapture`
|
||||
uses `script` to launch five isolated child probes at 80×24. The probes stop
|
||||
after raw-mode setup, stop after alternate-screen setup, return synthetic
|
||||
render and input I/O errors after full setup, and panic after full setup. Each
|
||||
child checks raw mode is off on return; the parent checks that alternate-screen
|
||||
and cursor enter/restore sequences match the resources reached. The panic case
|
||||
also checks that the panic hook emits its generic internal-failure diagnostic
|
||||
without the synthetic panic payload. These probes use the same private lifecycle
|
||||
guard and setup methods as production, without an environment-controlled
|
||||
production path or application/service adapters. The test reports itself
|
||||
incomplete if `script` is unavailable. PTY evidence remains synthetic; it does
|
||||
not test terminal-emulator behavior beyond the emitted control sequences.
|
||||
|
||||
## Integration testing with Alpine Testcontainers
|
||||
|
||||
Production access is limited and experimentation on the production VPN is prohibited. Use three complementary levels: ordinary unit/fixture tests, explicitly selected Alpine container integration tests, and a separate disposable Linux VM for real systemd/D-Bus acceptance. No level authorizes using production as a test environment.
|
||||
|
||||
### Alpine environment
|
||||
|
||||
- Use the approved `testcontainers` development dependency to start a fresh Alpine server and client on a dedicated internal IPv4 network. The RFC1918 bridge subnet `192.168.253.0/24` is reserved for this isolated underlay and must not overlap the synthetic tunnel ranges used by scenarios. Alpine is not a systemd test environment. The host process owns only resources it creates for the current scenario: uniquely named network, server/client containers, and private temporary fixture files. Run the probe synchronously so cleanup does not race a forcibly stopped Testcontainers request. When Testcontainers returns a container handle, RAII drops that owned container on success and recoverable failure; a final bounded cleanup inspects only the scenario's exact names and removes IDs only when the current process's ownership label matches. If a start request times out before returning an ID, an immediate absent inspection cannot prove the daemon will never finish creation. Report that resource status as uncertain, retain its exact name, and do not broaden cleanup.
|
||||
- Pin the Alpine base to a release or digest, record the resolved image identity and package versions, and build/cache the image before repeated suite runs. The runtime image contains only `wireguard-tools`, `iproute2`, BusyBox facilities that are actually used, and justified fixture requirements. It contains no systemd, Cargo registry, compiler, production configuration, generated key, or secret-bearing volume. Reuse image layers only; each scenario gets fresh containers and fresh private data directories.
|
||||
- The Docker-compatible daemon must be explicitly selected as a disposable Linux environment. The suite first identifies the selected endpoint and requires an explicit test-environment opt-in before making Docker API calls. It must not silently use a configured remote context. The daemon host must already provide kernel WireGuard support: containers share that kernel, and Alpine userspace tools do not supply it. A disposable probe verifies WireGuard interface creation and IPv4 address/route operations inside throwaway containers; it must not load modules, alter host sysctls, or create host interfaces. Missing support fails an explicitly requested run. IPv6 capability and traffic are intentionally not tested by M8.
|
||||
- Testcontainers 0.28.0 is built with only its `blocking` feature; `properties-config` is disabled. Its Docker client therefore reads `DOCKER_HOST` from the process environment and cannot select `tc.host` or `docker.host` from `~/.testcontainers.properties`. The probe requires `DOCKER_HOST` to exactly equal the explicitly selected local Unix endpoint, and its Docker CLI checks pass that same endpoint with `--host`. A mismatch fails before either client is used.
|
||||
- Give only the server and client containers `NET_ADMIN`, scoped to their own network namespaces. Run them in separate container network and PID namespaces. Do not use `--privileged`, `SYS_MODULE`, host networking, host PID/IPC namespaces, host `/etc/wireguard`, host D-Bus, Docker socket mounts, or production bind mounts. Use a uniquely named internal IPv4 network and choose its private underlay subnet so it does not overlap the tunnel ranges.
|
||||
- The host harness creates and tears down containers/networks and transfers bounded fixture inputs and outputs. Server-side operations run in the server container against its private fixture paths and namespace, using the real Application and `runtime::transport::observe_interface` path. Never query or mutate the host WireGuard interface as a shortcut. Use Testcontainers' supported synchronous runner (`SyncRunner`) and lifecycle/image APIs such as `ImageExt` network configuration only after checking the pinned crate version's API. Fixture commands use the selected endpoint and owned container ID through the bounded Docker CLI transport so a Testcontainers attach stream cannot leave the harness waiting indefinitely. See the [testcontainers 0.28.0 API](https://docs.rs/testcontainers/0.28.0/testcontainers/) and its [`ImageExt`](https://docs.rs/testcontainers/0.28.0/testcontainers/core/trait.ImageExt.html), [`Container`](https://docs.rs/testcontainers/0.28.0/testcontainers/core/struct.Container.html), and [`SyncRunner`](https://docs.rs/testcontainers/0.28.0/testcontainers/runners/trait.SyncRunner.html) references. Keep those APIs in host orchestration; they do not authorize fixture code to control unrelated containers.
|
||||
- Build only the required server-side test driver for `x86_64-unknown-linux-musl`, or build it in a pinned Alpine/musl builder using the repository's pinned Rust version. A host glibc executable, a target name, or a successful host build is not runtime compatibility evidence. Inspect dynamic requirements and execute a harmless readiness operation in the pinned Alpine image before the suite. Keep the compiler and build cache out of the runtime image. Missing target/linker prerequisites mean the suite cannot run; do not install global tools implicitly.
|
||||
- Keep the in-container driver boundary private to tests. Prefer an explicitly selected `cfg(test)` driver/scenario entry point that invokes the real application and the real Rustix reader; do not add a production apply command or an environment-variable bypass. The fixture apply operation may use explicit `ip`/`wg` commands with argument arrays and fixed fixture inputs, or equivalent direct fixture setup, but it must never execute imported hooks or arbitrary configuration text. It is not the production zbus adapter and all resulting reports must say “fixture apply.”
|
||||
- Preserve staged application authority in one long-lived in-container driver session for each scenario. That driver owns the `Application` instance from client creation through Commit and first delivery, retaining the application's in-memory first-issue eligibility. Reopening the saved store or launching a new process between Commit and first delivery is not a valid substitute: first delivery must remain bound to the creation session, and a reopened process must use password-authorized reissue. After first delivery, the driver may perform fixture apply through the private test seam and then verify through the real observation and normal application verification/persistence path. It must not mark state applied or release reservations directly.
|
||||
- The driver's control channel carries bounded, typed phase requests and redacted phase results only. Do not put passwords, private keys, complete client configurations, QR payloads, or vault plaintext in arguments, environment variables, image metadata, logs, or captured stdout/stderr. Generate WireGuard keys, vault material, and synthetic server/client configurations inside the private server fixture session. Seed only nonsecret driver binaries and fixture scripts with pre-start image copy configuration. Store secret-bearing files in mode-`0600` files under mode-`0700` container directories. Send only opaque path/session identifiers over the control channel. Copy the actual first-delivery file from the server container to a host-private mode-`0600` temporary file, then seed a fresh client container from those exact bytes before it starts. Configure the client from that file and verify its integrity without rendering it in diagnostics. Do not reconstruct or edit the payload to make the client work. The official [files and mounts guide](https://rust.testcontainers.org/features/files/) documents `ImageExt::with_copy_to`, `CopyTargetOptions::with_mode`, and `Container::copy_file_from`; the [exec guide](https://rust.testcontainers.org/features/exec_commands/) documents `Container::exec`. `with_copy_to` packages pre-start content in a TAR; accept only regular paths and reject symlinks. Confirm the pinned 0.28.0 crate supports the chosen sequence before implementation. Never use a host bind mount for secrets.
|
||||
- Generate synthetic keys/configurations per scenario in private fixture storage. Never put real keys/configs or working `Backup/` data in the build context. Diagnostics may include scenario, phase, public identity, status, elapsed time, and fixed redacted failure category, but not private payloads, command output, QR matrices, or `wg show all dump`. Each in-container command runs through `/bin/busybox timeout -s KILL 15 PROGRAM ...` and uses the bounded Docker CLI transport rather than a Testcontainers attach stream. Each CLI call has a 20-second host deadline, which also bounds a stalled exec response. Stdout and stderr are drained while retaining at most 128 bytes for namespace stdout and 1024 bytes for other stdout/stderr; output overflow fails with a fixed redacted category. The in-container timeout category is `In-container fixture command timed out`; if the host deadline expires, command completion inside the owned container is reported as uncertain. Testcontainers 0.28.0 configures a 120-second Docker API request timeout. The probe has at most 12 sequential Testcontainers API requests: two container starts (at most five each) and two removals, plus a conservative allowance for cleanup on partial starts. This gives a 24-minute upper bound for those API requests. Eight bounded fixture CLI calls add at most 160 seconds; daemon/image checks and network create/remove add at most 60 seconds; partial-start inspection/removal adds at most 60 seconds; final cleanup has a 40-second budget. The conservative aggregate bound is 27 minutes. No shorter outer deadline interrupts an in-flight Testcontainers request. If a start request fails before returning a resource ID, the harness reports an uncertain resource status even if immediate inspection finds no matching resource. Poll explicit conditions against monotonic deadlines rather than sleeping a fixed duration. A cleanup error must not replace the original test failure.
|
||||
|
||||
### Required container scenarios
|
||||
|
||||
1. Start an isolated server and existing client, establish a tunnel, and read real peer state through the runtime adapter.
|
||||
2. Create a client through the application, Commit, and verify saved files/vault plus unchanged running peer state. Deliver the client configuration and verify that it cannot connect before explicit application. M8 checks IPv4 behavior only.
|
||||
3. Use a test-only fixture operation to apply the saved configuration inside the container. Establish the new tunnel, generate IPv4 traffic, and verify handshake and increasing counters without asserting exact byte totals. IPv6 capability and traffic are intentionally untested.
|
||||
4. Delete a client and Commit; verify it is removed from saved data but remains in running state until explicit application, and its address remains reserved. After verified application, check removal, loss of tunnel access, and address release.
|
||||
5. Exercise backups and restoration against container-local data: restoration requires no vault password and does not mutate running state. Verify that applying the restored configuration returns the expected clients.
|
||||
6. Cover mandatory-backup failure and invalid client import with assertions about unchanged protected state and preserved source files.
|
||||
|
||||
Any fixture apply/restart adapter is test-only. It must not weaken the production requirement for a separate systemd restart or be reported as testing the production `zbus` implementation. If application orchestration uses the fixture service adapter, label those results accordingly.
|
||||
|
||||
### Opt-in contract and command status
|
||||
|
||||
The planned suite is an explicitly named ignored integration target, selected
|
||||
with a single-target command so `--ignored` cannot pick up unrelated privileged
|
||||
tests. The harness must additionally require an explicit environment selection
|
||||
for the disposable daemon before contacting Docker. A missing prerequisite after
|
||||
explicit opt-in is a nonzero test failure with a redacted prerequisite category,
|
||||
never a successful “skipped” test. Ordinary `cargo test`, `--help`, and
|
||||
`--version` must not contact Docker, require root, or inspect host interfaces.
|
||||
|
||||
The runtime image recipe is `tests/containers/alpine/Dockerfile`. Its build
|
||||
context is only `tests/containers/alpine/`; `.dockerignore` is an allowlist for
|
||||
the Dockerfile, package lock, and harmless readiness script. It does not include
|
||||
the repository, `.git`, Cargo data, credentials, `Backup/`, keys, or configs.
|
||||
The image pins the official Alpine 3.22.7 `linux/amd64` manifest
|
||||
`sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec`.
|
||||
The [official image metadata](https://github.com/docker-library/repo-info/blob/master/repos/alpine/tag-details.md)
|
||||
reports config/image ID
|
||||
`sha256:24ab38714e948bd49d24d566e97c1c104b63f85c2e642685e72393b37cfb9862`.
|
||||
The Alpine v3.22 x86_64 package indexes currently list [`wireguard-tools`
|
||||
1.0.20250521-r0](https://pkgs.alpinelinux.org/packages?branch=v3.22&name=wireguard-tools)
|
||||
and [`iproute2` 6.15.0-r0](https://pkgs.alpinelinux.org/packages?branch=v3.22&name=iproute2).
|
||||
The exact versions of every package in the installed closure are recorded in
|
||||
`packages.lock` and passed to `apk add` as constraints. Alpine branch indexes
|
||||
remain mutable; if an indexed version disappears, the build fails instead of
|
||||
silently selecting a different closure. The image installs only the listed
|
||||
runtime package closure; BusyBox is included in that closure.
|
||||
|
||||
From the repository root, after selecting the disposable daemon, build and cache
|
||||
the image with:
|
||||
|
||||
```sh
|
||||
docker build --pull=false --platform linux/amd64 --tag wireguard-admin-alpine:3.22.7 --file tests/containers/alpine/Dockerfile tests/containers/alpine
|
||||
docker image inspect --format='{{.Id}} {{.Os}}/{{.Architecture}}' wireguard-admin-alpine:3.22.7
|
||||
docker run --rm --network none --read-only --cap-drop ALL wireguard-admin-alpine:3.22.7 /usr/local/bin/wireguard-admin-alpine-check
|
||||
docker run --rm --network none --read-only --cap-drop ALL wireguard-admin-alpine:3.22.7 apk info -v
|
||||
```
|
||||
|
||||
The first command produces daemon-local image tag
|
||||
`wireguard-admin-alpine:3.22.7`; the inspect output records the actual image ID.
|
||||
The final command records the complete installed package closure. On the
|
||||
selected daemon, the image built successfully with ID
|
||||
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`;
|
||||
`apk info -v` matched the 33 exact constraints in `packages.lock`, and the
|
||||
networkless, read-only, capability-dropped readiness command exited 0. This
|
||||
validates the shell tools in the image only; no WireGuard interface was created
|
||||
and no Rust driver compatibility is claimed.
|
||||
|
||||
M8.4 provides the ignored `#[cfg(test)]` library test
|
||||
`container_driver::alpine_readiness`; libtest selects it with the supported
|
||||
`--ignored --exact` arguments. It checks only that the process is running on
|
||||
Linux x86_64 and does not access or modify an interface. The wrapper invokes
|
||||
only that test with one test thread. M8.4 also provides a test-only fixture
|
||||
command enum for explicit `ip` link/address/route and `wg setconf` operations.
|
||||
It passes argument arrays through BusyBox's 15-second timeout, limits interface,
|
||||
address, route and config-file inputs to typed/validated forms, discards child
|
||||
output, and returns a fixed redacted failure category. `wg setconf` consumes a
|
||||
fixture-local file; imported hooks and arbitrary configuration text are never
|
||||
executed. The application fixture-restart adapter is also `cfg(test)` and
|
||||
continues through the real Rustix observation, saved-peer verification and
|
||||
journaled persistence path. Its result represents fixture apply, not a systemd
|
||||
job.
|
||||
|
||||
M8.5's scenario driver must keep a single `Application` value alive from
|
||||
creation through Commit and first delivery so the in-memory first-issue
|
||||
authorization remains bound to that creation session. It must not reopen the
|
||||
store between those operations. Build the test harness for musl with:
|
||||
|
||||
```sh
|
||||
cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
The host lacks the musl target. The official `rust:1.97.1-alpine3.22`
|
||||
linux/amd64 builder manifest is pinned to
|
||||
`sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`.
|
||||
On the selected disposable daemon its image ID is
|
||||
`sha256:bc2b74a0f3f942897f678aa466c3cce54048cf1e094893af3596cf43766153c2`;
|
||||
it reports Rust/Cargo 1.97.1 and the installed musl target. From the repository
|
||||
root, build without installing host tooling or copying source into the image:
|
||||
|
||||
```sh
|
||||
docker run --rm --platform linux/amd64 --mount type=bind,src="$PWD",dst=/src,readonly --mount type=bind,src=/tmp,dst=/out -e CARGO_TARGET_DIR=/out/wga-m8-build-target -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
The 2026-10-23 build completed in 4m25s and emitted
|
||||
`/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031`.
|
||||
The hash may change after source edits; use Cargo's printed executable path.
|
||||
If `/tmp` has been cleared and an offline rebuild is needed, mount an existing
|
||||
public Cargo registry cache read-only and set `CARGO_HOME` as shown in the
|
||||
[M8.7 evidence](evidence/milestone-8.md). The pinned builder then produced the
|
||||
same driver SHA256 from the unchanged source on 2026-10-25. Missing cached
|
||||
dependencies remain a failed prerequisite until obtained through an approved
|
||||
build setup; an absent driver must never count as a skipped scenario.
|
||||
`file` identified that artifact as a static PIE x86-64 ELF, and `readelf -l`
|
||||
showed no dynamic interpreter. A preliminary networkless, read-only,
|
||||
capability-dropped Alpine run selected its readiness test and passed 1/1 using
|
||||
only a read-only bind of this nonsecret binary. The M8.4 host smoke now copies
|
||||
the current binary into a fresh Testcontainers fixture and selects one ignored
|
||||
`container_driver::alpine_scenario_driver` test through a bounded stdin/stdout
|
||||
channel. It requires bootstrap, Create, Commit, first delivery with activation
|
||||
warning, and verified fixture Apply phase markers. The readiness wrapper executes
|
||||
`PATH --ignored --exact container_driver::alpine_readiness --test-threads=1`;
|
||||
it does not pass a custom argument to the harness. No fixture key or
|
||||
configuration is copied into the builder image or runtime image build context.
|
||||
|
||||
The M8.3 prerequisite and M8.4 Application smoke share one exact ignored host
|
||||
test. After building the current musl artifact, run it on the explicitly selected
|
||||
local daemon with the matching endpoint and daemon ID:
|
||||
|
||||
```sh
|
||||
DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1
|
||||
```
|
||||
|
||||
The probe uses a uniquely named internal IPv4 bridge and no longer requires
|
||||
Docker IPv6 or IPv6 iptables support. It must pass before M8.4–M8.5 scenarios
|
||||
can be run. On 2026-10-23 the current musl artifact and host smoke passed 1/1
|
||||
in 4.05 seconds on the selected local daemon, and exact-name Docker checks
|
||||
found no leftover test resources. It proves test-only saved-state creation,
|
||||
Commit, first delivery and fixture Apply with real runtime observation; it does
|
||||
not establish a client tunnel or traffic. IPv6 capability and traffic are
|
||||
intentionally untested; production dual-stack behavior remains specified in
|
||||
SPEC.md. The M8.5 traffic slice transfers the server's actual first-delivery
|
||||
file to a fresh client and passed real IPv4 traffic checks. Deletion, restore,
|
||||
import/reissue, and mandatory-backup-failure slices also passed selected-daemon
|
||||
runs, with their slice findings closed. The evidence report records the
|
||||
known-source backup-failure run. On 2026-10-25 the final integrated host gates
|
||||
and two fresh selected-daemon runs passed on one unchanged source fingerprint;
|
||||
the documented unfiltered opt-in command passed as a third fresh run. See the
|
||||
[M8.7 evidence](evidence/milestone-8.md) for identities, counts, durations and
|
||||
limits. These Alpine fixture results do not cover actual systemd/D-Bus jobs or
|
||||
the user-excluded IPv6 capability and traffic checks.
|
||||
|
||||
### Systemd acceptance and test execution
|
||||
|
||||
- Use a disposable Linux VM with WireGuard and systemd to verify the actual `zbus` service adapter, job completion/timeouts, restart verification, and recovery from a selected backup. Exercise both the system-bus path and the root-only `/run/systemd/private` path with the system bus endpoint unavailable. Also verify that permission/authentication failures and connection deadlines do not trigger fallback. Current synthetic tests cover transport selection and failure classification; they do not emulate systemd or prove the D-Bus peer protocol. Do not substitute a mounted host D-Bus or claim Alpine tests cover systemd. The private socket is an internal systemd interface and may change between releases.
|
||||
- Keep container and VM suites out of the default `cargo test` execution. Once implemented, document their exact opt-in commands and prerequisites in this document. Do not list a planned command as available before its test target exists.
|
||||
- When integration testing is explicitly requested, unavailable Docker, WireGuard support, IPv4 networking, or required capabilities means the suite is not executed or incomplete, not passed. Distinguish prerequisite failures from application failures in the report. IPv6 capability and traffic are intentionally not tested by M8.
|
||||
- Report unit/fixture results, Alpine real-WireGuard results, and VM real-systemd results separately. Full acceptance requires the relevant real-system checks, not just mocks or successful container startup.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Encrypted vault format
|
||||
|
||||
The application stores the vault as UTF-8 TOML, schema version 1. The bundle is
|
||||
an opaque encrypted unit suitable for persistence, backup, and restoration
|
||||
without the vault password. Parsing is bounded and rejects unsupported versions,
|
||||
unknown fields, malformed labels, invalid Base64, and oversized bundles.
|
||||
|
||||
## Version 1 structure
|
||||
|
||||
The current encrypted bundle lives at
|
||||
`ResolvedSettings::data_directory.join("vault.toml")`. This stable filename is
|
||||
relative to the resolved data directory, whose default is `Backup/data` beside
|
||||
the executable. The application installs the opaque serialized bytes through
|
||||
the saved-file transaction after completing its mandatory pre-Commit backup.
|
||||
|
||||
The top level contains:
|
||||
|
||||
| Field | Content |
|
||||
| --- | --- |
|
||||
| `version` | Integer `1`. Other values are rejected. |
|
||||
| `recipient` | Canonical age x25519 public recipient (`age1...`). |
|
||||
| `identity_ciphertext` | Standard Base64 of an age binary file encrypted with the passphrase recipient format (scrypt). Its plaintext is the age x25519 secret identity. |
|
||||
| `records` | TOML table keyed by each client's canonical WireGuard public key. |
|
||||
|
||||
Each record table contains `ciphertext`, standard Base64 of an age binary file
|
||||
encrypted to `recipient`, and `reissue_blocked`, an advisory status snapshot.
|
||||
The snapshot is not authorization. Retrieval decrypts the record and enforces its
|
||||
authenticated `reissue_blocked` value; editing the TOML boolean cannot authorize
|
||||
a blocked key. Record insertion rejects an existing public-key label and never
|
||||
silently replaces ciphertext.
|
||||
|
||||
The TOML shape is:
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
recipient = "age1..."
|
||||
identity_ciphertext = "<base64 age passphrase-encrypted identity>"
|
||||
|
||||
[records."<canonical WireGuard public key>"]
|
||||
ciphertext = "<base64 age recipient-encrypted record>"
|
||||
reissue_blocked = false
|
||||
```
|
||||
|
||||
The plaintext of a decrypted client record is UTF-8 TOML with this schema:
|
||||
|
||||
```text
|
||||
version = 1
|
||||
label = "<canonical WireGuard public key>"
|
||||
private_key = "<canonical Base64 WireGuard private key>"
|
||||
client_addresses = ["<original client interface Address item>"]
|
||||
interface_dns = "<optional original DNS value>"
|
||||
interface_mtu = "<optional original MTU value>"
|
||||
server_public_key = "<canonical WireGuard server public key>"
|
||||
endpoint = "<optional original Endpoint value>"
|
||||
allowed_ips = ["<original client AllowedIPs item>"]
|
||||
persistent_keepalive = "<optional original PersistentKeepalive value>"
|
||||
preshared_key = "<optional canonical Base64 preshared key>"
|
||||
reissue_blocked = true
|
||||
```
|
||||
|
||||
Optional fields are omitted when absent. Unknown input directives such as hooks
|
||||
are not interpreted or executed, and are not part of the retained delivery
|
||||
parameters. The record label is authenticated by age encryption and repeated
|
||||
inside the ciphertext. On retrieval the implementation checks the embedded label,
|
||||
derives the public key from the decrypted private key, and enforces the encrypted
|
||||
conflict bit. Swapping a ciphertext to another public-key table entry therefore
|
||||
fails closed. No alias, filename, or server peer key is used as client identity.
|
||||
|
||||
Client imports derive identity only from `[Interface] PrivateKey`. The `[Peer]
|
||||
PublicKey` is retained as the server identity. Address discrepancies, a confirmed
|
||||
server-key mismatch, an unresolved authoritative server identity, file-only
|
||||
clients, unsupported address projection, invalid inputs, and duplicate candidates
|
||||
are separate report outcomes. An unresolved identity is not treated as a match.
|
||||
A valid key may be encrypted while a conflict is present, but password-authorized
|
||||
retrieval remains blocked. Clearing that authenticated state is deferred until an explicit reconciliation operation
|
||||
can verify both client material and a fresh authoritative server configuration;
|
||||
the current API intentionally provides no public-boolean or metadata-only bypass.
|
||||
|
||||
## Read-only source scan
|
||||
|
||||
The configured client source directory is scanned non-recursively. Immediate
|
||||
regular files ending in `.conf` (ASCII case-insensitive) are candidates; other
|
||||
names and nested directories are ignored. Symlinks and non-regular files are
|
||||
not read. Each candidate is capped at 1 MiB, a scan at 4,096 directory entries,
|
||||
1,024 candidates, and 32 MiB of input. File/open/read/size errors are reported
|
||||
by safe category and do not stop other candidates. A directory or enumeration
|
||||
failure and any per-file read/limit failure mark the scan incomplete. Initial setup returns
|
||||
the encrypted in-memory bundle and report; installing it and committing remain
|
||||
separate operations. Explicit later scans encrypt to the existing public
|
||||
recipient and preserve the protected identity. Source files are never modified.
|
||||
If a later import finds an existing public-key record, the prior ciphertext is
|
||||
preserved and the report identifies that duplicate; the scanner cannot establish
|
||||
plaintext equality without decrypting the prior record, so it does not claim the
|
||||
files are equal.
|
||||
|
||||
## Limits and failure behavior
|
||||
|
||||
- Serialized TOML bundle: 16 MiB maximum.
|
||||
- Records: 4,096 maximum.
|
||||
- One encrypted record: 64 KiB maximum, before Base64 encoding.
|
||||
- Password-encrypted identity: 16 KiB maximum, before Base64 encoding.
|
||||
- The serialized representation must fit within the bundle limit.
|
||||
|
||||
Malformed/truncated ciphertext, a recipient that differs from the protected
|
||||
identity, unknown versions, missing records, and relabelled records return
|
||||
secret-free typed errors. An unsupported decrypted record schema returns the
|
||||
version error, while a payload whose label does not match the requested public
|
||||
key returns the record mismatch error. There is no fallback to plaintext files.
|
||||
The bundle loader does not unlock the identity, so backup and restore require no
|
||||
password.
|
||||
|
||||
## Secret lifetime during encoding
|
||||
|
||||
New-client encryption needs only the public age recipient. Retrieval constructs
|
||||
an age scrypt identity for one call, decrypts the protected x25519 identity,
|
||||
verifies it against the public recipient, decrypts one record, and drops the
|
||||
temporary identity and plaintext on return. The application API does not expose
|
||||
an unlocked vault identity or retain a password between calls.
|
||||
|
||||
The WireGuard key's explicit Base64 representation and the record plaintext
|
||||
containing it must exist briefly in process memory before age can encrypt them.
|
||||
Age also creates and buffers its file key, stream state, and output ciphertext.
|
||||
These copies are inherent to the selected age streaming API; no plaintext is
|
||||
written into the TOML bundle. The in-memory private-key type, passphrase type,
|
||||
and temporary plaintext buffers use zeroizing secret containers where supported.
|
||||
Age's standard passphrase format uses its normal scrypt work factor; the
|
||||
application does not override or weaken it. The caller remains responsible for
|
||||
keeping the input password string short-lived.
|
||||
|
||||
The password protects only the vault identity. It does not encrypt server
|
||||
configuration, retained original client files, the entire backup archive, or
|
||||
WireGuard server secrets stored elsewhere.
|
||||
@@ -0,0 +1,392 @@
|
||||
# Disposable systemd VM acceptance contract
|
||||
|
||||
Status: Milestone 9 is paused at the user's request. The exact disposable VM
|
||||
was powered off after a guarded first start and an installer progress screen;
|
||||
read-only `list runningvms` was empty afterward. Debian installation completion,
|
||||
guest provisioning, reset, cleanup, and systemd acceptance are unverified.
|
||||
|
||||
This contract supplements [Testing and Acceptance](TESTING.md) and the M9
|
||||
criteria in [PLANS.md](../PLANS.md). Product behavior is governed by
|
||||
[SPEC.md](SPEC.md), [Runtime observation and verification](RUNTIME.md), and
|
||||
[ADRs 0001–0003](adr/).
|
||||
|
||||
## Selected target
|
||||
|
||||
The selected target is exactly one new VirtualBox VM:
|
||||
|
||||
| Field | Required value |
|
||||
| --- | --- |
|
||||
| Provider | Oracle VirtualBox, local `VBoxManage` 7.2.8_Gentoo |
|
||||
| VM name | `wga-m9-debian137-20261025` |
|
||||
| Guest base | Official Debian 13.7.0 (Trixie) amd64 netinst ISO |
|
||||
| ISO path | `/media/data/wm/Virtualbox/wga-m9-lab/debian-13.7.0-amd64-netinst.iso` |
|
||||
| ISO SHA-256 | `a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355` |
|
||||
| VirtualBox OS type | `Debian13_64` |
|
||||
| Lab root | `/media/data/wm/Virtualbox/wga-m9-lab` (owner `sasa`, mode `0700`) |
|
||||
| Networking | One NAT NIC only, no forwarding; all other NICs disabled |
|
||||
| VM UUID | `b14c850b-a21e-4a56-acca-785590f0c4ac`, pinned in the mode-`0600` identity manifest |
|
||||
| Guest marker | `/etc/wga-m9-lab-marker`, exact content `wga-m9-debian137-20261025` after provisioning |
|
||||
|
||||
The ISO is present, has the stated SHA-256, and was obtained from Debian's
|
||||
[official amd64 netinst directory](https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/).
|
||||
VBoxManage command forms were checked against the local 7.2.8 help and Oracle's
|
||||
[VirtualBox 7.2 VBoxManage manual](https://docs.oracle.com/en/virtualization/virtualbox/7.2/user/vboxmanage.html).
|
||||
Read-only `VBoxManage unattended detect --iso=... --machine-readable` reported
|
||||
`OSTypeId=Debian13_64`, `OSVersion=13.7.0 Trixie`, and
|
||||
`IsInstallSupported=on`, but then exited 1 with `NS_ERROR_NOT_IMPLEMENTED`.
|
||||
The later bounded `unattended install --dry-run --start-vm=none` succeeded for
|
||||
this exact VM with private synthetic password files, a fully qualified `.invalid`
|
||||
hostname, and the local Guest Additions ISO. The dry run made no VM changes;
|
||||
actual unattended preparation and guest boot remain unverified. VirtualBox's
|
||||
dry-run output contains plaintext password fields, so the wrapper suppressed
|
||||
them and displayed only redacted output. Any actual unattended step needs a
|
||||
reviewed exact-target procedure and post-prepare media inspection before start.
|
||||
|
||||
The approved read-only inventory outside the sandbox found `/dev/vboxdrv`
|
||||
available and `VBoxManage` 7.2.8 working. It initially listed Windows 7 and
|
||||
Windows 11 VMs; neither is a test target or was modified. The create-only run
|
||||
registered only the new Debian target and stopped before configuration. The
|
||||
default sandbox does not expose the host driver/device, so host inventory and
|
||||
later VBoxManage operations must use the approved outside-sandbox path. The
|
||||
guard matches the exact new name/UUID and does not inspect existing VMs'
|
||||
per-VM files.
|
||||
|
||||
## Guarded host operations
|
||||
|
||||
[`tests/vm/guard.py`](../tests/vm/guard.py) is a narrow standard-library Python
|
||||
guard, not a general VirtualBox wrapper. Its default `check`, `start`, and
|
||||
`authorize-guest` actions are outside Cargo's default test targets. The guard:
|
||||
|
||||
- Requires the exact lab root to be a real directory owned by the current user
|
||||
with mode `0700`, and `target-identity.json` to be a regular current-user file
|
||||
with mode `0600`.
|
||||
- Requires the manifest to pin the exact VM name, current owner UID, one valid
|
||||
UUID, and host marker `<VM name>:<UUID>`.
|
||||
- Uses `VBoxManage list vms` to require that exact name occur once with the pinned
|
||||
UUID, then `showvminfo <UUID> --machinereadable` to compare UUID/name/marker.
|
||||
- Requires the VM configuration and its one VDI to reside below the lab root,
|
||||
the VDI to be named exactly `wga-m9-debian137-20261025.vdi`, and any mounted
|
||||
installer ISO to be the pinned Debian ISO in that root.
|
||||
- Requires a complete inventory of all eight NIC slots, with only NIC 1 in NAT
|
||||
mode. NAT forwarding and all VirtualBox shared folders are refused.
|
||||
- Performs every identity, path, and isolation check before `startvm`. A missing,
|
||||
duplicate, malformed, or mismatched identity fails closed. The only start
|
||||
target is the pinned UUID, in the GUI frontend for console installation.
|
||||
- Before guest authorization, repeats all host checks, requires a current-user
|
||||
password file with mode `0600`, and reads only the guest marker via
|
||||
`VBoxManage guestcontrol ... run /usr/bin/cat` with `--wait-stdout` and a
|
||||
15-second guest process timeout. Host stdout/stderr capture is also capped at
|
||||
1 MiB while the process runs. The password is supplied using `--passwordfile`;
|
||||
it is never placed in argv. Marker output is compared exactly and is not
|
||||
echoed. This read is unprivileged inside the guest.
|
||||
|
||||
The host marker is a pre-install bootstrap identity: it binds the created
|
||||
VirtualBox object and protected manifest before the first start. It is not proof
|
||||
of guest identity. The guest marker is installed only after Debian setup and is
|
||||
checked separately after boot. Do not perform root provisioning, run application
|
||||
acceptance, or access the guest system bus until `authorize-guest` succeeds for
|
||||
the exact UUID. The guest marker check is deliberately read-only; it does not
|
||||
grant the caller a general guest command runner.
|
||||
|
||||
The exact current focused checks are:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests/vm -p 'test_*.py' -v
|
||||
python3 -m py_compile tests/vm/guard.py tests/vm/test_guard.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
They use a temporary synthetic VM inventory and fake VBoxManage responses. They
|
||||
do not call VBoxManage, start a guest, contact a host service, or read any
|
||||
registered VM configuration. The behavioral cases cover normal exact identity,
|
||||
missing/duplicate/wrong UUID, wrong name, manifest/root permissions, storage
|
||||
escape, NIC mode and incomplete inventory, NAT forwarding, shared folders,
|
||||
host/guest marker mismatch, no `startvm` on refusal, and exact UUID start scope.
|
||||
|
||||
The guard supports these later commands once the selected VM has been created,
|
||||
its manifest and marker have been recorded, and the coordinator has reviewed the
|
||||
mutation step:
|
||||
|
||||
```sh
|
||||
python3 tests/vm/guard.py check
|
||||
python3 tests/vm/guard.py start
|
||||
python3 tests/vm/guard.py authorize-guest --password-file /path/to/private/guest-password
|
||||
```
|
||||
|
||||
The placeholder password path above must name a private file owned by the
|
||||
current user with mode `0600`. Do not put a password value in the command line,
|
||||
environment, terminal history, or output. The `start` command is an implemented
|
||||
guarded operation but has not been run. Running it requires the approved
|
||||
outside-sandbox path because the default sandbox does not expose the host
|
||||
VirtualBox device.
|
||||
|
||||
## Create-only procedure (ran once; do not rerun)
|
||||
|
||||
[`tests/vm/create.py`](../tests/vm/create.py) exposes only the fixed create
|
||||
operation; it accepts no target, path, or action override. After confirming the
|
||||
lab root owner/mode, exact ISO digest, absence of both the identity manifest and
|
||||
the exact target VM path (using `lstat`, so dangling symlinks also count as
|
||||
occupied), and a well-formed VirtualBox inventory without the selected name, it
|
||||
issues bounded VBoxManage calls to:
|
||||
|
||||
1. Register `wga-m9-debian137-20261025` as `Debian13_64` below the lab root.
|
||||
2. Configure 1024 MiB RAM, one CPU, NIC 1 in NAT mode, NICs 2–8 disabled, audio
|
||||
disabled, USB disabled, and DVD then disk boot order.
|
||||
3. Create one dynamically allocated standard 8192 MiB VDI, attach it on SATA
|
||||
port 0, and attach the pinned Debian ISO on SATA port 1.
|
||||
4. Write the actual VM UUID and `<name>:<UUID>` host marker in
|
||||
`target-identity.json` with mode `0600`, set the same marker as the VirtualBox
|
||||
description, then run the existing read-only host guard.
|
||||
|
||||
Run this exact command only after coordinator review of the current host state
|
||||
and the scoped mutations:
|
||||
|
||||
```sh
|
||||
python3 tests/vm/create.py
|
||||
```
|
||||
|
||||
The command must run through the approved outside-sandbox path because the
|
||||
default sandbox cannot access the VirtualBox host driver. Its effects are VM
|
||||
registration, storage/network/device/boot configuration, ISO attachment, and
|
||||
the protected host identity manifest. It does not start the VM, install Debian,
|
||||
use guest control, create snapshots, or delete/clean up resources. VBoxManage
|
||||
calls have 30-second timeouts and 1 MiB per-stream output limits. Errors omit
|
||||
VBoxManage output; after registration they report the exact target name and
|
||||
known UUID. A failed procedure retains any partial target and its identity for
|
||||
inspection; it performs no automatic rollback or broad cleanup. The final guard
|
||||
must pass before the script reports success. The first run registered the UUID
|
||||
above, then stopped when strict parsing rejected VirtualBox's indented
|
||||
`rec_screen0` recording marker. The parser was narrowly repaired and
|
||||
independently re-reviewed. The target path is now occupied, so `create.py` must
|
||||
not be rerun; use the reviewed resume procedure below.
|
||||
|
||||
## Resume the registered partial target (ran once; do not rerun)
|
||||
|
||||
The earlier create attempt registered the selected target before stopping at
|
||||
the initial machine-readable parse. The exact retained target is
|
||||
`wga-m9-debian137-20261025` with UUID
|
||||
`b14c850b-a21e-4a56-acca-785590f0c4ac`; it is powered off and has only its
|
||||
`.vbox` configuration file. Its observed NIC inventory was NIC 1 in NAT with
|
||||
NICs 2–8 disabled. It had no identity manifest or VDI. That prior state was
|
||||
the sole input accepted by [`tests/vm/resume.py`](../tests/vm/resume.py).
|
||||
|
||||
The resume-only procedure is fixed to that UUID and lab root. Before its first
|
||||
mutation it checks the private root, exact pinned ISO path and SHA-256, absent
|
||||
manifest and target VDI, unique registered name/UUID identity, exact `.vbox`
|
||||
path, powered-off state, empty host description, default memory/CPU settings,
|
||||
the observed NIC inventory (NIC 1 in NAT and NICs 2–8 disabled), no attached
|
||||
media or storage controller, no shared folders, and no NAT forwarding. It
|
||||
refuses an unexpected adapter or preexisting state.
|
||||
After preflight it configures memory/CPU, NAT-only networking, disabled audio
|
||||
and USB, boot order, one standard 8192 MiB VDI, and the pinned ISO; then it
|
||||
writes the protected host manifest, sets the host marker, and runs the existing
|
||||
read-only guard. It does not create, start, unregister, delete, or clean up a
|
||||
VM. A failure after mutation retains the exact target and reports its pinned
|
||||
UUID for inspection; no rollback is attempted.
|
||||
|
||||
The focused command is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests/vm -p 'test_resume.py' -v
|
||||
```
|
||||
|
||||
It uses only a temporary synthetic lab and fake VBoxManage responses. It does
|
||||
not call VBoxManage or establish host/VM evidence. The implementation command
|
||||
was `python3 tests/vm/resume.py`; it ran once via the approved outside-sandbox
|
||||
path. It configured the exact target and wrote the host marker/manifest. Its
|
||||
final guard call initially refused observed VirtualBox storage metadata;
|
||||
focused repairs and independent re-review closed those parser and guard
|
||||
findings. A subsequent read-only `python3 tests/vm/guard.py check` passed
|
||||
against the actual powered-off target. Do not rerun the resume command on this
|
||||
occupied state.
|
||||
|
||||
Do not infer `unregistervm --delete`, snapshot restore, guest-control, or broad
|
||||
cleanup commands from the CLI help. No provisioning command is documented
|
||||
because Debian installation, Guest Additions/management channel, the unprivileged
|
||||
`wga` account, and guest marker placement have not been validated. No reset or
|
||||
deletion command is documented because a baseline snapshot and exact owned
|
||||
resource IDs do not yet exist. Existing registered VMs must remain untouched.
|
||||
|
||||
## Unattended preparation (ran once; do not rerun)
|
||||
|
||||
[`tests/vm/install_prepare.py`](../tests/vm/install_prepare.py) is pinned to the
|
||||
VM UUID above and accepts no target, path, credential, or action overrides. It
|
||||
first checks the current user's `/tmp/wga-m9-private` directory is a real mode
|
||||
`0700` directory and its nonempty `wga-password` and `root-password` files are
|
||||
current-user-owned regular mode-`0600` files. These are synthetic install
|
||||
credentials only; password contents never enter argv or output. It verifies the
|
||||
pinned Debian ISO and `/usr/share/virtualbox/VBoxGuestAdditions.iso` SHA-256
|
||||
digests, then calls the existing exact-target read-only guard and additionally
|
||||
requires the VM to be powered off, with the expected VDI at `SATA-0-0`, the
|
||||
pinned installer ISO at `SATA-1-0`, and every other reported SATA attachment
|
||||
slot set to `none` or `emptydrive`. This accepts VirtualBox 7.2.8's real
|
||||
`showvminfo` inventory (`SATA-2-0` through `SATA-29-0` are `none`) while
|
||||
rejecting any additional attached media. It refuses before mutation if any
|
||||
check fails.
|
||||
|
||||
The script runs a bounded `VBoxManage unattended install <pinned UUID>` dry run
|
||||
first with `--iso=<pinned Debian ISO>` and
|
||||
`--admin-password-file=/tmp/wga-m9-private/root-password`, using the synthetic
|
||||
`wga` account and password file, `.invalid` FQDN, `en_US`, `UTC`, US country,
|
||||
minimal package adjustment, and the pinned Guest Additions ISO. The root
|
||||
credential option is `--admin-password-file`; `--root-password-file` is not a
|
||||
valid option for the local VBoxManage 7.2.8. Only a successful dry run permits
|
||||
the same bounded command without `--dry-run`. Both commands request
|
||||
`--start-vm=none`; the script has no start, cleanup, or retry operation.
|
||||
VBoxManage output is captured privately because its dry-run output contains
|
||||
plaintext password fields; errors and status output omit captured text. The
|
||||
command has a 120-second deadline and 1 MiB per-stream capture limit.
|
||||
|
||||
After independent review, the command ran once through the approved
|
||||
outside-sandbox VBoxManage path:
|
||||
|
||||
```sh
|
||||
python3 tests/vm/install_prepare.py
|
||||
```
|
||||
|
||||
The command exited 0 and left the VM powered off. Read-only `showvminfo`
|
||||
inspection found the exact VDI at SATA 0 and a VirtualBox-generated auxiliary
|
||||
`.viso` at SATA 1, both in the private target folder. The generated installer
|
||||
files are mode `0600`; the target folder is mode `0700`. The normal host guard
|
||||
refuses the `.viso`, as designed, so the first start requires a separate guard
|
||||
for this exact installer state and independent review. Do not rerun preparation
|
||||
on the occupied state. Guest boot, Guest Additions setup, and guest provisioning
|
||||
are not established by this result or the synthetic tests.
|
||||
|
||||
The focused behavioral tests use only temporary synthetic files and a fake
|
||||
VBoxManage runner:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests/vm -p 'test_install_prepare.py' -v
|
||||
```
|
||||
|
||||
## First start of the prepared installer (ran once; VM stopped)
|
||||
|
||||
[`tests/vm/install_boot.py`](../tests/vm/install_boot.py) adds a separate,
|
||||
fixed-target `check`/`start` command for the one powered-off installer state.
|
||||
The ordinary [`guard.py`](../tests/vm/guard.py) deliberately continues to
|
||||
reject the generated `.viso`; this installer guard does not loosen that normal
|
||||
guard. It reuses the strict machine-readable parser and checks the exact
|
||||
current-user-owned mode-`0600` identity manifest, pinned UUID and matching host
|
||||
description, exact target name/configuration path, and current-user-owned
|
||||
mode-`0700` target folder. It requires the VDI at SATA 0 and exactly
|
||||
`Unattended-b14c850b-a21e-4a56-acca-785590f0c4ac-aux-iso.viso` at SATA 1;
|
||||
SATA ports 2–29 must all be `none`. The `.viso` and any generated
|
||||
`isolinux`/`preseed`/`postinstall` files are checked as current-user-owned
|
||||
mode-`0600` regular files with exactly one hard link. The VM configuration and
|
||||
VDI are also required to be regular current-user-owned files with exactly one
|
||||
hard link. The guard never opens or prints installer-file contents.
|
||||
The VM must remain powered off during preflight, have only NIC 1 in NAT mode,
|
||||
and have no forwarding rules or shared folders. Symlinked paths, extra media,
|
||||
unknown attachment metadata, and incomplete or mismatched identity refuse
|
||||
before start.
|
||||
|
||||
The exact commands are:
|
||||
|
||||
```sh
|
||||
python3 tests/vm/install_boot.py check
|
||||
python3 tests/vm/install_boot.py start
|
||||
```
|
||||
|
||||
`check` only reads the protected manifest, filesystem metadata, and bounded
|
||||
VirtualBox inventory; it does not change VM state. `start` repeats all checks,
|
||||
then issues only `VBoxManage startvm b14c850b-a21e-4a56-acca-785590f0c4ac
|
||||
--type=headless`. It accepts no UUID, root, media, credential, or action
|
||||
override. Each VBoxManage call uses the existing guard's 15-second deadline and
|
||||
1 MiB per-stream output cap. Output is not copied into errors. If the start call
|
||||
fails or times out, the result explicitly says the VM may have started and
|
||||
requires a separate read-only inspection; there is no retry, cleanup, or
|
||||
rollback. A successful command means only that VirtualBox accepted the start
|
||||
request. It does not prove guest boot, Debian installation, or Guest Additions.
|
||||
|
||||
Focused fake-runner coverage is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests/vm -p 'test_install_boot.py' -v
|
||||
```
|
||||
|
||||
These tests create synthetic private files and never call VBoxManage. Both
|
||||
commands ran once against the actual VM after independent review. `check`
|
||||
passed; `start` returned success; VirtualBox listed only the pinned target as
|
||||
running. A private console screenshot showed Debian's base-system installer at
|
||||
6%. The user then stopped Milestone 9, so the exact target was powered off and
|
||||
read-only `list runningvms` returned no running VMs. Do not restart it without
|
||||
a new explicit request. Installation completion and guest boot remain
|
||||
unverified.
|
||||
|
||||
After installation, provision only synthetic lab packages,
|
||||
fixed reviewed service units, synthetic WireGuard keys/configuration, and the
|
||||
guest-local marker. Keep all server/client data, outputs, and working backups
|
||||
under a guest-only root-owned `0700` directory; secret files are `0600`. The
|
||||
guest-local marker must be readable by the unprivileged `wga` account for the
|
||||
read-only marker gate. This work belongs to the next task after target creation
|
||||
and guard review; it is not claimed as implemented here.
|
||||
|
||||
## Isolation and acceptance boundary
|
||||
|
||||
- Use synthetic server/client keys, configuration, settings, and vault contents
|
||||
generated inside the guest. Preserve imported originals. Do not copy or read
|
||||
production keys, configuration, backups, or credentials.
|
||||
- The VM may have only one NAT management adapter, with no forwarding rules;
|
||||
do not use bridged, host-only, internal, NAT-network, USB network, or physical
|
||||
adapters. NAT-only VM egress still traverses the host's ordinary network; do
|
||||
not claim host-independent network isolation. No host D-Bus, `/etc`, `/run`,
|
||||
`/var`, WireGuard interface, service control, or general shared folder may be
|
||||
mounted or forwarded into the guest.
|
||||
- A guest system D-Bus and PID 1 must be guest-local. Keep console management
|
||||
independent of the VPN under test. Tunnel probes target only synthetic
|
||||
addresses reachable through the test tunnel, never the NAT management path.
|
||||
- Provision only fixed, reviewed lab units/drop-ins and fixture hooks. Imported
|
||||
configuration text remains data. Preserve administrator settings and prove
|
||||
preflight refusal using synthetic fixtures rather than silently rewriting
|
||||
`SaveConfig`.
|
||||
- Exercise the public `Application::restart` flow with the real zbus service
|
||||
adapter and Rustix observer. A test seam may inject persistence failures only
|
||||
where already established; it may not replace service submission or runtime
|
||||
observation as evidence of systemd behavior. Alpine fixture apply remains
|
||||
separate evidence.
|
||||
- Run the actual release executable separately from any `cfg(test)` driver.
|
||||
Capture source/dirty identity, compiler/locked dependencies, target, artifact
|
||||
SHA-256, and dynamic-library requirements for both artifacts. Prove each
|
||||
executable starts in the guest.
|
||||
- Keep secrets out of argv, environment variables, image metadata, logs,
|
||||
transcripts, and evidence. Use bounded typed control, private files/channels,
|
||||
monotonic timeouts, and bounded output. A timeout after service submission
|
||||
means it may still complete; it is not cancellation.
|
||||
- Before every scenario, reset to a recorded clean guest state and verify both
|
||||
the pinned host identity and guest marker. Afterward, stop only resources
|
||||
created by that run and verify their exact IDs. If cleanup is uncertain,
|
||||
preserve IDs and report them; never use broad host cleanup. Preserve the
|
||||
original failure if cleanup also fails.
|
||||
|
||||
## Required scenario evidence
|
||||
|
||||
For every run, record UTC start/end, guest and base identities, kernel/systemd/
|
||||
D-Bus/WireGuard versions, both marker results, source/artifact identities, exact
|
||||
commands, exit status and bounded redacted outcomes, backup/archive identities,
|
||||
reset/cleanup result, and the acceptance criteria supported. Keep raw console
|
||||
output private; evidence must contain no secrets or complete client config.
|
||||
Record each scenario independently so failures cannot be masked by unrelated
|
||||
fixture behavior.
|
||||
|
||||
| Scenario | Required observable evidence |
|
||||
| --- | --- |
|
||||
| Successful apply and fast job | Existing synthetic client has positive tunnel-only IPv4 traffic. Through one `Application`, create and Commit a client; show saved/runtime divergence and that Commit did not submit a service job. Cancel preview with no backup/job/mutation. Confirm restart: pre-restart backup completes first, real zbus job for the resolved primary unit reaches `done`, Rustix observes the expected interface and complete matching peer/routes, and durable verification is written. Verify new tunnel traffic, reopen and show verification is historical. Repeat with fast completion and a configured alias resolving to the primary unit; retain the completion signal. Include application outcomes, not journal inference alone. |
|
||||
| Deletion and reservations | Establish positive traffic before deleting/Committing. Show the client remains in runtime and its address remains reserved until successful real restart and complete verification. Then show it absent, tunnel-only traffic fails within a bounded probe, durable reservation releases, and old backups remain. |
|
||||
| Failure and deadline | Using fixed guest-only units/drop-ins, show terminal job failure and submitted-job deadline expiry with bounded results, accurate submitted/may-still-complete status, no automatic retry/rollback, no false Verified state, and retained reservations. Also show unresolved unit fails before submission and backup failure prevents restart. After late completion, explicitly observe and reset the guest; timeout is not cancellation. |
|
||||
| Job succeeds, verification fails | A dedicated fixed fixture service completes while leaving the interface absent or peers mismatched. Show the real adapter reports verification failure and reservations remain. Separately inject saved-state write failure only through the established persistence seam after real restart/observation; show runtime changed without durable Verified state and `RecoveryRequired` blocks unsafe mutations. |
|
||||
| Restore and manual recovery | Preview and confirm a known-good archive without vault password. Show precautionary backup, full saved settings/config/vault replacement, and unchanged runtime before explicit restart. Then run real restart/verification and expected tunnel check. Demonstrate documented manual recovery; no hidden rollback. Confirm permissions and reopen behavior. |
|
||||
| Release executable smoke | In guest console/PTY, prove identified release binary starts from its private directory and settings resolve beside it. Exercise current-client/statistics view and one controlled Commit/restart through the real worker path; confirm exit/cancel restores terminal. Help/version work as non-root without settings; non-root management is rejected. Record output/backup permissions. |
|
||||
|
||||
The final M9 evidence must map these observations to M9-A1–A9 and the
|
||||
specification-to-evidence matrix. State the tested scope, including IPv4 tunnel
|
||||
coverage, synthetic dual-stack tests, unverified real IPv6 behavior, and
|
||||
QR/device interoperability limits. A mocked bus, Alpine fixture apply, missing
|
||||
prerequisite, skipped test, or guest boot alone does not satisfy real systemd
|
||||
acceptance.
|
||||
|
||||
## Next action
|
||||
|
||||
None while Milestone 9 is paused by the user. The disposable VM is powered off;
|
||||
do not resume installation, provision it, or run acceptance without a new
|
||||
explicit request. No M9-A1–A9 check is complete.
|
||||
@@ -0,0 +1,285 @@
|
||||
# Domain and application refactoring contract
|
||||
|
||||
Status: integrated architecture map and handoff after M26. M20–M26 moved the
|
||||
responsibilities below to their current owners. This document records verified
|
||||
source ownership and behavioral proof; it does not authorize behavior,
|
||||
file-format, dependency, or product changes. M27 review and acceptance remain
|
||||
pending.
|
||||
|
||||
## Authority and boundaries
|
||||
|
||||
The server configuration is authoritative for server peers and their managed
|
||||
descriptive metadata. `ConfigDocument` preserves its bytes and exposes every
|
||||
peer section, even when a section has no usable public key or cannot be
|
||||
represented by the supported client model. `ClientStates` is a restricted
|
||||
projection used for client operations; it is not a second peer registry. The
|
||||
vault is authoritative for encrypted client secrets, and the saved files are
|
||||
authoritative for persisted state. Runtime reads are observations, not desired
|
||||
configuration. Historical successful verification is not current verification.
|
||||
|
||||
The dependency direction remains:
|
||||
|
||||
```text
|
||||
TUI -> application facade / application read model -> domain rules
|
||||
| -> config document/editor
|
||||
| -> vault/import/delivery
|
||||
| -> backup/persistence
|
||||
| -> runtime/service adapters
|
||||
```
|
||||
|
||||
Domain rules do not read files, parse server configuration, perform cryptography,
|
||||
call the runtime/service, or render terminal text. The application coordinates
|
||||
these owners. The TUI submits typed requests and displays secret-free facts; it
|
||||
does not authorize business operations or parse saved configuration. The
|
||||
document editor never rebuilds the server file from a client snapshot.
|
||||
|
||||
## Reading guide: common workflows
|
||||
|
||||
These paths identify the application entry, the rule owner, the side-effect
|
||||
boundary, and tests that exercise failures as well as success.
|
||||
|
||||
### Create, Commit, first delivery, explicit restart
|
||||
|
||||
- **Entry:** TUI `UiCommand::CreateClient`, `Commit`, `DeliverFirstIssue`, and
|
||||
`PreviewRestart` followed by the explicit restart choice; the adapter is
|
||||
`src/tui/events/executor.rs`. The facade methods are
|
||||
`Application::prepare_new_client`, `commit`, `deliver_first_issue_file`,
|
||||
`preview_restart`, and `restart`.
|
||||
- **Rule owners:** `app/draft.rs` coordinates the draft; `app/commit.rs` owns
|
||||
Commit; `app/first_delivery.rs` owns the one-use session capability and
|
||||
retries; `app/first_file.rs` owns first-file output; `app/restart.rs` owns
|
||||
backup, job completion, comparison and durable apply publication. The app
|
||||
read model reports eligibility; the TUI does not grant it.
|
||||
- **Effect boundary:** creation changes memory only. Commit backs up and installs
|
||||
saved files/vault, without a runtime call. First delivery writes the selected
|
||||
client file and may display its QR. Only the later explicit restart may call
|
||||
the service/runtime adapters; addresses and current applied knowledge change
|
||||
only after complete verification and durable bookkeeping.
|
||||
- **Failure proof:** `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`,
|
||||
`app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it`,
|
||||
`app::failed_file_publication_can_retry_in_a_different_directory`,
|
||||
`app::restart_backup_failure_preserves_draft_and_submits_no_job`,
|
||||
`app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`.
|
||||
|
||||
### Delete, Commit, verified address release
|
||||
|
||||
- **Entry:** `UiCommand::DeleteClient`, then `Commit`, then `PreviewRestart`
|
||||
and a confirmed restart choice through the executor; facade methods are
|
||||
`Application::delete_client`, `commit`, and `restart`.
|
||||
- **Rule owners:** `app/draft.rs` stages peer removal; `ConfigDocument` in
|
||||
`wireguard/document.rs` and `wireguard/edit.rs` owns peer identity and
|
||||
byte-preserving edits; `domain.rs` owns pending/committed reservations and
|
||||
allocation; `app/commit.rs` persists the deletion; `app/restart.rs` publishes
|
||||
release only after `runtime.rs` verifies the full saved peer set and durable
|
||||
state install succeeds.
|
||||
- **Effect boundary:** delete is draft-only. Commit backs up and changes saved
|
||||
files/vault but leaves the running interface unchanged. The address remains
|
||||
reserved through any restart failure or mismatch and is released only after
|
||||
verified publication.
|
||||
- **Failure proof:** `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`,
|
||||
`app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change`,
|
||||
`commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`,
|
||||
`app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`.
|
||||
|
||||
### Preview and confirm restore
|
||||
|
||||
- **Entry:** TUI `UiCommand::PreviewRestore`, then `ConfirmRestore` or cancel;
|
||||
`src/tui/events/executor.rs` binds the preview to its worker generation.
|
||||
Facade methods are `Application::preview_restore` and `confirm_restore`.
|
||||
- **Rule owners:** `app/restore.rs` validates the archive, rebuilds internal
|
||||
reservations and coordinates confirmed replacement; `backup.rs` owns archive
|
||||
validation/encoding; `persistence.rs` owns safe installation and recovery.
|
||||
The selected archive's settings, configuration and vault remain authoritative
|
||||
for restored saved state.
|
||||
- **Effect boundary:** preview and cancel are read-only. Confirmation requires a
|
||||
fresh precautionary backup, then replaces saved files/vault as a whole. It
|
||||
resets current apply knowledge to Unknown and does not call the running VPN or
|
||||
request a vault password.
|
||||
- **Failure proof:** `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`,
|
||||
`app::restore_accepts_changed_non_path_settings_and_rejects_changed_paths`,
|
||||
`app::restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`,
|
||||
`commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`,
|
||||
`events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`.
|
||||
|
||||
## Current concepts and migration map
|
||||
|
||||
References use current repository-relative module paths. The M19.1 map is
|
||||
resolved below against the integrated M20–M26 implementation. Test names point
|
||||
to existing behavioral coverage; this documentation update does not claim those
|
||||
tests were rerun.
|
||||
|
||||
### Application session fields and workflows
|
||||
|
||||
| Current type / field / function | Business meaning | Authority | Current owner | Readers / writers | Existing proof | Resolution |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `Application::store` (`src/app.rs`) | Saved-file boundary, configured paths, validation and install capability | `SavedStore` paths and installed files | Application facade; scenario owners in `app/commit.rs`, `app/restore.rs`, `app/backups.rs`, `app/restart.rs` | Read by application scenarios; saved files change only through saved-operation owners | `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`; persistence `complete_install_publishes_components_with_private_modes` | Resolved M23/M25 |
|
||||
| `Application::saved` (`src/app.rs`) | Exact installed generation held by this session | `SavedBytes` read from `SavedStore` | Application session; bound contexts in `app/restore.rs` and `app/restart.rs` | Open/read, Commit, restore, verified bookkeeping, restart preview | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it` | Resolved M23/M25 |
|
||||
| `Application::draft` (`src/app.rs`, `src/app/draft.rs`) and its document | Coordinated in-memory document, client projection, vault, and draft-only reservations | Authoritative server document plus encrypted vault; `ClientStates` is a restricted projection | `app/draft.rs` stages and publishes coordinated replacements; `wireguard/edit.rs` owns byte-preserving edits | Draft operations prepare replacements; Commit serializes the staged values | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `config_preservation::edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed` | Resolved M22 |
|
||||
| `Application::states` (`src/domain.rs`) | Narrow draft/saved management snapshots, current apply knowledge, and address reservations | Document projection for manageable clients plus persisted internal state | Domain `ClientStates` and `AddressReservations`; persistence only encodes/decodes durable fields | App draft/Commit/restart/reopen; read model exposes secret-free facts | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `persistence::persisted_history_never_marks_the_runtime_verified` | Resolved M20–M22 |
|
||||
| `Application::draft.vault` (`src/app/draft.rs`) | Encrypted vault draft used for secret encryption, import, deletion, and reissue | Installed vault bytes at open, then current app draft | `app/draft.rs` coordinates; cryptographic/value operations stay in `vault.rs` and `vault/import.rs` | App setup/import/create/delete/Commit/reissue | `app::selected_file_import_uses_the_existing_vault_draft_without_saving`; `app::deletion_only_removes_selected_current_record_and_retains_other_records`; `vault::encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password` | Resolved M22/M24 |
|
||||
| Pending exact-host reservations (`ClientStates::address_reservations`, `src/domain.rs`) | Draft-only exact host addresses retained when a saved peer cannot project to `Client` | Saved document peer routes and pending deletion | Domain `AddressReservations`, coordinated by `app/draft.rs` | Delete adds; Commit persists/promotes; discard clears | `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` | Resolved M21/M22 |
|
||||
| `historical_apply` | Last successfully persisted verification timestamp/status, for archive/history display | `PersistedState.last_verified_at_utc` and archive manifest | Persistence codec owns representation; application read model presents it | Open/backup/restore/restart bookkeeping | `persistence::persisted_history_never_marks_the_runtime_verified`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied`; `app::historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen` | Resolved M21/M23/M26 |
|
||||
| `verified_saved_projection` | Saved generation verified by the current session, used for delivery activation warning | Successful full runtime comparison bound to saved bytes | Application session/restart scenario; read-only view exposes only safe status | Restart publication writes; delivery warning reads; edits/restore invalidate as appropriate | `app::first_issue_warning_tracks_verified_saved_generation_for_file_and_qr`; `app::historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen` | Resolved M21/M24–M26 |
|
||||
| `active_restart_attempt`, `next_restart_attempt` | One-use authorization that binds verification publication to the confirmed attempt and saved generation | Application-created attempt ID plus saved-byte equality | Private `app/restart.rs` scenario context | Preview/confirm/restart lifecycle; verified persistence consumes it | `app::restart_confirmation_rejects_a_generation_changed_after_preview`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` | Resolved M20/M25 |
|
||||
| `FirstDeliverySession` (`src/app/first_delivery.rs`) | Creation-session-only first-delivery eligibility, one-use authorization and retry state | Generated key held in memory, committed peer directives and encrypted record binding | `app/first_delivery.rs` owns session capability state; `app/first_file.rs` runs first-file output; vault owns ciphertext/crypto and delivery adapters own publication | Coordinated creation registers; Commit promotes; file delivery consumes or updates; delete/discard/invalidation removes | `app::first_issue_authorization_is_single_flight_and_success_consumes_it`; `app::deletion_and_discard_revoke_only_session_bound_initial_material`; `app::uncertain_first_file_publication_retains_warning_and_skips_qr` | Resolved M20/M22/M24 |
|
||||
| `open_from_saved_store`, `open` (`src/app.rs`) | Construct a session from installed saved bytes; injected-state `open` exists for fixture/state-seeding callers. Reopen is Unknown regardless of history. | Installed files/document; persisted reservations/history | Application session constructors | Production startup uses `open_from_saved_store`; `open` is still called by library unit and integration fixtures | `startup::management_session_loads_supported_state_and_keeps_unsupported_peers_visible`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` | Resolved M22; retained fixture API, see caller audit |
|
||||
| `prepare_new_client`, `add_client`, `rename_client`, `delete_client`, `discard_draft` (`src/app.rs`) | Stage client/document/vault changes without saved-file or runtime effects; `add_client` is a legacy fixture helper and does not issue a key/vault secret | Document plus restricted client projection and vault | `app/draft.rs`, application facade and domain/editor/vault owners; no separate `app/clients.rs` was needed | TUI uses prepared create/rename/delete/discard; test fixtures still call `add_client` | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `client_delivery::committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent`; `client_delivery::deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password` | Resolved M22; retained fixture helper, see caller audit |
|
||||
| `commit`, `preview_restore`, `confirm_restore` (`src/app.rs`) | Commit atomically installs staged saved bytes after backup; restore is a preview-bound full saved-state replacement after backup | App draft for Commit; validated archive payload for restore | `app/commit.rs`, `app/restore.rs`; low-level formats/install remain in `backup.rs`/`persistence.rs` | TUI executor calls facade; store installs only with `BackupComplete` | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes` | Resolved M23 |
|
||||
| `preview_restart`, `restart`, verified-result publication (`src/app.rs`, `src/app/restart.rs`) | Confirmed restart scenario: preconditions, backup, service job completion, runtime comparison, durable status then publish applied knowledge | Saved generation + complete `SavedPeerProjection` + runtime observation | `app/restart.rs`; service submission in `service.rs`; comparison in `runtime.rs`; durable fields in persistence | TUI previews/confirms; fixture apply callback is `#[cfg(test)]`; success updates state only after durable write | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::service_timeout_reports_submission_and_may_still_complete`; `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified` | Resolved M20/M21/M25 |
|
||||
|
||||
### Domain state and transitions
|
||||
|
||||
| Current type / field / operation | Business meaning | Authority | Current owner | Readers / writers | Existing proof | Resolution |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `Client` / `ClientAddresses` / `Snapshot` (`src/domain.rs`) | Public, manageable projection keyed by public key; aliases may repeat, IPv6 may be absent for legacy clients | Public key and supported host-address projection from document | Domain value types in `domain.rs`; no child module split was needed | App operations and read model; draft/saved snapshot transitions | `domain::aliases_may_repeat_but_control_characters_are_rejected`; `domain::existing_ipv4_only_clients_remain_ipv4_only`; `wireguard::wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes` | Resolved M20/M21 |
|
||||
| `ClientStates::draft` / `saved` (`src/domain.rs`) | Manageable-client snapshots for staged and committed state, not all server peers | Document is source; snapshots are derived | Domain client states, mutated only through narrow transitions | Application edits draft and Commit advances saved; TUI consumes read-model facts | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes` | Resolved M21/M22 |
|
||||
| `applied_snapshot` / `ApplyStatus` | Optional management snapshot last verified plus current knowledge; may be `None` while full technical verification is Verified for valid unmanageable peers | Current verified runtime comparison; historical timestamp is separate | Domain apply knowledge; technical evidence remains runtime-owned | Restart publication changes; reopen resets current knowledge; read model maps current knowledge | `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` | Resolved M20/M21 |
|
||||
| `reserved_addresses` and `restore_persisted_reservations` (`src/domain.rs`) | Persisted conservative addresses that cannot be allocated | Prior saved peer routes and internal state | `domain.rs` owns reservation value/rules; `persistence.rs` only encodes/decodes | Allocator reads; Commit/reopen/restart update; restart success may release | `domain::a_draft_only_deletion_does_not_create_a_committed_reservation`; `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `persistence::state_codec_is_canonical_and_rejects_bad_or_unsupported_state` | Resolved M21 |
|
||||
| Narrow `stage_client`, `remove_draft_client`, `commit_draft`, `discard_draft` (`src/domain.rs`) | Mutate the manageable draft projection through scoped operations and preserve saved/applied separation | `ClientStates` snapshots, ultimately document-derived | Application draft coordinator calls crate-private stage/remove and public commit/discard transitions | No unrestricted `draft_mut` accessor remains; direct mutation in domain tests is limited to transitions | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::discard_restores_draft_without_writing_installed_bytes`; `app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it` | Resolved M21/M22; escape hatch removed |
|
||||
| Verified apply, technically verified apply, runtime unknown/unavailable transitions (`src/domain.rs`) | Change present apply knowledge only after verified result publication; preserve current uncertainty and optional management projection | Complete runtime comparison plus successful persisted bookkeeping | Domain transitions are crate-private and called by the application restart scenario | App restart success/failure/reopen; statistics reads cannot call transitions | `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`; `tui::statistics_refresh_is_generation_bound_and_never_changes_applied_state` | Resolved M20/M21 |
|
||||
| `allocate_ipv4`, `allocate_ipv6`, `AddressAvailability` (`src/domain.rs`) | Pure address allocation against server, occupied and reserved sets | Validated settings and document-derived address sets | Domain address allocator and reservations | App create reads | `domain::ipv4_allocation_skips_server_occupied_reserved_network_and_broadcast`; `domain::ipv6_allocates_first_free_address_from_a_very_large_range`; `tui_workflow::create_form_submits_automatic_dual_stack_client_without_changing_saved_state` | Resolved M21 |
|
||||
|
||||
### Peer and runtime projections
|
||||
|
||||
| Current projection | Business meaning / authority | Current owner and consumers | Existing proof | Resolution |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `PeerInspection`, `PeerIssue`, `DocumentIssue`, `ConfigDocument` (`src/wireguard/document.rs`) | Every authoritative server peer section, byte spans, parsed routes and anomalies. Identity can be absent or duplicated. Malformed/no-key peers remain inspectable by document position. | `wireguard/document.rs`; app editing/deletion/restart and application read model consume it | `wireguard::wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields`; `tui::malformed_peer_can_be_inspected_by_document_location_without_mutation_identity`; `tui::duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable` | Resolved M22/M25/M26 |
|
||||
| `ClientDisplay`, `SessionDisplay`, `ApplicationView` (`src/app/read_model.rs`) | Secret-free current draft-document rows and authoritative per-action eligibility; mutable identity only for an unambiguous manageable peer | App-owned read model; `Application` facade exposes it | `tui::enter_opens_an_unsupported_peer_for_inspection_without_mutation_identity`; `tui::issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Resolved M26 |
|
||||
| `SessionView`, `ClientStateRow`, `VisibleRecord` (`src/tui/state.rs`) | Maps `ApplicationView` into UI navigation state; keeps generation, selection, screens and rows, without reparsing saved bytes or constructing authority | TUI state consumes app facts and owns UI selection/navigation | `tui_workflow::saved_peer_projection_counts_legacy_peers_without_inventing_managed_state`; `tui::keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them`; `tui::refresh_preserves_selection_by_public_key_not_alias_or_row_position` | Resolved M26 |
|
||||
| Renderer-derived labels and issue text (`src/tui/render.rs`) | Styles and renders app facts; no operation eligibility or peer authority is inferred from labels | Renderer owns visual strings, styling, wrapping, geometry and layout | `tui::persistent_details_follow_the_highlight_and_keep_geometry_fixed`; `tui::client_rows_show_draft_state_and_escape_anomalous_unsupported_peers`; `tui::issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Resolved M26 |
|
||||
| `SavedPeerProjection`, `RuntimeObservation`, `TechnicalMatch` (`src/runtime.rs`) | Complete technical route set independent of manageable `Client`; valid subnet-route peers can be restart-verified without UI mutability. Handshakes/counters are not apply evidence. | Runtime owns projection/comparison and read-only transport; app restart scenario coordinates; read model exposes only safe status/findings | `runtime::projection_keeps_valid_unsupported_routes_and_ipv4_only_peers`; `runtime::counters_and_handshake_changes_do_not_change_technical_match`; `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot` | Resolved M20/M25/M26 |
|
||||
|
||||
### Authorization, prepared contexts and worker generations
|
||||
|
||||
| Current context | Meaning and authority | Current owner | Readers / writers | Existing proof | Resolution |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| UI selection guard `UiState::selected_client_mutable` (`src/tui/state.rs`) | Presentation-level guard requires one matching row, matching document position if selected, and a mutable identity; cannot grant operation authority | TUI selection state only; application owns typed per-action eligibility and execution revalidation | Terminal key handlers and footer hints read it; app methods/editors independently reject invalid actions | `tui::selection_uses_peer_position_for_duplicate_keys_and_disables_mutation`; `tui::removed_saved_client_is_visible_and_never_mutable` | Resolved M26 |
|
||||
| Per-action UI prerequisites (`src/tui/terminal.rs`, `src/app/read_model.rs`) | Selection is presentation-only; rename/delete/delivery/reissue availability comes from typed app eligibility, and each operation revalidates on execution | App read model owns availability facts; terminal maps them to actions and hints | `tui::state` binds selected row; app methods and config editor validate mutation | `tui::detail_hints_only_offer_saved_delivery_for_mutable_saved_selection`; `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` | Resolved M26 |
|
||||
| First-issue session context (`src/app/first_delivery.rs`, `src/app/first_file.rs`) | Session-local secret and one-use authorization bound to identity, peer directives and encrypted record; retry state reflects publication/cleanup uncertainty | `app/first_delivery.rs` owns session lifecycle; `app/first_file.rs` owns first-file scenario; adapters own crypto and publication | Create registers; Commit enables; delivery validates/consumes; edits/delete/discard invalidate | `app::committed_first_issue_publishes_selected_file_and_consumes_eligibility`; `app::published_file_is_reported_when_saved_store_bookkeeping_fails`; `app::failed_first_delivery_requires_consuming_capability_before_retry`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry` | Resolved M20/M24 |
|
||||
| `ApplicationExecutor` prepared destination and restore preview (`src/tui/events/executor.rs`) | Worker-owned continuation state bound to target, delivery mode/QR choice, attempt, preview and request generation | Private named contexts in the executor child | Executor command handling; clears on unrelated actions, consumes on confirmation/cancel | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `client_delivery::overwrite_confirmation_is_bound_to_the_selected_destination`; TUI workflow restore cancellation/confirmation | Resolved M20; both contexts use named structs |
|
||||
| `PendingOperation`, `SessionView.generation`, worker generation (`src/tui/events.rs`, `src/tui/state.rs`) | Monotonic identity for submitted work and returned view; stale view/statistics cannot overwrite current UI state | TUI worker/execution adapter only; not domain state | Mutating submit increments; worker tags result; UI accepts matching generations; periodic statistics rechecks generation across read | `events::delayed_statistics_are_bounded_and_do_not_advance_generation`; `events::statistics_started_before_a_mutation_are_discarded_as_stale`; `tui::stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable` | Resolved M20/M26 |
|
||||
| `FormController`, `SecretInput`, `SecretText`, `SensitiveCommand` (`src/tui/forms.rs`, `src/tui/events.rs`) | Short-lived form state and zeroizing password command; secrets excluded from view/debug/clone | Forms own input; worker carries secret command; application consumes only during vault operation | Form submits sensitive command; worker invokes setup/reissue; form clears on cancel/consumption | `tui::compact_form_hints_follow_the_active_form_without_leaking_input`; `vault::every_retrieval_requires_the_correct_password`; `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen` | Resolved M24/M26; lifetimes preserved |
|
||||
|
||||
### Public API escape-hatch callers and removal conditions
|
||||
|
||||
| API | Actual callers | Safe path and timing |
|
||||
| --- | --- | --- |
|
||||
| `ClientStates::draft_mut` | No current declaration or callers. Former unrestricted accessor was replaced by crate-private `stage_client`/`remove_draft_client` and narrow transitions in `src/domain.rs`; full `rg` audit finds no production, test, startup, binary, integration or container-driver reference. | Removed in M22 after caller audit; no replacement is needed by callers. [M22 evidence](../evidence/milestone-22.md) records the review. |
|
||||
| `Application::vault_draft_mut` | No current declaration or callers. The public mutation bypass was removed and no test-only API replaced it. | Removed in M22; vault state is mutated through coordinated application operations. [M22 evidence](../evidence/milestone-22.md) records the review. |
|
||||
| `Application::add_client` (`src/app.rs`) | Unit-test setup in `src/app.rs`; integration-fixture setup in `tests/commit_restore.rs` and `tests/runtime_restart.rs`. No binary, startup, production module, `tests/support`, or `src/container_driver.rs` caller. `ConfigDocument::add_client` remains the actual byte-preserving editor used by `prepare_new_client` and preservation tests. | Retained as a public compatibility/test-seeding operation because current integration fixtures still use it. It does not generate/encrypt a private key and must not be mistaken for the normal create workflow. Removal requires deliberate fixture migration. |
|
||||
| `Application::open(store, states)` (`src/app.rs`) | Unit tests and fixture helpers in `tests/client_delivery.rs`, `tests/commit_restore.rs`, `tests/runtime_restart.rs`, and `tests/tui_workflow.rs`. No binary/startup or production caller; no container-driver caller. | Retained for tests that seed `ClientStates` and apply knowledge. Production startup uses `open_from_saved_store`; fixture construction is the only current consumer class. |
|
||||
| `Application::open_from_saved_store` (`src/app.rs`) | Binary startup through `startup::open_management_session`; application/TUI/integration tests; `container_driver::open_fixture_application` in `src/container_driver.rs`, which is included only under `#[cfg(test)]` in `src/lib.rs`. | Required production and fixture entry point; retain. Container driver normal tests are synthetic; its ignored Alpine scenario is opt-in and is not a systemd acceptance test. |
|
||||
| `Application::display_projection` (`src/app.rs`) | No production-module, binary, or actual startup-path call. `startup::tests::management_session_loads_supported_state_and_keeps_unsupported_peers_visible` calls it after exercising session startup. There are no `tests/` integration-test calls. The only other direct callers are in `src/container_driver.rs`'s `#[cfg(test)]` synthetic lifecycle test `phase_protocol_keeps_creation_commit_and_first_delivery_in_one_app_session`; the ignored Alpine scenario does not call it. Current TUI presentation uses `Application::read_model`. | Retain for now as a public compatibility projection: the startup unit and synthetic fixture tests still exercise its inclusion of unsupported peers. Its compatibility value and test coverage do not make it a production authorization/read path. Removal requires deliberate migration of those callers and an API compatibility decision; do not infer it is obsolete solely because M26 moved TUI reads to `read_model`. |
|
||||
| Former public `Application::create_pre_restart_backup` (`src/app.rs`) | Direct callers are confined to colocated app unit tests: helper `test_mark_verified`, `manual_and_pre_restart_backups_are_listed_without_runtime_calls`, and `failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified`. No binary, integration-test, or container-driver caller. | Restricted to a private `#[cfg(test)]` method because only fixtures use it. Production restart performs its mandatory backup within `app/restart.rs`; no production API exception remains. |
|
||||
|
||||
## Transition tables
|
||||
|
||||
These tables preserve observed behavior. “No mutation” below means no protected
|
||||
saved-file or runtime mutation; drafts may remain in memory where specified.
|
||||
|
||||
### Draft lifecycle
|
||||
|
||||
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Open session | Saved settings/config validate; optional state/vault parse | Draft begins from saved document and client snapshot; current apply knowledge is Unknown | Read installed bytes, restore conservative reservations, load history separately | Invalid saved state rejects open; history never promotes current apply knowledge | `startup::management_session_loads_supported_state_and_keeps_unsupported_peers_visible`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` |
|
||||
| Create client | Vault exists; alias/address inputs valid; configured ranges available | Document/client/vault drafts all contain new peer/record; first-issue material registers only after staging succeeds | Generate key, allocate/check dual stack, encrypt record, write no files | Any preparation failure leaves all live draft components and issue rights unchanged | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `app::manual_addresses_reject_occupied_and_out_of_range_without_partial_staging` |
|
||||
| Rename or delete | Target identity resolves to an authoritative unambiguous peer; deletion of unprojectable peer can reserve only exact host routes | Corresponding document and manageable snapshot edit; delete removes its vault record if present | Mutate in-memory drafts; retain exact unsupported routes pending Commit; invalidate relevant issue eligibility | Malformed/ambiguous/unsafe broad-route edit fails without partial draft change | `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change`; `app::deletion_only_removes_selected_current_record_and_retains_other_records` |
|
||||
| Discard draft | Session can reread saved bytes | Draft returns to installed saved state | Reparse installed document/vault, restore persisted reservations; retain only still-valid awaiting-first-file issue material | Read/parse failure returns error; no installed writes; already-consumed delivery is not recreated | `app::discard_restores_draft_without_writing_installed_bytes`; `app::deletion_and_discard_revoke_only_session_bound_initial_material` |
|
||||
| Commit | Draft validates; mandatory pre-Commit archive succeeds | Saved state advances to installed draft only after successful recoverable install | Persist document, encrypted vault and internal reservations; no runtime call; apply knowledge becomes unknown when saved differs | Backup failure stops before protected writes; partial install reports failure/recovery; draft remains retryable | `app::backup_failure_preserves_installed_files_and_draft`; `app::injected_archive_and_install_failures_preserve_bytes_and_session_saved_state`; `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes` |
|
||||
| Restart with draft | Valid saved-state restart preview; explicit choice | Cancel keeps draft; discard choice resets it before restart sequence | Separate restart flow; Commit never applies | Cancel makes no backup/job/runtime call. Once service request is submitted, cancellation/timeout cannot assert that the job did not finish | `app::restart_cancel_preserves_draft_and_never_calls_adapters`; `app::service_timeout_reports_submission_and_may_still_complete` |
|
||||
|
||||
### Address reservations
|
||||
|
||||
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Delete saved manageable client in draft | Target's addresses known | No committed reservation yet; addresses remain occupied by saved snapshot | Draft deletion only | Discard restores client and no pending reservation is published | `domain::a_draft_only_deletion_does_not_create_a_committed_reservation` |
|
||||
| Delete saved unprojectable peer in draft | Every route to be retained is an exact IPv4 `/32` or IPv6 `/128` host route | Exact routes enter draft-only reservation set | Prevent allocation while deletion is staged | Broad/subnet route or missing address evidence rejects deletion without draft mutation | `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` |
|
||||
| Commit deletion | Mandatory backup and saved install succeed | Deleted saved addresses enter persisted reservation set; committed client snapshot advances | Allocator excludes current draft/saved and reserved addresses | Backup/install failure does not publish release or new saved state | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::state_file_round_trip_keeps_pending_addresses` |
|
||||
| Restore archive removing current peers | Preview is valid and confirmation accepted; pre-restore backup succeeds | Rebuilt internal state carries archived/current persisted reservations, addresses from current manageable clients, and exact host routes from unsupported current peers; reservations are excluded for addresses still assigned by restored peers | Full saved replacement only; runtime is untouched | Cancel leaves saved state unchanged; backup/install failure leaves recoverable prior state and drafts | `commit_restore::restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes`; `app::restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `app::partial_restore_failure_preserves_session_and_pre_restore_backup_then_reopens_to_old_set` |
|
||||
| Verified restart publication | Confirmed attempt still matches installed generation; completed service job; complete interface/peer match; internal-state install succeeds | Reservations for removed clients clear | Persist verified timestamp/state, then publish current applied knowledge | Mismatch/read/service/timeout/persistence failure retains reservations and conservative apply knowledge | `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` |
|
||||
|
||||
### Current and historical verification
|
||||
|
||||
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Open/reopen | Saved configuration and internal state parse | Current state Unknown; optional management applied snapshot absent; historical timestamp remains separately available | Restore reservations from persisted metadata only | Invalid internal state rejects session; historical success is not live proof | `persistence::persisted_history_never_marks_the_runtime_verified`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` |
|
||||
| Periodic statistics read | Worker has an interface name; bounded read returns observation | UI statistics update only | No domain apply transition, no saved write, no reservation release | Unavailable/unknown data remains so; stale-generation result is discarded | `tui::events::statistics_started_before_a_mutation_are_discarded_as_stale`; `tui::state::statistics_refresh_is_generation_bound_and_never_changes_applied_state` |
|
||||
| Saved state changes | Commit/restore changes installed generation | Current apply knowledge becomes Unknown; old historical timestamp may remain or be cleared according to existing successful restore bookkeeping | No runtime operation | Failed write leaves in-memory published state conservative | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it` |
|
||||
| Restart comparison matches complete saved technical peer set | Interface identity and complete peer dump match; malformed/duplicate/unverifiable records absent | Still not published until durable verification bookkeeping succeeds | Prepare persisted reservations/timestamp update | Persistence failure leaves in-memory apply knowledge/reservations unchanged | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified` |
|
||||
| Full technical match with peer outside management projection | Peer record is valid/verifiable even if not a `Client`, e.g. subnet routes | Knowledge may be Verified with no complete `applied_snapshot` | Persist verified result; do not invent partial management snapshot | Any incomplete runtime dump still cannot pass | `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot`; `runtime::projection_keeps_valid_unsupported_routes_and_ipv4_only_peers` |
|
||||
|
||||
### First delivery and password reissue
|
||||
|
||||
| Trigger / stage | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Create before Commit | Successful coordinated create in this session | Pending draft issue | Hold generated private key in session and bind peer directives/ciphertext | Any failed create publishes no new issue authorization | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue` |
|
||||
| Commit succeeds | Created peer and encrypted record appear in installed state and match session context | Awaiting first file | Promote one-use capability for current session | Commit failure keeps retryable draft capability but does not permit output | `app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it` |
|
||||
| Prepare destination | Eligible committed issue or saved reissue selected; output path validated | Prepared destination is bound to identity, mode, QR request, attempt and generation | No key disclosure or file write yet | Unrelated command/cancel invalidates the prepared context; stale generation requires reselection | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `client_delivery::overwrite_confirmation_is_bound_to_the_selected_destination` |
|
||||
| Confirm first file | Same session, context still matches saved record, overwrite confirmation if required | File is published; first issue is consumed only after saved bookkeeping | Secure file publication; optional QR derives from the same payload and happens after mandatory file | Before-publication failure can retry; uncertain publication requires overwrite confirmation; cleanup failure requires manual cleanup; QR failure does not undo file | `app::failed_file_publication_can_retry_in_a_different_directory`; `app::uncertain_first_file_publication_retains_warning_and_skips_qr`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry`; `app::file_and_qr_share_payload_and_qr_failure_still_consumes_first_issue` |
|
||||
| Saved bookkeeping after file | File publication succeeded | Consumed if bookkeeping succeeds; otherwise explicit published-but-uncertain result, no automatic retry capability | Report file exists; preserve activation warning | Do not misreport as failed/unpublished or auto-delete file | `app::published_file_is_reported_when_saved_store_bookkeeping_fails` |
|
||||
| Reissue after session/reopen | Selected client is in saved state; password supplied for this action | No first-issue session eligibility is involved | Unlock matching saved vault record, render/write selected client file; optional QR | Wrong password/record mismatch/file failure is reported without secret leakage; each client requires fresh password | `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen`; `vault::every_retrieval_requires_the_correct_password`; `client_delivery::imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue` |
|
||||
|
||||
### Restore preview and confirmation
|
||||
|
||||
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Select archive for preview | Safe archive name and valid archive format | Opaque preview holds validated bytes plus metadata; UI receives redacted summary/change list and generation | Read-only archive validation; no password and no runtime call | Invalid/traversal archive rejected; cancel drops preview | `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `commit_restore::archive_parser_rejects_traversal_links_duplicates_and_oversized_members` |
|
||||
| Confirm matching preview | Preview token still belongs to the operation generation; installation paths remain compatible | Selected archive replaces saved settings, server configuration and vault as a whole after mandatory pre-restore backup; running VPN remains unchanged | Rebuilt internal state carries archived and current persisted reservations, addresses of current manageable clients and unsupported peers' exact host routes that the restore removes; addresses still assigned by the restored configuration are excluded. Draft-only exact routes are included. The rebuilt state has no historical verification timestamp; current apply knowledge becomes Unknown. No restart occurs. The pre-restore backup retains the prior saved bytes and history. | Stale/cancelled preview cannot write; backup/install failure preserves prior saved state or requires journal recovery | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `app::restore_accepts_changed_non_path_settings_and_rejects_changed_paths`; `commit_restore::restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes`; app `restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes` |
|
||||
| Cancel preview/confirmation | No restore install has begun | Existing saved state and draft remain | Clear app-executor preview context | No backup/install/runtime side effect | `tui_workflow::worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operations`; app preview read-only test above |
|
||||
|
||||
### Restart attempt identity and uncertainty
|
||||
|
||||
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Preview restart | Installed bytes equal session saved generation; interface/service settings and complete saved peer projection are safe | Read-only preview captures saved bytes/settings/projection and draft presence | No backup/job/runtime operation | Unsafe or ambiguous peer state blocks before backup; stale saved bytes reject confirmation | `app::unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location`; `app::restart_confirmation_rejects_a_generation_changed_after_preview` |
|
||||
| Cancel choice | Preview still valid | Cancelled report; draft remains | No backup, service request, runtime read, or persistence | No operation was submitted | `app::restart_cancel_preserves_draft_and_never_calls_adapters` |
|
||||
| Confirm restart | Preview still current; explicit choice; backup succeeds | Attempt identity bound to saved generation, then service job submitted | Optional draft discard at specified safe point; complete backup; one shared deadline | Backup failure submits no job; before submission cancellation is definite | `app::restart_backup_failure_preserves_draft_and_submits_no_job`; `app::confirmed_restart_orders_backup_job_read_and_durable_publication` |
|
||||
| Service job submitted | Request accepted | Await job completion under same deadline | Job may still complete even if caller times out/disconnects | Timeout report retains submitted/may-still-complete distinction; never claim cancellation or rollback | `app::service_timeout_reports_submission_and_may_still_complete`; `app::submitted_terminal_job_failures_cannot_still_complete` |
|
||||
| Job complete, observe and compare | Job successful; remaining shared deadline; expected configured interface and complete runtime peer set | Matching result becomes candidate for durable verification | Bounded observation and pure comparison; no direct state publish yet | Read error/timeout/missing interface/incomplete dump/mismatch stops publication and retains reservations | `app::shared_deadline_expired_after_successful_job_stops_before_runtime_read`; `app::shared_deadline_expired_after_runtime_read_reports_timeout_before_comparison`; `runtime::partial_duplicate_and_unavailable_records_never_pass` |
|
||||
| Durable verification succeeds | Attempt token and both current/attempt saved bytes still equal; full technical match; internal state install succeeds | Publish current verified knowledge and clear retained addresses | Persist timestamp/reservations first; update in-memory session after successful install | Failed persistence leaves conservative state; no second restart or automatic rollback | `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` |
|
||||
|
||||
## M20–M26 migration disposition
|
||||
|
||||
This register resolves the destinations proposed by M19 against the integrated
|
||||
source. The milestone labels are historical; the owner column names the current
|
||||
code. Public facade calls remain where the caller audit below documents an
|
||||
active integration-fixture dependency. Existing low-level adapters and
|
||||
persisted formats remain in place.
|
||||
|
||||
| Milestone | Production surface and destination | Compatibility / preserved boundary | Existing baseline and exact focused commands |
|
||||
| --- | --- | --- | --- |
|
||||
| M20 — vocabulary and named contexts | Domain/apply vocabulary is in `src/domain.rs`; observations/comparison in `src/runtime.rs`; presentation mapping in `src/tui/state.rs`; `PreparedDestinationContext` and `RestorePreviewContext` are private named structs in `src/tui/events/executor.rs`. | Semantics remain unchanged; no domain child split was justified. | Historical focused checks recorded in M20 evidence; current generation-bound proof is `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`. |
|
||||
| M21 — reservations and apply knowledge | `src/domain.rs` owns `AddressReservations`, allocation and narrow apply transitions; `src/persistence.rs` encodes durable state; `src/runtime.rs` compares technical peer state; `src/app/restart.rs` coordinates verified publication. | No `domain/reservations.rs` or `domain/apply.rs` split was warranted; unrestricted draft mutation was removed. Persisted schema and semantics remain in persistence. | `domain::exact_host_routes_stay_pending_until_commit_and_discard_drops_them`; `persistence::persisted_history_never_marks_the_runtime_verified`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`. |
|
||||
| M22 — coordinated draft and create/edit/import | `src/app/draft.rs` coordinates document/client/vault/reservation drafts; `src/app.rs` remains the facade; `src/vault/source.rs` reads sources and `src/vault/import.rs` reconciles/encrypts. | No separate `app/clients.rs` or `app/import.rs` was justified. `Application::open`/`add_client` remain for injected-state and fixture setup, documented in the caller audit. | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; config preservation, delivery, import and workflow integration suites. |
|
||||
| M23 — saved Commit, backups and restore | `src/app/commit.rs`, `src/app/restore.rs`, and `src/app/backups.rs` own scenarios; `src/persistence.rs` owns journaling/path safety and `src/backup.rs` owns archive encoding/parsing. | Preview is byte-bound and saved-only. M23 added restoration of exact routes from unsupported current peers; this closed the earlier noted reservation gap. | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`. |
|
||||
| M24 — delivery scenarios | `src/app/first_delivery.rs` owns first-issue capability/retry state; `src/app/first_file.rs` runs first-file delivery; `src/app/reissue.rs` runs password reissue. `src/delivery.rs`, `src/delivery/files.rs`, `src/delivery/qr.rs`, and `src/vault.rs` are focused adapters/value owners. | No `app/delivery.rs` was created because session capability, file delivery and saved-record reissue have separate cohesive ownership. | `app::failed_file_publication_can_retry_in_a_different_directory`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry`; `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen`. |
|
||||
| M25 — restart scenario | `src/app/restart.rs` owns preview, attempt token, backup/job/read/compare/durable-publication order; `src/runtime.rs` owns complete peer projection/comparison; `src/service.rs` owns systemd adapter; persistence owns durable state. | Fixture apply is compiled only under `#[cfg(test)]`. No host service calls occur in ordinary fixture tests. | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::service_timeout_reports_submission_and_may_still_complete`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`. |
|
||||
| M26 — read model and TUI command adapter | `src/app/read_model.rs` owns secret-free peer/state/issue/reservation/apply facts and per-action eligibility. `src/tui/state.rs` owns UI navigation/generation; `src/tui/events.rs` and `src/tui/events/executor.rs` own worker lifecycle and command adaptation; forms own transient inputs; renderers own visual output. | TUI does not parse saved bytes or duplicate business authorization. Split of broader terminal/render modules was deferred; their shared lifecycle/layout helpers did not justify a larger move. | `tui::`; `tui_workflow`; `client_delivery`; M26 evidence records focused/full host results and limits. |
|
||||
|
||||
### Test inventory and verification notes
|
||||
|
||||
- There is no `tests/client_lifecycle.rs` target. Lifecycle coverage is
|
||||
distributed across `app::`, `client_delivery`, `commit_restore`,
|
||||
`vault_import`, and `tui_workflow`; the M22–M26 work used existing
|
||||
targets, and a new target is needed only if acceptance finds a real behavioral
|
||||
gap.
|
||||
- Application projection and eligibility are covered by colocated `app::`
|
||||
tests plus `tui::` and `tui_workflow::` tests; no separate integration target
|
||||
was needed.
|
||||
- Restore reservation coverage includes manageable saved clients, exact routes
|
||||
from unprojectable current peers, persisted reservations, and exact routes
|
||||
retained by the restored document. `app/restore.rs` excludes routes still
|
||||
owned by restored peers. The previous M19/M23 gap note is superseded by
|
||||
`restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`.
|
||||
- `ClientStates::draft_mut` and `Application::vault_draft_mut` no longer exist.
|
||||
`Application::add_client` and injected-state `Application::open` remain
|
||||
public because integration fixtures use them; the caller audit above records
|
||||
those exceptions. They are not the production create/startup path.
|
||||
- Existing test-only container driver is `src/container_driver.rs` included only
|
||||
by `src/lib.rs` under `#[cfg(test)]`. Its normal fixture tests are synthetic;
|
||||
ignored `container_driver::alpine_scenario_driver` is opt-in and is not an
|
||||
ordinary host test or a systemd acceptance test. Do not run it as part of
|
||||
this documentation task.
|
||||
- M19.2 and the subsequent migrations use only approved dependencies already
|
||||
present. No dependency, manifest, lockfile, configuration, or product behavior
|
||||
change is implied.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Milestone 8 diagnostic record
|
||||
|
||||
This is a development investigation record for disposable Alpine fixtures. It
|
||||
contains no production observations or secret fixture payloads. Current task
|
||||
state and acceptance remain in [PLANS.md](../../PLANS.md).
|
||||
|
||||
## M8.5 restore fixture reservation mismatch
|
||||
|
||||
- Recorded: 2026-10-24T23:04:57+03:00.
|
||||
- Symptom: the second password-free restore confirmed through `Application`,
|
||||
but its test driver failed a saved-state assertion. The first restore,
|
||||
explicit fixture Apply and renewed IPv4 traffic had already passed.
|
||||
- Bounded reproduction: pinned musl driver built with
|
||||
`cargo test --locked --offline --lib --no-run --target x86_64-unknown-linux-musl`
|
||||
in the approved Rust Alpine builder; selected-daemon ignored test invoked as
|
||||
`cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1`
|
||||
with the explicitly selected local Docker daemon and musl driver path.
|
||||
- Discriminating observations: fixed redacted phase markers reached
|
||||
`application_confirmed` and `precautionary_contents_verified`. Settings,
|
||||
server bytes, vault bytes and target-client presence matched. The actual
|
||||
reservation count was two, the fixture expected one, and both contained the
|
||||
target IPv4 address.
|
||||
- Cause: the fixture expected only the target IPv4 reservation when restoring
|
||||
an archive that removed a saved client with more than one address. Application
|
||||
retained both saved addresses as required while the live peer remained.
|
||||
- Repair: derive the exact expected reservation set from every address on the
|
||||
removed saved client, preserve prior reservations, and filter addresses still
|
||||
saved in the restored set. Keep the IPv4 target membership assertion. This is
|
||||
saved-state bookkeeping; no IPv6 capability or traffic test was run.
|
||||
- Verification: focused reservation-helper regression passed; rebuilt pinned
|
||||
musl driver; selected-daemon scenario passed 1/1 in 72.66s, including both
|
||||
restores and explicit applies. Exact-name test containers and network were
|
||||
absent afterward. Independent code review found no implementation defect;
|
||||
its plan-status finding closed on re-review. Final integrated gates remain.
|
||||
|
||||
Earlier failed diagnostic runs and superseded status are preserved in
|
||||
[the pre-cleanup record](../history/milestone-8-before-workflow-cleanup.md).
|
||||
|
||||
## M8.7 transient execution prerequisites
|
||||
|
||||
- Recorded: 2026-10-25T20:40:51+03:00. The first post-review opt-in run failed
|
||||
before scenario startup because the sandbox denied Docker API access.
|
||||
Running the selected-daemon command with approved sandbox escalation then
|
||||
failed prerequisite inspection because the musl driver artifact under `/tmp`
|
||||
was absent. Exact-name test container/network listings were empty.
|
||||
- A pinned builder rebuild with an empty `/tmp` Cargo cache and `--offline`
|
||||
failed dependency resolution for the already-approved `age` crate. The host's
|
||||
cached public Cargo registry contained it. Mounting that cache read-only into
|
||||
the pinned builder produced the same driver SHA256 as the prior passing run;
|
||||
no dependency or source change was needed.
|
||||
- Three subsequent selected-daemon runs passed with fresh mutable fixtures.
|
||||
Their exact commands, source fingerprint, outcomes, and limits are recorded
|
||||
in [the evidence report](../evidence/milestone-8.md). These initial
|
||||
prerequisite failures are not counted as scenario passes.
|
||||
|
||||
## M8.5 failure fixture legacy-peer deletion
|
||||
|
||||
- Recorded: 2026-10-24T23:27:52+03:00.
|
||||
- Symptom: the selected-daemon scenario passed real reader `NoDevice`/`Timeout`
|
||||
and deterministic Commit backup-failure checks, then stopped before restart
|
||||
backup-failure assertions.
|
||||
- Bounded check: fixed secret-free driver markers localized the stop after
|
||||
parsing the baseline client's identity and before deletion completion. The
|
||||
selected-daemon command is the one recorded in
|
||||
[the evidence report](../evidence/milestone-8.md), with the current pinned
|
||||
musl driver rebuilt for each diagnostic revision.
|
||||
- Cause: the fixture treated `Application::delete_client`'s Boolean as whether
|
||||
the peer was removed. It reports whether an encrypted vault record was
|
||||
removed. The synthetic legacy baseline peer had no such record, so `false`
|
||||
was correct even though the draft peer was deleted.
|
||||
- Repair: assert the baseline identity is absent from the draft after deletion
|
||||
and report the vault-record outcome separately. A focused regression covers
|
||||
deletion of a legacy peer with no vault record.
|
||||
- Verification: focused regression, formatting and Clippy passed; source
|
||||
fingerprint `656707eac2af28f2f3747687e73bb355c4df421663c142d4a7e91409763179b0`
|
||||
remained stable through a pinned musl build and selected-daemon run. That
|
||||
run passed 1/1 in 81.14s; exact-name containers and network were absent.
|
||||
Independent review and final integrated gates are still pending.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Milestone 9 diagnostics
|
||||
|
||||
Current execution state and acceptance are in [PLANS.md](../../PLANS.md). This
|
||||
file records bounded host preparation failures, not systemd acceptance evidence.
|
||||
All timestamps are UTC; the VM is disposable and contains synthetic data only.
|
||||
|
||||
## 2026-10-25T18:28:55Z — M9.1-R8, initial create parse
|
||||
|
||||
`python3 tests/vm/create.py` registered only
|
||||
`wga-m9-debian137-20261025` (UUID
|
||||
`b14c850b-a21e-4a56-acca-785590f0c4ac`) and stopped before VM configuration.
|
||||
Cause: actual VirtualBox 7.2.8 machine-readable output contains an indented
|
||||
`rec_screen0` recording section marker. The strict parser rejected it. Read-only
|
||||
inventory showed the target powered off with only its `.vbox` file; the existing
|
||||
Windows VMs were unchanged. A narrow marker parser repair and synthetic test
|
||||
passed independent re-review. Creation cannot be rerun because the target path
|
||||
is occupied; a UUID-pinned resume procedure was reviewed instead.
|
||||
|
||||
## 2026-10-25T18:35:00Z — M9.1-R5/R8, resume final guard
|
||||
|
||||
`python3 tests/vm/resume.py` verified the exact partial target, then configured
|
||||
the VDI, pinned installer ISO, NAT-only NICs, and private host marker/manifest.
|
||||
Its final read-only guard refused actual quoted SATA attachment keys and
|
||||
per-port metadata. `python3 tests/vm/guard.py check` first reported an
|
||||
unrecognized machine-readable key; after quoted-key parsing it reported an
|
||||
unrecognized attachment-shaped metadata key. Read-only `showvminfo` identified
|
||||
`SATA-IsEjected-1-0` among the actual metadata. Focused repairs accepted only
|
||||
the observed key forms, required the exact SATA controller, and rejected
|
||||
unrecognized/extra media and a symlinked target folder. Independent re-review
|
||||
closed the findings; the integrated synthetic VM suite passed 26/26 and the
|
||||
actual read-only `guard.py check` exited 0. No second resume or VM start occurred.
|
||||
|
||||
## 2026-10-25T18:47:20Z — M9.1 unattended prerequisite (recorded)
|
||||
|
||||
The first no-change `VBoxManage unattended install ... --dry-run --start-vm=none`
|
||||
refused a single-label hostname. Repeating with the fully qualified synthetic
|
||||
hostname `wga-m9-debian137.invalid` exited 0, including a separate dry run with
|
||||
the local Guest Additions ISO. Both used current-user mode-`0600` private
|
||||
synthetic password files. VirtualBox prints password values in dry-run output;
|
||||
the wrapper redacted them before display. These runs created no files or VM
|
||||
configuration changes. Actual installer preparation, boot, Guest Additions,
|
||||
and guest systemd remain unverified.
|
||||
|
||||
## 2026-10-25T18:53:05Z — M9.1 installer preparation (recorded)
|
||||
|
||||
Independent review found that the first prepare-only script omitted the
|
||||
required `--iso` and used an unsupported root-password option; its media
|
||||
comparison also rejected VirtualBox's empty SATA ports. M9.1-R9/R10 repairs
|
||||
passed re-review and 33/33 synthetic VM tests. The corrected
|
||||
`python3 tests/vm/install_prepare.py` exited 0 outside the sandbox with
|
||||
password-bearing VirtualBox output suppressed. Read-only `showvminfo` showed the
|
||||
same pinned VM powered off, NAT-only, with its one VDI at SATA 0 and the private
|
||||
generated `Unattended-<UUID>-aux-iso.viso` at SATA 1. Generated installer files
|
||||
were mode `0600` in the mode-`0700` target folder. The normal guard intentionally
|
||||
refuses `.viso`; first start awaits a separately reviewed installer-state guard.
|
||||
|
||||
## 2026-10-26T20:30:15Z — User stopped M9.1
|
||||
|
||||
The installer-state guard and start code passed independent review after a
|
||||
hard-link isolation repair; the full synthetic VM suite passed 44/44. Actual
|
||||
`python3 tests/vm/install_boot.py check` and `start` exited 0 against only the
|
||||
pinned target. Read-only `list runningvms` listed only that VM; a private
|
||||
console screenshot showed Debian base-system installation at 6%. Completion
|
||||
was not observed. After the user explicitly stopped Milestone 9, the exact VM
|
||||
was powered off; a subsequent read-only running-VM inventory was empty. Guest
|
||||
installation, Guest Additions, systemd, WireGuard, and M9 acceptance remain
|
||||
unverified. No existing Windows VM, production resource, Git commit, or
|
||||
deployment was touched.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Milestone 10 acceptance evidence
|
||||
|
||||
Run: 2026-10-26T21:07:24Z, local development workspace. This report covers the
|
||||
styled TUI keyboard footer only. Milestone 9 remains retired and unaccepted.
|
||||
|
||||
## Source and environment
|
||||
|
||||
- HEAD: `e141c03c2c401b09ad0fa78557cb9403640ea80a`; working tree was dirty
|
||||
with `PLANS.md`, `README.md`, `src/delivery/qr.rs`, and `src/tui/render.rs`.
|
||||
The following SHA-256 values identify the relevant integrated inputs at the
|
||||
gates: `render.rs` `40e2abc891cc562a628eb661dcdcd3e052818c4332c25f3ae555e184d9adb65d`;
|
||||
`forms.rs` `8ecbd614c1ecc7f1ab4cbae9223f9820c5d8315c62a54cffbc6d5c2e640f6c31`;
|
||||
`qr.rs` `b8d30e6b24370fd1e3d666586481af74b12f279043cb1e0a7a1e61923588c4b8`;
|
||||
`README.md` `fa4ef797565895dbd78f920e92263df718720bdb84dba48da88e611020638741`;
|
||||
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
|
||||
- `rustc 1.97.1 (8bab26f4f 2026-07-14)` and `cargo 1.97.1
|
||||
(c980f4866 2026-06-30)`. The release build used the locked local target;
|
||||
no container, VM, production service or VPN was accessed.
|
||||
|
||||
## Checks and scope
|
||||
|
||||
| Exact command | Result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked tui::` | Exit 0; 38 TUI unit tests passed. |
|
||||
| `cargo test --locked --test tui_workflow` | Exit 0; 6 integration tests passed, including isolated PTY smoke. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0 after M10.3-R1 repair. |
|
||||
| `cargo test --locked` | Exit 0; 271 library units passed, 2 opt-in container drivers ignored; all integration suites and 2 compile-fail doctests passed. The Testcontainers target reported 25 passed and 1 ignored; no opt-in Alpine run occurred. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
The TestBackend checks cover 120×30, 80×24, 40×8, 34×8 and below-minimum
|
||||
rendering. They assert visible footer interior, styled key labels, applicable
|
||||
80×24 list actions, compact navigation/Help and overflow, context-specific
|
||||
forms/progress, and QR Esc guidance without fixture payload disclosure. These
|
||||
are synthetic buffer checks; no physical terminal QR scan or systemd behavior
|
||||
is claimed.
|
||||
|
||||
## Review and decision
|
||||
|
||||
Independent M10.1 review found R1/R2 context/form hint issues; repair and
|
||||
re-review closed both on the integrated implementation. A separate fresh
|
||||
integrated M10.3 review found R1 (Clippy failure) and R2 (README context
|
||||
overstatement). The repairer fixed both; the same independent reviewer
|
||||
re-reviewed the repaired state and reported no remaining actionable finding.
|
||||
The reviewer supported M10-A1–A5 within synthetic/PTY scope; the coordinator's
|
||||
full gates above satisfy M10-A6. Compact QR may show a too-large condition at
|
||||
34×8 while retaining Esc clearing guidance. M10 acceptance is scoped to TUI
|
||||
presentation and does not upgrade M9 systemd/IPv6 evidence.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Milestone 11 acceptance evidence
|
||||
|
||||
Run: 2026-10-26T21:20:07Z, local development workspace. This report covers
|
||||
presentation of valid but unsupported peer `AllowedIPs` shapes. It does not
|
||||
establish runtime WireGuard, Alpine or systemd behavior.
|
||||
|
||||
## Source and environment
|
||||
|
||||
- HEAD `18f28eef02d6a9f94a1d34ad94e25079ebf125a2`; the working tree was
|
||||
dirty with `PLANS.md`, `src/app.rs`, `src/tui/render.rs`,
|
||||
`src/tui/terminal.rs`, and `tests/config_preservation.rs`. An external
|
||||
workspace actor created HEAD during implementation; this team created no
|
||||
commit. The integrated inputs at the gates have SHA-256 values:
|
||||
`app.rs` `75935d694d85cad11511ffbcff093cc7ebded08f02eb26589cb552fdbe1c7720`;
|
||||
`document.rs` `af4fed6c25fe4b801112d19fe4867ed809c6a05e68492c3a53cc4829ac3b766b`;
|
||||
`render.rs` `2f673febaba9e6f0ada1c274f33c0db02fe78fd2bca50310b1215d5a22dd8c9f`;
|
||||
`terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`;
|
||||
`config_preservation.rs` `0cdac39ac6ac370aaa85ea13610cb00f778a7efc559a39896212d96c6147f0b0`;
|
||||
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
|
||||
- `rustc 1.97.1 (8bab26f4f 2026-07-14)`; `cargo 1.97.1
|
||||
(c980f4866 2026-06-30)`. Synthetic fixtures and Ratatui TestBackend only;
|
||||
no production, Docker, VM or live VPN action.
|
||||
|
||||
## Checks and scope
|
||||
|
||||
| Exact command | Result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety` | Exit 0; focused config preservation test passed. |
|
||||
| `cargo test --locked tui::render::tests::client_rows_show_draft_state_and_escape_anomalous_unsupported_peers` | Exit 0; focused list/details test passed. |
|
||||
| `cargo test --locked --lib issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Exit 0; focused issue-screen test passed after repair. |
|
||||
| `cargo test --locked --test config_preservation` | Exit 0; 10 tests passed. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||
| `cargo test --locked` | Exit 0; 272 library tests passed, 2 opt-in drivers ignored; integration and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
The parser preserves the valid example's original source and all three routes.
|
||||
The application leaves its raw peer issues and unsupported projection intact;
|
||||
the TUI presents parsed-valid address-shape limitations separately from
|
||||
malformed input. The list and issue screen omit capability-only diagnostics;
|
||||
details show the exact subnet explanation. Tests also cover a normal supported
|
||||
client, extra host routes, IPv6-only shape, mixed malformed route, and existing
|
||||
broad-route deletion safety. Alias-only rename of an unprojectable peer retains
|
||||
its existing behavior and preserves routes. The TUI still blocks normal client
|
||||
mutations for unprojectable peers.
|
||||
|
||||
## Review and decision
|
||||
|
||||
Fresh independent review initially raised M11-R1, treating a second distinct
|
||||
`/32` host route as a configuration error. After checking the WireGuard
|
||||
[`wg(8)` AllowedIPs format](https://man7.org/linux/man-pages/man8/wg.8.html),
|
||||
the reviewer withdrew R1: a valid additional host route is another model
|
||||
capability limitation. The coordinator raised M11-R2 because non-subnet valid
|
||||
address shapes were still displayed as anomalies. A repairer classified the
|
||||
two parsed-valid shape variants consistently for presentation, kept all raw
|
||||
issues for safety, and added regressions. Independent re-review reported no
|
||||
remaining actionable finding. A1–A4 passed within source/fixture scope; the
|
||||
integrated gates above satisfy A5.
|
||||
|
||||
No real WireGuard or systemd behavior was exercised. Milestone 9 remains
|
||||
retired and unaccepted; real systemd/D-Bus and IPv6 capability are unverified.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Milestone 12 acceptance evidence
|
||||
|
||||
Run: 2026-10-26T21:39:45Z, local development workspace. This report covers
|
||||
the responsive client table presentation. It does not establish real terminal
|
||||
emulator, Alpine WireGuard, systemd/VM or production behavior.
|
||||
|
||||
## Source and environment
|
||||
|
||||
- HEAD `ecc5c83d78a4428b8fa2c1826fe74ead144cf72b`; the working tree at
|
||||
validation was dirty with `PLANS.md` and `src/tui/render.rs`. Relevant
|
||||
integrated SHA-256 inputs: `render.rs`
|
||||
`78be59f8550f94c573db9eab6b43968a9e0621bcabd7dd268b5ba7f9b9aba3ad`;
|
||||
`state.rs` `7e893758b623b42f955358dcaba8100fdb6b54ca48c9dd9a1dea15090b84f371`;
|
||||
`terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`;
|
||||
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
|
||||
- Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97; native
|
||||
`x86_64-unknown-linux-gnu` host. No dependency or lockfile change.
|
||||
|
||||
## Validation
|
||||
|
||||
| Exact command | Actual result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked tui::render` | Exit 0; 20 render tests passed. |
|
||||
| `cargo test --locked tui::terminal` | Exit 0; 8 terminal tests passed. |
|
||||
| `cargo test --locked --test tui_workflow` | Exit 0; 6 workflow tests passed, including isolated PTY smoke. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||
| `cargo test --locked` | Exit 0; 279 library unit tests passed, 2 opt-in drivers ignored. All integration suites and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
Ratatui TestBackend coverage checks five full headers and column boundaries at
|
||||
ordinary widths, State/Name priority across 72/78/79/80 widths, display-cell
|
||||
Unicode truncation, long routes, mixed client modes, empty/draft/document rows,
|
||||
no false non-peer selection, and visible selected peers with the Keys footer at
|
||||
40×8 and 34×8. Below-minimum resize fallback remains covered. Existing
|
||||
terminal tests cover Up/Down highlight and Enter-to-details by peer index.
|
||||
These tests use synthetic buffers and fixtures; physical terminal display
|
||||
differences were not tested.
|
||||
|
||||
## Independent review
|
||||
|
||||
Fresh read-only review found M12-R1 (compact table hid all peer rows), R2
|
||||
(empty/document-only views falsely selected row zero), and R3 (selector width
|
||||
clipped the last column). A targeted repair added compact body visibility,
|
||||
unselected non-peer views and marker-aware width budgeting; independent
|
||||
re-review closed R1–R3. That re-review found R4: five-column mode began before
|
||||
the `Allowed IPs` header fit at widths 72–78. A second targeted repair moved
|
||||
the threshold to the full-header minimum and added boundary-buffer checks;
|
||||
independent re-review closed R4 with no remaining actionable finding.
|
||||
|
||||
M12-A1–A4 are supported by the inspected implementation and synthetic
|
||||
render/navigation evidence. The integrated gates above satisfy M12-A5.
|
||||
Configuration parsing, validation, persistence, allocation and VPN behavior
|
||||
were outside scope and unchanged. Milestone 9 remains retired and unaccepted;
|
||||
real systemd/D-Bus and IPv6 behavior remain unverified.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Milestone 13 acceptance evidence: compact terminal QR disclosure
|
||||
|
||||
Validated 2026-10-27T21:13:30+02:00 on the native
|
||||
`x86_64-unknown-linux-gnu` development host. This is synthetic terminal
|
||||
rendering evidence, not a manual scan or production integration result.
|
||||
|
||||
## Behavior and review
|
||||
|
||||
The QR renderer packs two vertical modules into one cell using space, `▀`,
|
||||
`▄`, and `█`, with explicit black foreground and white background. It preserves
|
||||
the encoded four-module quiet zone on all four edges and treats the absent
|
||||
partner of an odd final row as light. The QR is rendered in a separate,
|
||||
unwrapped region. The fit check accounts for its bordered area and actual
|
||||
wrapped delivery notice. A deterministic nonsecret 73-module fixture uses
|
||||
73 columns and 37 QR rows; with a one-row notice, the exact tested terminal
|
||||
layout is 75×44. Longer notices require more terminal height.
|
||||
|
||||
When the complete QR does not fit, a compact fallback shows required and
|
||||
available dimensions, actual terminal dimensions, saved-file guidance, delivery
|
||||
status, and Esc. Tests inspect rendered buffers at 74×45, 34×12 and 20×8;
|
||||
resize redraws the held QR and Esc clears it. The compact notice preserves
|
||||
uncertain publication and destination-inspection guidance. File delivery,
|
||||
password authorization, and draft/saved/applied transitions were not modified.
|
||||
`QrCodeOutput` retains redacted Debug and the fixture matrix is nonsecret.
|
||||
|
||||
An independent GPT-6-Luna/high reviewer examined the actual changes and
|
||||
closed M13-R1–R5 after targeted repairs: short fallback clipping, hidden
|
||||
saved/applied summary, incomplete quiet-zone edge coverage, inaccurate
|
||||
available-area dimensions, and misleading uncertain-delivery compaction. No
|
||||
actionable scoped findings remain.
|
||||
|
||||
## Commands and source identity
|
||||
|
||||
Pinned rustc/Cargo 1.97.1, rustfmt 1.9.0, and Clippy 0.1.97 were verified.
|
||||
The following commands ran from the repository root and exited 0:
|
||||
|
||||
- `cargo test --locked tui::render::tests::`: 27 passed.
|
||||
- `cargo test --locked delivery::qr::tests::`: 2 passed.
|
||||
- `cargo fmt --all -- --check`.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`.
|
||||
- `cargo test --locked`: 352 passed, 3 explicitly ignored, 0 failed,
|
||||
including 2 documentation tests.
|
||||
- `cargo build --locked --release`.
|
||||
- `git diff --check`.
|
||||
|
||||
Base commit: `af2d061880a8532314f57d4f1ec6eb5265520091`. Dirty Rust inputs
|
||||
at validation were `src/tui/render.rs` and `src/delivery/qr.rs`; `PLANS.md`
|
||||
was also dirty with this milestone's execution record. SHA256 inputs:
|
||||
|
||||
- `src/tui/render.rs`: `98ffa4d396194c201cfe20b6c6dcbd80b6689886fc625c8cb83efa6c12564e8a`
|
||||
- `src/delivery/qr.rs`: `1f2012c6d1583178ca964df1d444c8afeae35607dfafac05bc538911be480748`
|
||||
|
||||
Native release artifact `target/release/wireguard-admin` SHA256:
|
||||
`0c2fc010138c7db27c3126d0eca6bd83da3be600c1c4e0f8c8beeef77d11e43a`.
|
||||
|
||||
No physical terminal, phone/decoder scan, Alpine integration rerun, disposable
|
||||
systemd VM, or production operation was performed. The 80×24 size remains a
|
||||
dimensioned fallback for a 73-module QR. Previous integration artifacts do not
|
||||
identify this revision; M9 systemd acceptance remains retired and unverified.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Milestone 14 acceptance evidence: restart with valid legacy peers
|
||||
|
||||
Validated 2026-10-27T21:55:11+02:00 on the native
|
||||
`x86_64-unknown-linux-gnu` development host. Results below use synthetic
|
||||
saved files, fake service/runtime adapters, and existing pure runtime comparison
|
||||
tests. They do not establish a real systemd restart.
|
||||
|
||||
## Behavior and review
|
||||
|
||||
Restart preview and confirmation now use the complete `SavedPeerProjection`
|
||||
derived from the authoritative saved configuration. It includes every valid
|
||||
peer's public key and complete normalized `AllowedIPs`, independently of
|
||||
optional managed alias metadata and the narrower management `Client` shape.
|
||||
The existing runtime comparator still requires a complete observed peer set
|
||||
and checks all saved peers and routes before verification can be recorded.
|
||||
The management applied snapshot remains absent when it cannot represent the
|
||||
whole saved peer set; no partial `Client` snapshot is marked applied.
|
||||
|
||||
Synthetic fixtures cover a saved managed client alongside metadata-free legacy
|
||||
peers, IPv4-only host routes, and valid subnet routes outside the management
|
||||
model. Preview preserves server bytes and creates no backup. Fake successful
|
||||
service completion plus a full runtime match creates the required pre-restart
|
||||
backup, records verified historical status, and releases deleted-address
|
||||
reservations only after durable installation. Reopening retains that history,
|
||||
starts with runtime `Unknown`, and does not regain a partial applied snapshot.
|
||||
A missing legacy runtime peer leaves reservations durable and runtime unknown.
|
||||
Malformed AllowedIPs, duplicate public identity, and malformed section headers
|
||||
block preview before backup/service mutation with a secret-free public identity
|
||||
or section line and condition. Existing tests also cover backup failure,
|
||||
service timeout/failure, stale saved generation, runtime mismatch, persistence
|
||||
failure, draft cancellation and `SaveConfig` rejection.
|
||||
|
||||
An independent GPT-6-Luna/high reviewer found no actionable behavior finding.
|
||||
The first coordinator Clippy gate found M14-R1 (P3), a collapsible diagnostic
|
||||
guard in `src/app.rs`. A separate GPT-6-Luna/high repairer fixed it; the
|
||||
reviewer confirmed equivalent logic and closed R1. The final integrated gates
|
||||
below ran after that repair.
|
||||
|
||||
## Commands and source identity
|
||||
|
||||
Pinned rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
|
||||
The following commands ran from the repository root and exited 0 on the
|
||||
integrated revision:
|
||||
|
||||
- `cargo test --locked app::tests::`: 57 passed.
|
||||
- `cargo test --locked --test runtime_restart`: 3 passed.
|
||||
- `cargo fmt --all -- --check`.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`.
|
||||
- `cargo test --locked`: 357 passed, 3 explicitly ignored, 0 failed,
|
||||
including 2 documentation tests.
|
||||
- `cargo build --locked --release`.
|
||||
- `git diff --check`.
|
||||
|
||||
Base commit: `4bb975f122abfdeabbd04fe6b737eec5706596f5`. Dirty Rust inputs
|
||||
at validation were `src/app.rs`, `src/domain.rs`, and `tests/runtime_restart.rs`;
|
||||
`PLANS.md` was dirty with this milestone's execution record. SHA256 inputs:
|
||||
|
||||
- `src/app.rs`: `01cf281f5b8ae62877dcf63ece7d51b5cca5522f6bc5c9e99dbc0e718a4121f4`
|
||||
- `src/domain.rs`: `179ecbe66ad7b6e022233a6da3b62a6ce57d92d26d32b0ae6c750ccd2edc5f01`
|
||||
- `tests/runtime_restart.rs`: `4cff0de16a18cccc846ace86808840567165fc110d121fd7bdc459481379e05f`
|
||||
|
||||
Native release artifact `target/release/wireguard-admin` SHA256:
|
||||
`a7c946297d2a0140ddcca1c15bb3f651fe6ddc494c2eb7e8e713fdfc700e6b5a`.
|
||||
|
||||
No Alpine rerun, disposable systemd VM, or production operation was performed.
|
||||
The accepted M8 Alpine artifact identity predates this revision; it does not
|
||||
validate this build. M9's real systemd/D-Bus acceptance remains retired and
|
||||
unverified.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Milestone 15 evidence: diagnosable systemd restart
|
||||
|
||||
Validated 2026-10-27T22:19:58+02:00 on the native
|
||||
`x86_64-unknown-linux-gnu` development host. All behavioral checks below use
|
||||
synthetic fixtures or fake service/unit APIs. No system D-Bus or host service
|
||||
was invoked.
|
||||
|
||||
## Cause, change, and limits
|
||||
|
||||
The source-level diagnostic loss was confirmed: `Application` mapped every
|
||||
unsuccessful service outcome to `ServiceFailed`. The report now retains the
|
||||
typed `RestartFailure`, an explicit operation phase, and the configured unit;
|
||||
the terminal renders a readable category and phase with the unit escaped for
|
||||
terminal safety. Raw D-Bus error messages and service output are not included.
|
||||
|
||||
The [official systemd Manager D-Bus API](https://freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html)
|
||||
confirms `GetUnit` fails if the named unit has not yet been loaded and `LoadUnit`
|
||||
loads it from disk if possible. The previous adapter called `GetUnit`; it now
|
||||
calls `LoadUnit`, requires `LoadState == loaded`, and reads canonical `Id` for
|
||||
job-event matching while requesting restart with the configured name. This is
|
||||
a confirmed API mismatch for existing unloaded units, but its role in the
|
||||
user's specific host failure is a hypothesis until a disposable-VM run provides
|
||||
real systemd/D-Bus evidence. Missing or nonloaded units stop at resolution.
|
||||
|
||||
The preexisting service adapter could understate uncertainty if the manager
|
||||
owner changed during a pending restart request. That path now reports possible
|
||||
submission, possible later completion and manual recovery. Synthetic tests
|
||||
cover connection, subscription, permission, resolution, request and job
|
||||
failures; alias identity; unloaded and missing unit load flows; and readable
|
||||
TUI status for every typed failure. They also assert failure does not mark the
|
||||
state applied. Existing tests cover mandatory backup, confirmation, generation,
|
||||
complete runtime match, and durable applied-state boundaries. No shell restart
|
||||
path was introduced.
|
||||
|
||||
Independent GPT-6-Luna/high review found M15-R1 (P2, request owner-change
|
||||
uncertainty) and M15-R2 (P2, unloaded-unit adapter test gap). A separate
|
||||
GPT-6-Luna/high repairer addressed both. The reviewer rechecked the actual
|
||||
repair and closed both findings with no further scoped issue.
|
||||
|
||||
## Commands and source identity
|
||||
|
||||
Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
|
||||
Commands ran from the repository root on the repaired revision:
|
||||
|
||||
- `cargo test --locked service::dbus::tests::`: 13 passed.
|
||||
- `cargo test --locked app::tests::application_report_preserves_every_service_failure_and_operation_phase`: 1 passed.
|
||||
- `cargo fmt --all -- --check`: exit 0.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`: exit 0.
|
||||
- `cargo test --locked`: 364 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests. The selected Docker daemon test was not run.
|
||||
- `cargo build --locked --release`: exit 0.
|
||||
- `git diff --check`: exit 0.
|
||||
|
||||
Base commit: `86c28a35b559f70cb08365a7cdf0d5961e5570ea`. Dirty Rust inputs
|
||||
at validation and their SHA256 values:
|
||||
|
||||
- `src/service.rs`: `83a6089a4db019918178d7d97346e104180d63eb3ccc8a025bda91939720e02e`
|
||||
- `src/service/dbus.rs`: `79ebc8c91db6dd08163ee0a6484359c2a8e7623e33081c9088c4d97d507d1816`
|
||||
- `src/app.rs`: `2c3f6822a48dd5ec92faad9fbfd05a5be8723057d23aaba7f92e7899891cbefb`
|
||||
- `src/tui/terminal.rs`: `48835132a30b1d5243a6076abfe06f6e98fb9419b0c4433c540406767715bae3`
|
||||
- `src/container_driver.rs`: `9f6b27bff93d4face72ef8980a5d8e2db418bdb079c501a885d2c419fcfd6964`
|
||||
|
||||
Native release artifact `target/release/wireguard-admin` SHA256:
|
||||
`18dd2ce2dcc9f93272440d45491b5f83da82a7b9ed392986a8eaf059b76bf593`.
|
||||
|
||||
No Alpine rerun, disposable systemd VM, or production operation was performed.
|
||||
The actual systemd/D-Bus acceptance required by M9 remains incomplete and
|
||||
unclaimed. An operator can now use the specific safe category and phase to
|
||||
distinguish a unit-resolution problem from connection, subscription, access,
|
||||
request, or job failure without exposing raw service details.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Milestone 16 evidence: private systemd peer transport
|
||||
|
||||
Recorded 2026-10-27T22:42:48+02:00 on the native
|
||||
`x86_64-unknown-linux-gnu` development host. This is **implementation and
|
||||
synthetic evidence, not systemd acceptance**. On 2026-10-28, the user reported a
|
||||
successful manual check and explicitly directed that M16 be considered done.
|
||||
The check's environment, commands, source identity, and transport coverage were
|
||||
not supplied. PLANS.md records user-directed acceptance; this report does not
|
||||
claim a passing disposable-VM run or independently close M16-R2.
|
||||
|
||||
## Source and protocol basis
|
||||
|
||||
The existing adapter connected only with `zbus::Connection::system()`. That
|
||||
cannot reach systemd where no system D-Bus daemon exists. Official systemd
|
||||
[source](https://github.com/systemd/systemd/blob/main/src/shared/bus-util.c)
|
||||
shows root `systemctl` connecting to `unix:path=/run/systemd/private` and
|
||||
checking peer credentials. Its [manual](https://www.freedesktop.org/software/systemd/man/latest/systemd.html)
|
||||
calls this an internal interface; compatibility across systemd releases is
|
||||
not guaranteed. Pinned [zbus 5.19.0](https://docs.rs/zbus/5.19.0/zbus/connection/struct.Builder.html)
|
||||
provides `p2p` and EXTERNAL-authentication support.
|
||||
|
||||
The adapter now selects the system bus first and falls back to the private
|
||||
socket only for a missing/refused/disconnected bus endpoint during connection
|
||||
setup, before any restart request. Permission, handshake and timeout failures
|
||||
do not trigger fallback. The private path checks peer UID 0; method calls omit
|
||||
the bus destination; its `JobRemoved` stream uses a local peer match without
|
||||
`org.freedesktop.DBus` owner monitoring. The system-bus path retains its
|
||||
destination and owner-change watch. Both preserve `Subscribe` before
|
||||
`RestartUnit`, canonical unit identity, the shared deadline, job matching and
|
||||
conservative submission uncertainty. No retry follows a possibly submitted
|
||||
restart. Application backup/confirmation/runtime/applied logic is unchanged.
|
||||
|
||||
## Checks, review, and gap
|
||||
|
||||
An independent GPT-6-Luna/high reviewer found M16-R1 (P2): private socket
|
||||
permission errors were reduced to `RequestFailed`. A separate GPT-6-Luna/high
|
||||
repairer changed the production builder error mapping to preserve
|
||||
`PermissionDenied` and added a focused synthetic assertion. The reviewer
|
||||
rechecked and closed R1. M16-R2 (P2) remains open: synthetic tests do not
|
||||
execute the production private peer branch end to end. An attempted local zbus
|
||||
UnixStream peer fixture failed at peer credential access with `EPERM` in the
|
||||
sandbox; later fixture variants did not establish a successful protocol path.
|
||||
Those experiments were removed from the test tree. They are not evidence that
|
||||
real systemd rejects the implementation. No host private socket was accessed.
|
||||
|
||||
Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0, and Clippy 0.1.97 were verified.
|
||||
On the repaired inputs, from the repository root:
|
||||
|
||||
- `cargo test --locked --lib service::dbus::tests -- --test-threads=1`: 16 passed.
|
||||
- `cargo fmt --all -- --check`: exit 0.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`: exit 0.
|
||||
- `cargo test --locked`: 367 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests. Selected Docker/VM suites were not run.
|
||||
- `cargo build --locked --release`: exit 0.
|
||||
- `git diff --check`: exit 0.
|
||||
|
||||
Base commit: `fe7e3d61c22108acba5c4a86480254a12e089a53`. Dirty inputs and
|
||||
SHA256 at validation:
|
||||
|
||||
- `Cargo.toml`: `807d08cc43b4c3f986134b7aa5b051fd2f0966fab95ac01d861c65f173c77caa`
|
||||
- `Cargo.lock`: `6a929bb72d90da16b5ca7d209566e31a611d1ddcd5817ee778d7e9fe25b10c2e`
|
||||
- `src/service/dbus.rs`: `d1ed34dd8a46d2495299c776176d7d5f6cfa9b6abb29ec84b6045e74bdffeb26`
|
||||
- `docs/DEPENDENCIES.md`: `1db85ecd3f892e3f42f1220eb7627cfd423406e46e5024bdf6599a647f07be1f`
|
||||
- `docs/TESTING.md`: `7ecab091999b50964b1641772a4cfc370639507b9c6621a058829a54b0f71325`
|
||||
|
||||
Native release artifact `target/release/wireguard-admin` SHA256:
|
||||
`64c2851431ec406901e76dc866a05abd630a0e71adfd4efc16d3b44ea3c72b73`.
|
||||
|
||||
The prior disposable Debian VM was stopped before guest installation and has
|
||||
no verified guest marker or running systemd acceptance environment, per
|
||||
[`VM_ACCEPTANCE.md`](../VM_ACCEPTANCE.md). No private-socket or system-bus
|
||||
restart was run in a disposable VM as part of the recorded checks. This gap
|
||||
remains despite the later user-directed acceptance. A future independent check
|
||||
would use a disposable VM with synthetic WireGuard configuration to cover both
|
||||
transport paths, job completion, and full runtime matching, with exact
|
||||
source/artifact identity. Production must not be used to fill the gap.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Milestone 17 evidence: adaptive client list and state presentation
|
||||
|
||||
Recorded 2026-10-28T23:09:37+02:00 on `x86_64-unknown-linux-gnu` with
|
||||
`rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. Base commit:
|
||||
`d49721ff8e6a40c1016e59b2648a6a622f84a4c4`. This is a dirty working tree;
|
||||
the M17 Rust/README inputs at validation have SHA256:
|
||||
|
||||
| Input | SHA256 |
|
||||
| --- | --- |
|
||||
| `src/tui/render.rs` | `19fad8a9edfa571060c94fe34a1a484369f3601a3ffc52148081c935be2404f2` |
|
||||
| `src/tui/state.rs` | `b533427001c67fb2c1d8527fb8a2cc1d6bd7e824be8985fd8b773b09a144d0f9` |
|
||||
| `src/tui/terminal.rs` | `591ad7498fb4bb8cb825dfba0bad26e28bd8432ef26d5527950863a7534689f6` |
|
||||
| `tests/tui_workflow.rs` | `f57d058d8538d5d1afdab7ddb7e88d23259804a9927ee4ac52982903dc1a6928` |
|
||||
| `README.md` | `7c5eb0b58e81aaecbddae230211099db59560dfc5fe30991e7022d841d6b04a8` |
|
||||
|
||||
`PLANS.md` and `docs/evidence/milestone-16.md` are also dirty documentation
|
||||
inputs. The latter records user-directed M16 acceptance and an undocumented
|
||||
manual check; it is not M17 test evidence. No M17 dependency manifest or
|
||||
application adapter changed. The release binary
|
||||
`target/release/wireguard-admin` has SHA256
|
||||
`336103d76bb78225735411a76abdbf3f6d5dc49dd6ce0b96537dfd3dea46a578`.
|
||||
`file` identified an x86-64 dynamically linked GNU/Linux PIE.
|
||||
|
||||
## Implementation and acceptance mapping
|
||||
|
||||
- M17-A1: the table allocates the actual list-pane width across wide, medium
|
||||
and compact columns. Synthetic buffers cover growing names, Latin/Cyrillic/
|
||||
wide-character alignment, separate host IPv4/IPv6 cells, IPv4-only peers,
|
||||
and route markers. Full routes and public identity remain in details.
|
||||
- M17-A2/A3: shared visible records and selection drive the list, persistent
|
||||
pane and Enter details. Synthetic state/navigation/buffer checks cover first
|
||||
selection, duplicate and malformed peers, draft removals, document-only
|
||||
issues, refresh, dimension breakpoints and stable geometry. A loading-state
|
||||
regression covers side and stacked panes. Breakpoints use the content area:
|
||||
side-by-side at 140×13 or larger, stacked at 80×17 or larger (eight rows for
|
||||
details), compact summary at 60×12 or larger, otherwise list-only. These
|
||||
dimensions are independent of selected content and action availability.
|
||||
- M17-A4/A5: explicit Draft, Saved and Applied labels distinguish pending
|
||||
drafts from saved peers and runtime verification. The saved peer count comes
|
||||
from installed server bytes and includes nonmanaged peers; managed counts
|
||||
remain separately scoped. Tests cover state combinations, issue versus
|
||||
capability-limit counts, complete details and available observations.
|
||||
- M17-A6: focused TUI/workflow checks include navigation, forms, footer, QR and
|
||||
synthetic PTY lifecycle coverage. No Commit/restore/restart behavior changed.
|
||||
|
||||
## Review and checks
|
||||
|
||||
One GPT-6-Luna/high implementer completed M17.1, M17.2 and M17.3 in order.
|
||||
A separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated
|
||||
diff and ran `cargo test --locked tui::` (70 passed) and
|
||||
`cargo test --locked --test tui_workflow` (8 passed). It found M17-R1 (P2):
|
||||
loading list and persistent detail messages conflicted. A separate
|
||||
GPT-6-Luna/high repairer fixed the pane and added a 140×20/80×24 buffer test;
|
||||
the reviewer rechecked and closed R1 (`cargo test --locked tui::render::tests`:
|
||||
39 passed). The integrated Clippy gate then found M17-R2 (P2), two redundant
|
||||
closures. The repairer replaced them with function items; the reviewer checked
|
||||
semantics, reran exact Clippy, and closed R2. No actionable review finding
|
||||
remains.
|
||||
|
||||
Final integrated checks on the input hashes above:
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo test --locked tui::` | 71 passed, 0 failed |
|
||||
| `cargo test --locked --test tui_workflow` | 8 passed, 0 failed; includes synthetic PTY smoke |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after R2 repair |
|
||||
| `cargo test --locked` | 384 passed, 0 failed, 3 explicitly ignored; 2 doctests passed |
|
||||
| `cargo build --locked --release` | exit 0; binary hash above |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
All tests used synthetic configuration and keys. The PTY smoke checks emitted
|
||||
terminal control behavior through an isolated `script` session; it is not a
|
||||
manual terminal-emulator usability test. Alpine Testcontainers, disposable-VM
|
||||
systemd paths, live WireGuard, production configuration, and phone QR scanning
|
||||
were not run for this presentation milestone. Those checks are not claimed by
|
||||
the fixture results. No host service was restarted and no Git commit was made.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Milestone 18 evidence: in-app Help
|
||||
|
||||
Recorded 2026-10-28T23:59:51+02:00 on `x86_64-unknown-linux-gnu` with
|
||||
`rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. Base commit:
|
||||
`4960c65f6511da50bac9d35e2b4a625adbaf86c3`. The working tree is dirty;
|
||||
the M18 Rust/README inputs at final validation have SHA256:
|
||||
|
||||
| Input | SHA256 |
|
||||
| --- | --- |
|
||||
| `src/tui/render.rs` | `dbf00298f1bf466f99688f790be5ae34e9a7434aa374bc61c7df31e41c8d842b` |
|
||||
| `src/tui/terminal.rs` | `4658404e856c1acced9c115f1cfb4c9f87e714b2343c3996df7ee63504368b81` |
|
||||
| `tests/tui_workflow.rs` | `d7bc72c20eb9b4d3ba160e599d83f83111e418a9c3056b4e430aa17a555baacd` |
|
||||
| `README.md` | `7352da8e13850b1b13e11bf2f828c8368dbd1b6b95320d6838ac26b7fa0532d6` |
|
||||
|
||||
`PLANS.md` is also dirty, recording the prior M18 plan and this milestone's
|
||||
progress. No dependency manifests or application adapters changed. Release
|
||||
`target/release/wireguard-admin` SHA256 is
|
||||
`d179b2934565e12e0d90ad716730418a40b215c5ca68058fd76566c267e98488`;
|
||||
`file` identified an x86-64 dynamically linked GNU/Linux PIE.
|
||||
|
||||
## Shortcut audit and content basis
|
||||
|
||||
The implementer audited current `src/tui/terminal.rs::handle_key` and
|
||||
`handle_navigation_key`, `src/tui/forms.rs::handle_key` and
|
||||
`src/tui/events.rs` before writing Help. The guide's shortcut families map to
|
||||
the existing handlers and guards:
|
||||
|
||||
| Keys | Handler and actual availability |
|
||||
| --- | --- |
|
||||
| `?`, `Up/Down`, `PageUp/PageDown`, `Enter`, `Esc` | `terminal.rs`: Help resets to top; Help scrolls by visual row/page; list arrows select and Enter opens details; Esc returns to list or cancels an active form/clears QR. |
|
||||
| `q`, handled Ctrl-C | `terminal.rs`: outside forms, exit is immediate without a draft or asks about draft discard; Ctrl-C is translated to `q` and is form-specific while a form is active. |
|
||||
| `n`, `r`, `d` | `terminal.rs` and `forms.rs`: create requires a loaded view; rename/delete require a supported unambiguous selected peer and confirmation. Blank paired addresses select automatic allocation. |
|
||||
| `p`, `c`, `x`, `R` | `terminal.rs`, `forms.rs`, `events.rs`: preview draft; confirmed Commit saves after backup; confirmed discard drops draft; uppercase restart previews saved state and requires confirmation, backup, service completion and verification. |
|
||||
| `o`, `O` | `terminal.rs`, `forms.rs`, `events.rs`: supported selected saved client only; first delivery after Commit is session-bound and retryable after file failure, while uppercase reissue requires a vault password per action; file precedes optional QR. |
|
||||
| `b`, `B` | `terminal.rs` and `forms.rs`: list/select backup and preview confirmed full restore versus create manual backup. Restore changes saved state only. |
|
||||
| `s`, `i`, `!` | `terminal.rs` and `forms.rs`: setup only before vault initialization; import submits one configured-directory filename and preserves its source; issue summary is available after view load. |
|
||||
|
||||
Actions remain guarded by the existing handlers. The Help navigation keys
|
||||
themselves do not submit application work. Existing action letters can still
|
||||
dispatch while Help is visible; the milestone did not change action dispatch.
|
||||
The guide says how to return to the list and accurately distinguishes lowercase
|
||||
and uppercase keys.
|
||||
|
||||
## Implementation, review and acceptance mapping
|
||||
|
||||
The Help screen is one ordered document with an introduction and nine sections:
|
||||
quick start; list/details; state/observations; client changes; save/restart;
|
||||
delivery/reissue; backups/restore; vault/import/issues; navigation/exit. It
|
||||
uses styled headings, key labels, workflows and warnings, with display-cell
|
||||
wrapping at the actual inner viewport width. A visible row-range title and the
|
||||
fixed-height footer show navigation and overflow. Renderer and terminal share
|
||||
the same wrapped-row metrics: one-row arrows, viewport-page keys, bounded
|
||||
scrolling, resize clamping, `?` reset and Esc return. These changes cover
|
||||
M18-A1–A5; synthetic buffers cover 34×8, 40×9, 60×16, 80×24, 100×30 and
|
||||
180×50, including reachability of the last section. README Help instructions
|
||||
were updated.
|
||||
|
||||
One GPT-6-Luna/high implementer completed M18.1–M18.3 sequentially. A separate
|
||||
fresh GPT-6-Luna/high read-only reviewer compared content with the handlers and
|
||||
tested Help rendering/navigation. It found M18-R1 (P2): at 34×8, the Help
|
||||
footer omitted Esc return. A separate GPT-6-Luna/high repairer changed only
|
||||
Help's narrow hint packing and added 34×8/40×9 assertions. The reviewer
|
||||
rechecked the actual repair, ran the focused test and closed R1. No actionable
|
||||
finding remained in that pass. A coordinator content audit then found M18-R2:
|
||||
four missing explanations about Applied status, current observations, the
|
||||
first-delivery activation warning and masked password entry. The reviewer
|
||||
independently confirmed those gaps. The separate repairer amended Help and
|
||||
added a 34×8 semantic/reachability regression; independent re-review checked
|
||||
the claims against status/form code and SPEC.md and closed R2. No actionable
|
||||
finding remains, satisfying M18-A6. A proposed change to action-key dispatch
|
||||
was withdrawn after checking the explicit preserve-actions boundary.
|
||||
|
||||
## Final checks and limits
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo test --locked tui::` | 79 passed, 0 failed |
|
||||
| `cargo test --locked --test tui_workflow` | 8 passed, 0 failed; includes synthetic PTY Help/resize flow |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||
| `cargo test --locked` | 392 passed, 0 failed, 3 explicitly ignored; 2 doctests passed |
|
||||
| `cargo build --locked --release` | exit 0; binary hash above |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
Tests use synthetic fixtures and an isolated PTY. That PTY run checks emitted
|
||||
terminal behavior, not manual readability in a specific terminal emulator.
|
||||
Alpine Testcontainers, disposable-VM systemd, live WireGuard and production
|
||||
configuration were not used for this presentation milestone. The user-directed
|
||||
M16 manual check has no recorded environment/transport detail and is not M18
|
||||
evidence. No host service was restarted and no Git commit was made.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Milestone 19 evidence: refactoring contract
|
||||
|
||||
Recorded 2026-10-29T22:23:28+02:00. Base commit:
|
||||
`23d6180f2478f636c185d67b8bb3788d21656ce3`. The working tree is dirty:
|
||||
`PLANS.md` already held the user-approved M19–M27 plan before execution, and
|
||||
this milestone changed `PLANS.md` and `CONTEXT.md` and added
|
||||
`docs/architecture/domain-refactoring.md` and this report. No Rust source,
|
||||
manifest, lockfile, production configuration, or generated secret was changed.
|
||||
The reviewed substantive document inputs have these SHA256 fingerprints:
|
||||
|
||||
| Input | SHA256 |
|
||||
| --- | --- |
|
||||
| `PLANS.md` (accepted snapshot) | `724bb710c2f8db7ceca7a059425be6a868e23ab9dd41cb1ff5ae5c32986a4b8e` |
|
||||
| `CONTEXT.md` | `06efa3f8bb91dbd3b7fb6a00f0035dde7c3be1c01d0a6fe9c096460d6d28deb8` |
|
||||
| `docs/architecture/domain-refactoring.md` | `7dbb01cfa85dd89a3d122ecf8a564dd204c805bea8a33cb09c4268d0308329c8` |
|
||||
|
||||
## Scope and acceptance mapping
|
||||
|
||||
M19.1 read-only inventory traced the domain/application, WireGuard document,
|
||||
runtime, persistence/backup, vault/import/source, delivery, startup, TUI,
|
||||
library, test-only container driver, and integration tests. M19.2 added a
|
||||
domain-only glossary and a source-referenced migration contract. The contract
|
||||
maps field/function authority, readers/writers, actual test names and M20–M27
|
||||
owners, and describes draft, reservation, verification, delivery, restore and
|
||||
restart transitions. It keeps the server document authoritative for malformed,
|
||||
metadata-free and unsupported peers, including valid subnet-route peers that
|
||||
can be verified technically without becoming manageable clients. Historical
|
||||
verification is distinct from current apply knowledge. This supports M19-A1–A3.
|
||||
|
||||
The caller audit found `ClientStates::draft_mut` used by app production code and
|
||||
colocated tests; `Application::vault_draft_mut` by one colocated test;
|
||||
`Application::add_client` and `Application::open` by integration/test fixtures;
|
||||
and `open_from_saved_store` by production startup and the test-only container
|
||||
driver. The contract retains compatibility until actual callers migrate.
|
||||
M20 tuple arity is six; M22–M26 planned focused commands name present targets.
|
||||
No dependency, schema, archive, UI or behavior change is implied.
|
||||
|
||||
The remaining behavior gap is explicit in the contract: current restore
|
||||
reservation coverage proves manageable saved clients, while
|
||||
`preview_restore` does not collect exact host routes from unprojectable current
|
||||
peers or `draft_unsupported_reservations`. M23 must add focused coverage and
|
||||
repair any uncovered preservation under the existing reserved-address policy.
|
||||
M19 does not claim that this behavior is already correct. M9 remains retired
|
||||
and unaccepted; M19 depends on accepted M18, not M9.
|
||||
|
||||
## Independent review and repair
|
||||
|
||||
A GPT-6-Luna/high implementer completed M19.1 and M19.2. A separate, fresh
|
||||
GPT-6-Luna/high reviewer inspected the actual source, callers, tests and
|
||||
documents. A GPT-6-Luna/high repairer corrected architecture wording; the
|
||||
coordinator alone corrected plan wording and later task commands. The reviewer
|
||||
rechecked each repair and reported no remaining actionable findings:
|
||||
|
||||
| Finding | Final disposition |
|
||||
| --- | --- |
|
||||
| M19-R1 | Closed: restore rewrites internal state, retains conservative reservations and clears historical verification. |
|
||||
| M19-R2 | Closed: historical M18 handoff no longer contradicts active M19. |
|
||||
| M19-R3 | Closed for this documentation milestone: current unprojectable-peer restore reservation gap and M23 focused test/repair are recorded. |
|
||||
| M19-R4 | Closed: programme and historical M17 handoff wording reflect the current plan. |
|
||||
| M19-R5 | Closed: M19.3 task row reflects the current finding disposition. |
|
||||
| M19-R6 | Closed: tuple arity and later focused test targets match the actual repository. |
|
||||
|
||||
This independent review supports M19-A4 subject to the final DOC checks below.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
Final integrated DOC checks ran in the repository root on the accepted plan
|
||||
snapshot. The exact link/whitespace command was:
|
||||
|
||||
```sh
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re, sys
|
||||
files = [Path(p) for p in ('PLANS.md', 'CONTEXT.md', 'docs/architecture/domain-refactoring.md', 'docs/evidence/milestone-19.md')]
|
||||
errors = []
|
||||
links = anchors = 0
|
||||
for file in files:
|
||||
body = file.read_text()
|
||||
if not body.endswith('\n'): errors.append(f'{file}: final newline')
|
||||
for number, line in enumerate(body.splitlines(), 1):
|
||||
if line.rstrip() != line: errors.append(f'{file}:{number}: trailing whitespace')
|
||||
for target in re.findall(r'(?<!!)\[[^]]+\]\(([^)]+)\)', line):
|
||||
if '://' in target or target.startswith(('mailto:', 'plugin:')): continue
|
||||
path, _, anchor = target.partition('#')
|
||||
dest = (file.parent / path).resolve() if path else file.resolve()
|
||||
links += 1
|
||||
if not dest.exists(): errors.append(f'{file}:{number}: missing {target}'); continue
|
||||
if anchor:
|
||||
anchors += 1
|
||||
headings = [re.sub(r'[^\w\- ]', '', h.lower()).strip().replace(' ', '-') for h in re.findall(r'^#{1,6}\s+(.+)$', dest.read_text(), re.M)]
|
||||
if anchor not in headings: errors.append(f'{file}:{number}: missing anchor {target}')
|
||||
print(f'{len(files)} files, {links} local links, {anchors} anchors, {len(errors)} errors')
|
||||
for error in errors: print(error)
|
||||
sys.exit(bool(errors))
|
||||
PY
|
||||
```
|
||||
|
||||
It exited 0: four files, 72 local links, zero anchors and zero errors. The
|
||||
new untracked documents were included in the final-newline and trailing-space
|
||||
check. The exact plan-consistency command was:
|
||||
|
||||
```sh
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re, sys
|
||||
plan = Path('PLANS.md').read_text()
|
||||
rows = [(int(i), state) for i, state in re.findall(r'^\|\s*(\d+)\s*\|[^\n]*?\|\s*(accepted|retired|pending|in_progress|in_review|needs_changes|blocked)\s*\|$', plan, re.M)]
|
||||
errors = []
|
||||
if [i for i, _ in rows] != list(range(1, 28)): errors.append('register numbering')
|
||||
states = dict(rows)
|
||||
if states.get(18) != 'accepted' or states.get(19) != 'accepted' or any(states.get(i) != 'pending' for i in range(20, 28)): errors.append('register states')
|
||||
record = plan.split('### Milestone 19 —', 1)[1].split('### Milestone 20 —', 1)[0]
|
||||
if 'Status: accepted' not in record or any(f'M19.{i} |' not in record for i in (1, 2, 3)): errors.append('M19 record/tasks')
|
||||
resume = plan.split('## Current progress and resume point', 1)[1].split('## Plan maintenance', 1)[0]
|
||||
if 'M19 is accepted' not in resume or 'Stop after M19' not in resume: errors.append('resume point')
|
||||
for target in set(re.findall(r'cargo test --locked --test ([a-z_]+)', plan.split('### Milestone 20 —', 1)[1].split('## Current progress and resume point', 1)[0])):
|
||||
if not Path(f'tests/{target}.rs').exists(): errors.append(f'missing test target {target}')
|
||||
if "prepared_destination's six-element tuple" not in plan: errors.append('tuple arity')
|
||||
print(f'{len(rows)} register IDs, M19 tasks/status/resume, future test targets and tuple: {len(errors)} errors')
|
||||
for error in errors: print(error)
|
||||
sys.exit(bool(errors))
|
||||
PY
|
||||
```
|
||||
|
||||
It exited 0: 27 ordered register IDs and no status, task, target or tuple
|
||||
errors. `git diff --check` exited 0. The coordinator also inspected factual
|
||||
claims and the final changed-file set; the independent reviewer compared
|
||||
the migration contract with production and test source. Rust formatting,
|
||||
Clippy, tests, release build, Alpine Testcontainers and disposable-VM checks
|
||||
were not required for documentation-only M19 and were not run. No production
|
||||
or live WireGuard access occurred.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Milestone 20 evidence: vocabulary and prepared contexts
|
||||
|
||||
Recorded 2026-10-29T22:38:53+02:00. Base commit
|
||||
`ede45d224a9ad255a919900e556a03d7e575430f` had a clean working tree.
|
||||
The final tree is dirty only for this milestone's `PLANS.md`, Rust source and
|
||||
test changes, and this evidence report. No `Cargo.toml` or `Cargo.lock` diff,
|
||||
new dependency, persisted-format change, production configuration, or secret
|
||||
fixture was introduced. The final Rust source/test patch fingerprint is
|
||||
SHA256 `cba97207f926f1927c70643754db2743603e0f72892115ef8d959696d769992a`,
|
||||
produced by:
|
||||
|
||||
```sh
|
||||
git diff -- src/app.rs src/domain.rs src/persistence.rs src/container_driver.rs src/tui/events.rs src/tui/render.rs src/tui/state.rs src/tui/terminal.rs tests/client_delivery.rs tests/commit_restore.rs tests/runtime_restart.rs tests/tui_workflow.rs | sha256sum
|
||||
```
|
||||
|
||||
The release artifact `target/release/wireguard-admin` built from those inputs
|
||||
has SHA256 `7a6ee46b1d9c39bac9a7b3627b0b8ba30e70321126ebd16b1948b42b8658ac98`.
|
||||
`file target/release/wireguard-admin` identified an x86-64 dynamically linked
|
||||
GNU/Linux PIE with interpreter `/lib64/ld-linux-x86-64.so.2`.
|
||||
|
||||
## Implementation and acceptance mapping
|
||||
|
||||
- M20.1 renamed the domain's current knowledge enum to `ApplyStatus`, retained
|
||||
`runtime::RuntimeObservation` for measured interface data, and migrated actual
|
||||
application, persistence, TUI and integration callers. The exact duplicate
|
||||
TUI `AppliedState` enum/mapping and now unnecessary `Statistics`,
|
||||
`TechnicalObservation` and `ObservedRuntime` aliases were removed. UI labels
|
||||
and current-versus-historical state transitions were preserved (M20-A1).
|
||||
- M20.2 replaced the six-value prepared delivery tuple and three-value restore
|
||||
tuple with private named contexts. A private `DeliveryMode` distinguishes
|
||||
first issue and reissue without changing public `reissue` flags. Neither
|
||||
context derives `Clone` or `Debug`; identity, attempt, generation, overwrite,
|
||||
cancellation, unrelated-command clearing, password ownership and QR/file
|
||||
order remain unchanged. A behavioral test covers cancel/unrelated-command
|
||||
clearing (M20-A2).
|
||||
- All affected production and test callers compile; no manifests, formats or
|
||||
transport behavior changed. Focused tests, final full RUST gates and
|
||||
independent review passed (M20-A3).
|
||||
|
||||
## Independent review and repair
|
||||
|
||||
A GPT-6-Luna/high implementer completed M20.1 and M20.2 sequentially. A
|
||||
separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated
|
||||
diff, production paths, callers, tests and secret-bearing contexts and found
|
||||
no behavior issue. The coordinator's first Clippy gate found M20-R1 (P2): two
|
||||
test/test-only `.clone()` calls on the now-`Copy` status. A separate
|
||||
GPT-6-Luna/high repairer changed only those two calls to dereferences in
|
||||
`src/app.rs` and `src/container_driver.rs`. The reviewer independently
|
||||
rechecked the repair and affected tests, closed R1 and found no new issue.
|
||||
No actionable finding remains.
|
||||
|
||||
## Checks and limits
|
||||
|
||||
Environment at execution: `rustc`/Cargo 1.97.1 on
|
||||
`x86_64-unknown-linux-gnu`, rustfmt 1.9.0 and Clippy 0.1.97, matching the
|
||||
pinned compiler. Focused implementer/reviewer runs covered `domain::` (13),
|
||||
`runtime::` (25), `tui::state::` (7), `tui::events::` (7),
|
||||
`--test commit_restore` (9), `--test runtime_restart` (3),
|
||||
`--test tui_workflow` (8), and `--test client_delivery` (5). After R1, the
|
||||
repairer ran `cargo test --locked app::` (58 passed) and
|
||||
`cargo test --locked --lib container_driver::` (23 passed, 2 ignored). The
|
||||
reviewer reran affected app/event tests. These are host synthetic/PTY checks;
|
||||
ignored Alpine selectors were not run.
|
||||
|
||||
| Final coordinator command | Actual result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after M20-R1 repair |
|
||||
| `cargo test --locked` | exit 0: 391 unit/integration tests passed, 3 ignored; 2 doctests passed; 0 failed |
|
||||
| `cargo build --locked --release` | exit 0; artifact SHA256 above |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
The initial Clippy run exited 101 on M20-R1 before repair and is not the final
|
||||
gate result. Default tests did not execute the three explicitly ignored
|
||||
Alpine/selected-daemon tests. No Alpine Testcontainers or disposable systemd VM
|
||||
run was required for this naming/context refactor or performed. Prior Alpine
|
||||
results are historical for earlier source and do not validate this artifact;
|
||||
real systemd/D-Bus and IPv6 limitations remain as previously recorded. No
|
||||
production, live VPN, host service or host network operation was performed.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Milestone 21 evidence — domain reservations and apply knowledge
|
||||
|
||||
Run: 2026-10-29T23:00:09+02:00. Base commit: `8413b382b178991d2e1e597f1422d147f3454581` (clean at start). Final dirty source inputs: `src/domain.rs`, `src/app.rs`, and `docs/PROJECT_LAYOUT.md`; `PLANS.md` and this evidence record are coordinator documentation. `git diff -- src/domain.rs src/app.rs docs/PROJECT_LAYOUT.md | sha256sum` returned `d9056e1fd05db2425a127ac57f906333ae311bdb0f3cce13b9153f39c041c982`. `target/release/wireguard-admin` SHA-256: `60b3ad8b9c1bf2750239d3500b319cf42e5581fe027e5b6e39c8ff4115feb024`. `Cargo.toml` and `Cargo.lock` have no diff. Rust and Cargo: 1.97.1, pinned toolchain.
|
||||
|
||||
## Implementation and invariant evidence
|
||||
|
||||
- M21.1: `AddressReservations` owns private committed and draft-only pending host-address sets. Staging validates the entire exact `/32` or `/128` route list before mutation. The allocator sees both sets. Draft discard drops pending addresses; successful Commit promotes pending and retains addresses removed from the prior manageable saved snapshot. Persisted restoration is crate-scoped and rejects saved-client conflicts. No serialization field or version changed.
|
||||
- M21.2: private `ApplyKnowledge` owns current runtime status and optional complete last-verified management snapshot. Historical verification remains separate in persisted application history. Session open resets current knowledge to `Unknown`; technical verification may publish `Verified` with no complete management snapshot. The restart scenario prepares a cloned verified candidate, then publishes it only after internal-state installation succeeds. Only that verified transition releases committed reservations. Backup, service, read, mismatch and persistence failure paths retain reservations.
|
||||
- M21-R1: independent review found unconditional Commit demotion of `Verified` and `Unavailable` on unchanged saved client snapshots. The repair removed the extra application call; the domain Commit transition still demotes `Verified` when the saved management snapshot changes. Three behavioral app tests cover unchanged `Verified`, unchanged `Unavailable`, and changed `Verified`. The same independent reviewer re-reviewed the repair and full affected reservation/apply paths, closed M21-R1, and reported no remaining actionable finding.
|
||||
|
||||
## Validation on integrated source
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||
| `cargo test --locked` | exit 0; 399 passed, 3 ignored (opt-in Alpine drivers), 0 failed, including 2 doctests |
|
||||
| `cargo build --locked --release` | exit 0 |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
Focused implementation, repair and review runs covered `domain::`, `persistence::`, `app::`, `--test commit_restore`, and `--test runtime_restart`; the final full test command supersedes those focused source results. The independent reviewer specifically inspected reservation mutation/release callers, pending/committed timing, exact-host policy, unchanged/changed Commit behavior, reopen/current versus historical state, legacy technical matching without a management snapshot, and failed verified publication.
|
||||
|
||||
## Scope and acceptance
|
||||
|
||||
M21-A1: passed by reviewed transition callers and synthetic failure-path tests. M21-A2: passed by legacy/reopen/history tests and the M21-R1 behavioral repair. M21-A3: passed by allocator, reservation, persistence and application tests, independent re-review and all required Rust gates. Evidence is host/synthetic. The opt-in Alpine suites and disposable systemd VM were not run for M21, and no real systemd, live VPN, production or IPv6 integration result is claimed. M9 remains retired/unaccepted. The previously recorded unprojectable-peer restore reservation gap belongs to M23 and is not represented as resolved here.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Milestone 22 evidence — coordinated draft editing
|
||||
|
||||
Run: 2026-10-29T23:14:00+02:00. Base commit: `2766c62` (clean at start). The integrated dirty source inputs are `src/app.rs`, new `src/app/draft.rs`, `src/domain.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce source identity with `sha256sum`:
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `a2ae18af4a5755397d86f36da3938baba01bc434da1b024aad64720267cebb7d` |
|
||||
| `src/app/draft.rs` | `4b4c7b4ef9f8d673dad44d017badad8edbe1c73c19a74c1454008229e8fb1cbc` |
|
||||
| `src/domain.rs` | `47740c902eee54053c1a4e46c7dacaf18204dc6366cb32d1189d6a04b293a516` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `500fd91848e5896153fa39a330131103a50a2f442e58ecfd6ad2bafc6ba9ba07` |
|
||||
| `target/release/wireguard-admin` | `1b955bc2858924811043f48a66bfc7e2180ff0ecbc6d93b8b0339b791a9646e5` |
|
||||
|
||||
The native host toolchain was rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, persistence and archive code have no diff.
|
||||
|
||||
## Implementation and independent review
|
||||
|
||||
M22.1 added private `DraftChanges` to own the lossless document, narrower client states with pending address reservations, and encrypted vault draft. Its explicit staged replacement publishes selected prepared components without saved-file or runtime effects. M22.2 moved create/add/rename/delete, discard, initial vault setup and selected-file import through that boundary. Fallible document parsing/projection, state insertion, vault serialization/encryption/import and saved-state reading finish before publication. Vault copies are made through encrypted TOML only when a vault-changing operation needs one; document-only edits keep the current vault. A new client's session-first-delivery material is registered after successful draft publication.
|
||||
|
||||
The unrestricted `ClientStates::draft_mut` and test-only public `Application::vault_draft_mut` methods were removed after caller audit. `Application` facade entry points remain. Three behavioral app tests cover a late missing-vault creation failure, selected import failure, and discard parse failure without partial draft or issue-state mutation. Layout documentation now names the actual draft module and existing integration targets.
|
||||
|
||||
Fresh GPT-6-Luna/high reviewer `/root/m22_review`, separate from the implementer, inspected the base-to-current diff and relevant startup, TUI, integration and container-driver callers. The reviewer found no actionable issue. Review covered authority, secret lifetime, all publication callers, unsupported peers, source-file preservation, public API, fixture restart scope and unchanged formats/dependencies. No repair cycle was needed.
|
||||
|
||||
## Validation on integrated source
|
||||
|
||||
| Exact command | Result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||
| `cargo test --locked` | exit 0; 401 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
|
||||
| `cargo build --locked --release` | exit 0 |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
The implementer and reviewer also ran the required focused suites: `cargo test --locked app::` (64), `cargo test --locked --test config_preservation` (10), `cargo test --locked --test client_delivery` (5), `cargo test --locked --test vault_import` (5), and `cargo test --locked --test tui_workflow` (8), all passed. The implementer additionally ran `cargo test --locked domain::` (15), passed. The final full test command supersedes the focused runs as integrated host evidence.
|
||||
|
||||
## Acceptance scope
|
||||
|
||||
M22-A1 is supported by staged publication and failure tests. M22-A2 is supported by byte-preserving document and import tests plus source review. M22-A3 is supported by synthetic app/TUI creation, Commit, first delivery, explicit fixture restart, deletion and discard coverage; draft and Commit flows make no live runtime call. M22-A4 is supported by the independent review and completion gates. This is host/synthetic evidence. The opt-in Alpine driver and disposable systemd VM were not run for M22; no real systemd, live VPN, production or IPv6 integration result is claimed. The M23 unprojectable-peer restore reservation gap remains assigned to M23 and is not claimed resolved here.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Milestone 23 evidence — saved-state Commit, backup, and restore scenarios
|
||||
|
||||
Run: 2026-10-29T23:32:25+02:00. Base commit: `c1d4f93` (clean at start). Final dirty source inputs are `src/app.rs`, new `src/app/commit.rs`, `src/app/restore.rs`, `src/app/backups.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce identity with `sha256sum`:
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `9fe74372a68182dc24d7061b4409d3dd6970bc77e3528560da2cacd9d70876b2` |
|
||||
| `src/app/commit.rs` | `e8af86c36613d91605c81c3dca82fb3d1b3b0dd9b41fdbfeaba9e84984fc091b` |
|
||||
| `src/app/restore.rs` | `5dd96ee9650ce66e17378eebea2adf18da746264814f0f0d6258a6ebbda651e4` |
|
||||
| `src/app/backups.rs` | `d837fb0dd29cd3affd9ae12233ecb0337e9a308d730b24a4c892c7bc2605e6ef` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `c9d873caf3c46d48392e6352b5aed036ce3fdaa471c66b4df8d92994c1a2f5ef` |
|
||||
| `target/release/wireguard-admin` | `44444967d552bbb7bac2d36423cc943cde2ced783190946aae67f12e0283541e` |
|
||||
|
||||
Native host toolchain: rustc/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, `src/persistence.rs`, and `src/backup.rs` have no diff.
|
||||
|
||||
## Implementation and review
|
||||
|
||||
- M23.1 extracted Commit into a private scenario: prepare and validate proposed bytes, parsed document, and next client state; complete a pre-Commit archive of the installed generation; perform journaled install; then publish the prepared in-memory state. A failed install reads back conservatively to retain or revoke session-first-delivery rights. The public `Application` facade and narrow test callback remain. No service or runtime call is made by Commit.
|
||||
- M23.2 extracted manual/pre-restart backup listing/creation and previewed restore. Preview is read-only and holds the exact installed generation and selected archive bytes. Confirmation rejects a stale generation before backup, prepares path-compatible settings/document/vault/state, completes a pre-restore archive, performs journaled full replacement, then publishes. An install failure clears session-only delivery rights and reports recovery uncertainty. Absent optional components and archive/historical labels retain their documented semantics. No vault password or runtime call is used.
|
||||
- The M19 exact-host restore gap is repaired: removed unprojectable peers' exact IPv4 `/32` and IPv6 `/128` routes are retained as reservations through reopen and allocation. Broad routes are not expanded into guessed addresses.
|
||||
- Fresh GPT-6-Luna/high reviewer `/root/m23_review`, separate from implementer `/root/m23_commit`, found M23-R1: newly carried routes were reserved even when still present in the restored document. GPT-6-Luna/high repairer `/root/m23_repair` separated archived reservations from carried current/pending addresses and excluded carried exact routes still owned by the restored document. Focused tests cover retained, removed, pending-deletion and archived-reservation cases. Independent re-review closed M23-R1 with no remaining code finding. M23-R2 identified that this evidence file was pending during the first review; it is now present for final verification.
|
||||
|
||||
## Integrated validation
|
||||
|
||||
| Exact command | Result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||
| `cargo test --locked` | exit 0; 404 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
|
||||
| `cargo build --locked --release` | exit 0 |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
Focused implementer/repairer/reviewer runs covered `cargo test --locked app::` (final 67), `cargo test --locked persistence::` (15), `cargo test --locked backup::` (8), `cargo test --locked --test commit_restore` (9), and `cargo test --locked --test client_delivery` (5), all passed on the final code. The full test command supersedes those focused runs as integrated host evidence. The independent review inspected backup-before-install order, saved-only dependencies, path/generation binding, optional components, secret handling, recovery and preserved formats.
|
||||
|
||||
## Acceptance scope and limits
|
||||
|
||||
M23-A1 is supported by reviewed operation ordering and backup/install/cancel failure tests. M23-A2 is supported by restored settings/path, optional-component, recovery, historical-status and old-archive tests. M23-A3 is supported by fixture old-state backups, compatibility tests, the saved-only dependency audit, independent re-review and required Rust gates. This is host/synthetic evidence. No opt-in Alpine or disposable systemd VM run was made for M23; no real systemd, live VPN, production or IPv6 integration behavior is claimed. M9 remains retired/unaccepted.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Milestone 24 evidence — session-owned delivery scenarios
|
||||
|
||||
Run date: 2026-10-30T21:23:54+02:00. Baseline commit: `ed602f0`; the working tree contains the uncommitted M24 source and documentation changes listed below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, host native target. Checks used synthetic fixtures. No production configuration, keys, VPN, or host service was accessed.
|
||||
|
||||
## Integrated inputs
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `a337a6c25db64f0a1422e0efa1bf67ecae06f7c59e74f6144d2964345d06117d` |
|
||||
| `src/app/first_delivery.rs` | `0814de038423a0b8886d216aa3cf2b0913337595fbde4624da93bdb610f1df62` |
|
||||
| `src/app/first_file.rs` | `770f833c99c42ac2b138a071bf4df26bbcaffcc431733380b846d600d8be8b6b` |
|
||||
| `src/app/reissue.rs` | `2bf5b2f967c55f08d53b6d1e5d3d727d2774d5ad97454b2c4eb2425fdacdd563` |
|
||||
| `src/app/commit.rs` | `eac859338dd11b8cb2cac8db2dc621d6862215998ad89701f6eadd6a6c2609d8` |
|
||||
| `src/app/restore.rs` | `1ac76556d31d3f1502c644195c3f479fa092c321ebb53e671911a632ecc91f35` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `302f2a7eb16def7e2c0810780a3e9805caea416915737ce4ab4033900a379306` |
|
||||
| `target/release/wireguard-admin` | `3aa6c56f32ed3da82c25c6da1f209c6dc55293998e497f57de0a2bd2c6c7a39a` |
|
||||
|
||||
No dependency, manifest, or lockfile changes. The release artifact hash identifies the binary built from the current integrated source; it is not a production deployment.
|
||||
|
||||
## Checks on integrated tree
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
|
||||
| `cargo test --locked delivery::` | Exit 0; 22 delivery tests passed. |
|
||||
| `cargo test --locked vault::` | Exit 0; 24 vault tests passed. |
|
||||
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
|
||||
| `cargo test --locked --test tui_workflow` | Exit 0; 8 tests passed. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc suites passed. Ignored Alpine suites were not selected. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
## Acceptance mapping and review
|
||||
|
||||
- **M24-A1:** `FirstDeliverySession` privately owns keys, bound context, stages and tokens. Existing app and client-delivery tests pass for pre-Commit, stale, consumed and reopened rights, Commit promotion, discard/delete, and independent rights across rename/restart.
|
||||
- **M24-A2:** App tests pass for retry to a different directory, uncertain publication and explicit overwrite/manual cleanup acknowledgement, post-publication bookkeeping uncertainty, activation warnings, same-payload file/QR, and independent QR failure. The new owner and authorization have no ordinary `Debug`, `Clone`, or serialization; public errors and projections remain redacted.
|
||||
- **M24-A3:** Reissue reads installed saved state, unlocks the selected record with the password supplied for that action, renders authoritative server values, writes the mandatory file before optional QR, and retains imported-record mismatch checks. `client_delivery` and vault tests pass.
|
||||
|
||||
Fresh independent reviewer `/root/m24_review` examined the combined implementation and ran the focused suites, formatting, Clippy, full tests, release build and diff check. No source correctness or security finding remained. **M24-R1** was a missing evidence record named by `PLANS.md`; the coordinator created this file after independently rerunning the required checks. Independent re-review of this record and its source hashes remains the next action before acceptance.
|
||||
|
||||
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; Alpine evidence would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M24.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Milestone 25 evidence — explicit restart verification scenario
|
||||
|
||||
Run date: 2026-10-30T21:35:10+02:00. Baseline commit: `7cef400`; the working tree has uncommitted M25 changes in `src/app.rs`, new `src/app/restart.rs`, and `docs/PROJECT_LAYOUT.md`, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures/adapters only. No production configuration, keys, VPN, host service, Docker environment, or VM was accessed.
|
||||
|
||||
## Integrated inputs
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `784001013f90d9bbdb1579cf3c6672a27dd34dc0e295d3e6ea3f70d53d05c586` |
|
||||
| `src/app/restart.rs` | `fb724054faec6adf1b58a31800a2a636307b2620ac3d980309638ad508a00fa3` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `8f156088800e469e6bbf7ac0f012afdb4bf1f190ab4c9161fb3c41d871e04df2` |
|
||||
| `target/release/wireguard-admin` | `b383a1f30e776dcce983bb123af6f4e474e71d6b3d28866f3a06ba3c423228b7` |
|
||||
|
||||
No dependency, manifest, lockfile, service-adapter or runtime-transport files changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
|
||||
|
||||
## Checks on integrated tree
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
|
||||
| `cargo test --locked runtime::` | Exit 0; 25 runtime tests passed. |
|
||||
| `cargo test --locked service::` | Exit 0; 26 service tests passed. |
|
||||
| `cargo test --locked --test commit_restore` | Exit 0; 9 tests passed. |
|
||||
| `cargo test --locked --test runtime_restart` | Exit 0; 3 tests passed. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc tests passed. The selected Alpine tests stayed ignored. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
## Acceptance mapping and review
|
||||
|
||||
- **M25-A1:** `restart.rs` contains the preview, validated preparation, completed pre-restart backup, one-use attempt, optional draft discard, service job, bounded runtime read, full comparison and durable publication in the original order. Cancel preserves draft and performs no backup/job/read. The service deadline is created after backup and shared with observation; both saved generation and attempt ID are checked before persistence.
|
||||
- **M25-A2:** Existing app/runtime/service tests cover legacy metadata-free, IPv4-only, subnet-route and missing-IPv4 valid peers; malformed or duplicate identities, incomplete observations, mismatches, service and transport distinctions, shared deadline exhaustion, and post-match persistence failure. `SavedPeerProjection` remains the complete technical target while a management `Snapshot` is optional. Reservations release only after durable verified success.
|
||||
- **M25-A3:** `Application::restart` still calls the existing `service::restart_systemd_unit_report` and `runtime::transport::observe_interface` adapters. The fixture apply method and container driver remain `#[cfg(test)]`; no rollback or second restart was introduced. `app` public restart types remain re-exported.
|
||||
|
||||
Fresh independent reviewer `/root/m25_review` examined the integrated source and original behavior, verified all five focused suites and Rust gates, and found no actionable source defect. **M25-R1** was the missing specified evidence artifact; the coordinator created this report after independently running all required commands. The reviewer independently rechecked the record and all four hashes and closed M25-R1; no finding remains.
|
||||
|
||||
This is host/fixture evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run, so real systemd/D-Bus and live IPv6 behavior remain unverified. Alpine would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M25.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Milestone 26 evidence — application read model and TUI command adapter
|
||||
|
||||
Run date: 2026-10-30T22:59:07+02:00. Baseline commit: `9a143e9`; the working tree contains uncommitted M26 source, test and layout changes below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures, buffer rendering and PTY workflows only. No production configuration, keys, VPN, host service, Docker environment or VM was accessed.
|
||||
|
||||
## Integrated inputs
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `5aeab061f45a75f3b4f4ec41b078cbecce3cbdd5a49ec2c47c0a98f062747023` |
|
||||
| `src/app/read_model.rs` | `92a1945ca0ad0d4f6dc7aad255643d3d515712a1750c466738d29655f69d282d` |
|
||||
| `src/app/first_delivery.rs` | `b9e80c7802b083f0ffa6e1e99898e9be9459e9eb9f3ea415b00e5592020a6ef3` |
|
||||
| `src/tui/state.rs` | `da429e61bf918bf8524b7d131bc553460eb260f76f39d3763824e399764dcfac` |
|
||||
| `src/tui/terminal.rs` | `9131ef9ae1e4d6f8a84715cdcdfcdd528262930dc95834991916484ff5ce4e21` |
|
||||
| `src/tui/render.rs` | `3fa3b6496b792f3d7d3db16405a847c91736f2e975e5dba892d6921ca367df19` |
|
||||
| `src/tui/events.rs` | `114c9505e69a1e9ec0782b149daf2ff9dcdc1a29af99abdb28df91fee70b758c` |
|
||||
| `src/tui/events/executor.rs` | `4a46a8b03b1f708ab89d2e50ceceb60674c30e3bc1bcbb4c87af93b57815cd57` |
|
||||
| `tests/tui_workflow.rs` | `8050c2e7887366ca6145c62fa8ded25b9dcd36f01062afa85ce85518ce64733f` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `06b83f3c0a26de4a7c3f8ccbaf1d37a697afb17cf67c297b69326018d144262c` |
|
||||
| `target/release/wireguard-admin` | `70b45b51e2e345e1d914b1196355fde9f5688f123bed47332690d56752ca5cbe` |
|
||||
|
||||
No dependency, manifest or lockfile changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
|
||||
|
||||
## Checks on integrated tree
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked app::` | Exit 0; 74 app tests passed. |
|
||||
| `cargo test --locked tui::` | Exit 0; 88 TUI tests passed. |
|
||||
| `cargo test --locked --test tui_workflow` | Exit 0; 9 tests passed. |
|
||||
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
|
||||
| `cargo fmt --all -- --check` | Exit 0. |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and doc tests passed. Opt-in Alpine scenarios stayed ignored. |
|
||||
| `cargo build --locked --release` | Exit 0. |
|
||||
| `git diff --check` | Exit 0. |
|
||||
|
||||
## Acceptance mapping and review
|
||||
|
||||
- **M26-A1:** `Application::read_model` owns the secret-free peer rows, saved peer count, draft/saved public client changes, issue classes, reservations, applied knowledge, vault/draft flags and per-action eligibility. `SessionView::from_application` consumes those facts and maps them into compatibility UI fields without accessing saved server bytes or `ClientStates`. Malformed, duplicate and no-key draft peer rows remain available for inspection, and removed saved peers outside manageable `ClientStates` remain visible.
|
||||
- **M26-A2:** Selection binds action presentation to public identity and peer index. App eligibility separates rename, deletion, first delivery, reissue and restart; execution still checks its own preconditions. Exact-host-route deletion remains available even for a peer outside the manageable client projection, and the removed row is inspection-only. First-delivery session availability is queried without secret disclosure. Unrelated saved-document issues do not suppress a valid selected reissue; selected-peer issues and vault conflicts do.
|
||||
- **M26-A3:** TUI, workflow and delivery suites cover worker generations, stale continuations and statistics, shutdown, QR, selection, list/help/footer buffers and synthetic PTY behavior. Existing dimensioned tests include 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50 cases. No assertions were weakened to pass the refactor.
|
||||
- **M26-A4:** `src/tui/events/executor.rs` owns command adaptation and prepared destination/restore contexts; `events.rs` keeps the single-owner worker queue and generation lifecycle. `docs/PROJECT_LAYOUT.md` matches the integrated modules. Broader terminal/render splitting was deferred because it would broaden visibility across shared lifecycle and layout helpers without a cohesive ownership gain in M26.
|
||||
|
||||
Fresh independent reviewer `/root/m26_review` reviewed the integrated diff. M26-R1 (removed unprojectable peer omitted) and M26-R2 (unrelated issue overblocked reissue) were fixed by repairer `/root/m26_repair`. Coordinator found M26-R3 (false Removed row for unchanged saved client) in the repair; it was fixed with a one-row regression. Reviewer found M26-R4 (reissue shown for selected capability-issue peer); the repair aligned eligibility with delivery validation and added a failing-before/passing-after regression. The reviewer independently re-reviewed and closed R1–R4 with no remaining source finding, then verified all 11 evidence hashes, reran the full test suite, app tests and release build, and found no evidence issue.
|
||||
|
||||
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; prior Alpine results do not identify this M26 source, and Alpine does not prove systemd behavior. Real systemd/D-Bus and live IPv6 behavior remain unverified. The retired M9 real-system limitation remains outside M26.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Milestone 27 evidence — integrated refactoring handoff
|
||||
|
||||
Recorded 2026-10-30T23:18:12+02:00. Baseline HEAD: `7123197ec4a3fad74b71f5c7b598f1d6fd74d3a1`, initially clean. Final working tree is uncommitted and changes `CONTEXT.md`, `PLANS.md`, `docs/PROJECT_LAYOUT.md`, `docs/architecture/domain-refactoring.md`, `src/app.rs`, `src/app/backups.rs`, `src/app/first_file.rs`, `src/app/restart.rs`, `src/runtime/netlink.rs`, and `src/service.rs`. No Cargo manifest, lockfile, dependency, feature, persisted codec, archive, vault, settings-default, WireGuard editor, or fixture file changed in M27. Rust/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97, native host target. All tests below used synthetic fixtures/PTY only; no production configuration, keys, VPN, host service, Docker daemon, or VM was accessed.
|
||||
|
||||
## Final inputs
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `CONTEXT.md` | `7dcfa4d86f70fb850f3416d6659275ee28237b5075c5beed82d2a33ad44150a4` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `f973207e71f20c6c912105c16546a51150acf63551a7f196ecd38d2101e9a44b` |
|
||||
| `docs/architecture/domain-refactoring.md` | `45ad18a43b224f318b0a7e55548d47f7ccea8e2e719e3378ef35f419e3ec8391` |
|
||||
| `src/app.rs` | `e235ff788c1b5f1ac0ac9951a9c4bb578205dfc65903d3ae80ed9f3142fb38da` |
|
||||
| `src/app/backups.rs` | `b3b01289b20e262c24cfce79c507fe4d3761102df4863a11b3a90b2398b0692f` |
|
||||
| `src/app/first_file.rs` | `d41fa3e2d2150c401ceee9caaeb7268b741a970105e5266123334cd78d977073` |
|
||||
| `src/app/restart.rs` | `5d26cb877fd8a8211549e15868ccd450729ce3ecb54ea954efd15b379f5a2ce0` |
|
||||
| `src/runtime/netlink.rs` | `396f4726880c7cf54764d6ff0c67aa1ab2d6c66cd36af7b1be4ca0e04862bab3` |
|
||||
| `src/service.rs` | `e7c7a4367e2a0dc8a87113e3083d8998e7aebb56f6d4c32d5c763b9f239fa8eb` |
|
||||
| `target/release/wireguard-admin` | `cb6e995d64a7cb7537c5fb17e86d609ca62fff150f519a970febd0800b0d4177` |
|
||||
|
||||
Source-input fingerprint from `rg --files src tests/support tests/containers tests/testcontainers_wireguard.rs Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum`: `1b746e31e2e8c96f511c34b51e417b486d65fa0cc17983fa15e12882dd9c6a02`. This includes the final test-only backup visibility repair and identifies the host-tested source independently of the dirty plan/evidence files.
|
||||
|
||||
## Architecture and preservation audit
|
||||
|
||||
M19's baseline source was recorded against `23d6180f2478f636c185d67b8bb3788d21656ce3`. The coordinator inspected `git diff --stat` from that commit through M26 HEAD and the changed production/format-sensitive file list; that diff spans the M20–26 application/domain/TUI moves and changes only persistence test references, not its codec. `Cargo.toml`, `Cargo.lock`, settings defaults, backup/vault/byte-preserving editor implementation, and synthetic fixture files did not change in that baseline-to-M26 diff. The independent reviewer inspected the integrated refactor against the M19 map, current source and shared preservation matrix, including authority, public identity, unsupported/ambiguous peers, original imports, retained plaintext discipline, short-lived delivery secrets, saved/runtime separation, restart uncertainty, address release, TUI eligibility/generation and public visibility. No unresolved behavior, authorization, or secret-handling regression was found. Historical M19 restore-reservation gap was closed by M23 and is now mapped to actual tests and owner.
|
||||
|
||||
The architecture document now gives the three requested end-to-end reading paths with entry point, rule owner, side-effect boundary and failure tests, and resolves each M19 owner/transition to actual M20–26 modules/tests. The glossary remains domain-only and the layout lists present files. `ClientStates::draft_mut` and `Application::vault_draft_mut` were already removed. `Application::open`, `add_client`, and `display_projection` have active unit/integration or test-only container callers and remain with explicit caller/retention rationale; production startup/create uses the intended methods. A fixture-only pre-restart backup facade and its helper were restricted to private `#[cfg(test)]` code. Stale comments describing implemented confirmation, D-Bus and netlink flows as future work were corrected. No test assertion was weakened.
|
||||
|
||||
Existing preserved-format and behavior coverage includes the focused `config_preservation`, `commit_restore`, `client_delivery`, `runtime_restart`, `vault_import`, and `tui_workflow` suites below, plus persistence, archive, vault, domain, service and application tests in the full suite. Old fixture inputs still load with unchanged meaning under these host checks; this is synthetic compatibility evidence, not a new real-system run.
|
||||
|
||||
## Integrated checks
|
||||
|
||||
All commands ran in the repository root after the final source repair. Exit 0 unless specified.
|
||||
|
||||
| Command | Actual result |
|
||||
| --- | --- |
|
||||
| `cargo test --locked --test config_preservation` | 10 passed, 0 failed |
|
||||
| `cargo test --locked --test commit_restore` | 9 passed, 0 failed |
|
||||
| `cargo test --locked --test client_delivery` | 5 passed, 0 failed |
|
||||
| `cargo test --locked --test runtime_restart` | 3 passed, 0 failed |
|
||||
| `cargo test --locked --test vault_import` | 5 passed, 0 failed |
|
||||
| `cargo test --locked --test tui_workflow` | 9 passed, 0 failed |
|
||||
| `cargo fmt --all -- --check` | Exit 0 |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0 |
|
||||
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and 2 doc tests passed; opt-in Alpine tests stayed ignored |
|
||||
| `cargo build --locked --release` | Exit 0; artifact hash above |
|
||||
| `git diff --check` | Exit 0 |
|
||||
|
||||
The first final-gate attempt after making the fixture facade test-only found Clippy dead code in its child helper. The repair made the child helper test-only too; the entire table above was rerun on that final source. The initial failed attempt is diagnostic history, not a passing gate.
|
||||
|
||||
## Independent review and limits
|
||||
|
||||
Fresh reviewer `/root/m27_review` was separate from implementer `/root/m27_implement`; repairer `/root/m27_repair` made bounded fixes. R1 (missing `display_projection` caller audit), R2 (stale plan resume point), R3 (future-work source comments), and R4 (fixture-only public backup API) were repaired and re-reviewed. R4's first repair exposed the Clippy dead-code helper; the second repair and final gates closed it. The reviewer independently verified the final source, fingerprint and binary hashes, reran formatting, Clippy, full tests, release build and diff check, and confirmed M27-A4 plan/evidence consistency after the register correction. No scoped review finding remains.
|
||||
|
||||
M8's last Alpine result used fingerprint `b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`; today's final source fingerprint differs. The M8 run is historical and **does not validate this source**. No explicitly selected current disposable daemon/driver was provided for M27, so Alpine revalidation is incomplete and no Alpine pass is claimed. The retired M9 disposable-VM/systemd checks remain unverified; Alpine would not prove actual systemd/D-Bus behavior. Real IPv6 capability/traffic and release readiness are also unverified. M27 acceptance is limited to host/synthetic scope.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Milestone 8 reproducible evidence
|
||||
|
||||
This report records disposable Alpine fixture results. It does not claim
|
||||
production or systemd/D-Bus behavior. Current acceptance state is in
|
||||
[PLANS.md](../../PLANS.md); superseded attempts are in the
|
||||
[history](../history/milestone-8-before-workflow-cleanup.md) and focused
|
||||
[diagnostics](../diagnostics/milestone-8.md).
|
||||
|
||||
## M8.5 import/reissue and preceding scenarios
|
||||
|
||||
- Recorded: 2026-10-24T23:14:00+03:00. Run occurred earlier on 2026-10-24;
|
||||
an exact start timestamp was not captured.
|
||||
- Source identity: Git HEAD
|
||||
`2ccd67d64cd9ef4210ac3dc369473b3849319649` with dirty tracked and
|
||||
untracked M8 files. The reproducible input fingerprint is SHA256
|
||||
`e7aeaf13444ac6350db66cd6c34fb3a46f4ee0723eb18b8ebe0b0054e1672fd0`.
|
||||
It was identical before the pinned driver build and after the real run. The
|
||||
fingerprint was generated by:
|
||||
|
||||
```sh
|
||||
rg --files src tests/support tests/containers tests/testcontainers_wireguard.rs Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum
|
||||
```
|
||||
|
||||
- Builder: `rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`,
|
||||
target `x86_64-unknown-linux-musl`; Rust 1.97.1. The locked, offline driver
|
||||
build passed:
|
||||
|
||||
```sh
|
||||
docker run --rm --platform linux/amd64 --mount type=bind,src=/home/sasa/share/rust/wireguard-admin,dst=/src,readonly --mount type=bind,src=/tmp,dst=/out -e CARGO_TARGET_DIR=/out/wga-m8-build-target -e CARGO_HOME=/out/wga-m8-cargo-home -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --offline --lib --no-run --target x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
- Driver: `/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031`,
|
||||
SHA256 `e250d8eb60db0ad262b404624fa7f9d984f807d8688c055e9f8629178c00b151`.
|
||||
- Runtime image: `wireguard-admin-alpine:3.22.7`, image ID
|
||||
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`,
|
||||
Linux/amd64. Selected disposable local Docker daemon:
|
||||
`unix:///var/run/docker.sock`, ID
|
||||
`1baad80e-d79d-49ab-b19a-fb00fd3662d7`, Docker Server 29.1.3,
|
||||
host Linux `6.18.18-gentoo` x86_64.
|
||||
- Exact selected-daemon command:
|
||||
|
||||
```sh
|
||||
DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1
|
||||
```
|
||||
|
||||
- Result: exit 0, 1 passed, 0 failed, 0 ignored, 25 filtered out, 82.06s.
|
||||
The integrated scenario exercised existing and new IPv4 peers, Commit and
|
||||
delivered-file traffic, saved deletion and explicit fixture Apply, two
|
||||
previewed password-free restores with saved/runtime separation, and import
|
||||
of matched/conflicting/invalid private sources followed by password-gated
|
||||
reissue and traffic from the reissued bytes. It used the real Rustix reader
|
||||
for peer/routes/handshake/counter assertions. Exact-name Docker container
|
||||
and network listings were empty after the run.
|
||||
- Independent disposition: M8.5-IMP-01 closed on re-review after checking all
|
||||
three source-file byte comparisons and the regression for a changed invalid
|
||||
candidate. Earlier traffic, deletion and restore slice findings were closed
|
||||
independently as recorded in the plan.
|
||||
- Limit: this is one Alpine fixture result for the fingerprint above. Later
|
||||
source edits invalidate it as final integrated evidence. User-directed IPv6
|
||||
capability/traffic tests were skipped. No real systemd job was exercised.
|
||||
|
||||
## M8.5 mandatory-backup failure
|
||||
|
||||
- Source-input fingerprint: SHA256
|
||||
`b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`, stable
|
||||
before the pinned driver build and after the real run. Driver SHA256:
|
||||
`ad45dc3f6b0987b075ac522e171c82c6651124c3d01c65c14df90595fe552815`.
|
||||
- Builder: `rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`.
|
||||
Runtime image: `wireguard-admin-alpine:3.22.7`, ID
|
||||
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`.
|
||||
Selected disposable daemon: `unix:///var/run/docker.sock`, ID
|
||||
`1baad80e-d79d-49ab-b19a-fb00fd3662d7`.
|
||||
- Exact selected-daemon command:
|
||||
|
||||
```sh
|
||||
DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1
|
||||
```
|
||||
|
||||
- Result: exit 0, 1 passed, 0 failed in 81.45s. The scenario required a typed
|
||||
backup error and verified unchanged saved/runtime state, no fixture apply,
|
||||
retained address reservation, and the expected reader missing-interface and
|
||||
deadline errors. Exact-name checks found no remaining test containers or
|
||||
networks.
|
||||
- Limit: this was a completed M8.5 slice result before the final gates and
|
||||
repeated full scenario runs recorded below. IPv6 capability/traffic was
|
||||
excluded by user direction; no real systemd job was exercised.
|
||||
|
||||
## M8.7 integrated gates and repeated Alpine scenario
|
||||
|
||||
- Recorded: 2026-10-25T20:40:51+03:00. Git HEAD remained
|
||||
`2ccd67d64cd9ef4210ac3dc369473b3849319649` with dirty milestone files.
|
||||
The source-input fingerprint command above returned
|
||||
`b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`
|
||||
before the rebuilt driver and after all three successful real runs.
|
||||
- The pinned Alpine runtime image rebuilt entirely from cached layers (ID
|
||||
`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`,
|
||||
linux/amd64). Networkless, read-only, capability-dropped readiness exited 0;
|
||||
`apk info -v` listed the 33 constrained packages. The pinned Rust 1.97.1
|
||||
Alpine builder produced the x86_64 musl test driver in 1m38s using the host's
|
||||
cached public Cargo registry mounted read-only after the earlier `/tmp` cache
|
||||
disappeared. The static PIE driver's SHA256 was
|
||||
`ad45dc3f6b0987b075ac522e171c82c6651124c3d01c65c14df90595fe552815`.
|
||||
The exact rebuild command was:
|
||||
|
||||
```sh
|
||||
docker run --rm --platform linux/amd64 --mount type=bind,src=/home/sasa/share/rust/wireguard-admin,dst=/src,readonly --mount type=bind,src=/tmp,dst=/out --mount type=bind,src=/home/sasa/.cargo/registry,dst=/out/wga-m8-cargo-home/registry,readonly -e CARGO_TARGET_DIR=/out/wga-m8-build-target -e CARGO_HOME=/out/wga-m8-cargo-home -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --offline --lib --no-run --target x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
- Integrated host gates on this source revision: `cargo fmt --all -- --check`,
|
||||
`cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`,
|
||||
`cargo build --locked --release`, release `--help` and `--version`, and
|
||||
`git diff --check` all exited 0. Default tests included 258 passing library
|
||||
tests with two explicitly ignored driver tests, five delivery, nine
|
||||
Commit/restore, nine configuration, two runtime, 25 host harness with one
|
||||
ignored container scenario, six TUI, five vault import, and two doctests.
|
||||
Default tests did not execute the real-container scenario.
|
||||
- The selected-daemon command in the preceding section ran twice against this
|
||||
fingerprint with fresh mutable resources: exit 0, 1/1 passed each time,
|
||||
80.77s and 80.77s. The documented unfiltered
|
||||
`cargo test --locked --test testcontainers_wireguard -- --ignored --test-threads=1`
|
||||
form then ran with the same `DOCKER_HOST` and `WIREGUARD_ADMIN_TEST_*` values:
|
||||
exit 0, 1/1 passed in 81.87s; 25 nonignored host tests were filtered out.
|
||||
Each run exercised the full M8.5 scenario, including real IPv4 traffic and
|
||||
Rustix observations, saved/runtime separation, deletion and verified
|
||||
reservation release, both restores, import/reissue, and mandatory-backup
|
||||
failures. Exact-name container and network listings were empty after each.
|
||||
- Independent integrated review found no code defect. Its R1 stale testing and
|
||||
evidence statements, R2 general IPv6 gate, and R3 stale milestone summary
|
||||
were corrected and each closed on independent re-review. M8 acceptance state
|
||||
is recorded in PLANS.md.
|
||||
- Scope: fixture Apply is test-only and does not prove a systemd/D-Bus job.
|
||||
IPv6 capability and traffic were deliberately not tested by user direction;
|
||||
production dual-stack behavior is still specified and unverified here. No
|
||||
production configuration, key, or service was accessed.
|
||||
@@ -0,0 +1,47 @@
|
||||
# TUI new-client creation timestamp repair
|
||||
|
||||
Validated on 2026-10-27 at 20:39 +02:00. Bounded maintenance task
|
||||
TUI-TS-001 repairs the new-client form path from the accepted M7 workflow.
|
||||
No production, Alpine, or VM execution was performed.
|
||||
|
||||
## Behavior and review
|
||||
|
||||
The form now clears nanoseconds on its UTC instant before RFC3339 formatting.
|
||||
The metadata renderer and its exact `YYYY-MM-DDTHH:MM:SSZ` validation are
|
||||
unchanged. A deterministic test supplies a fixed UTC instant with nonzero
|
||||
nanoseconds, checks its exact whole-second output, and sends that output to
|
||||
the metadata renderer. A synthetic workflow enters an alias and blank IPv4 and
|
||||
IPv6 form fields, submits the resulting command through the application worker,
|
||||
and observes a dual-stack draft client. It checks that saved settings, server
|
||||
configuration, internal state, and vault bytes are unchanged. The test submits
|
||||
no Commit or restart command. Independent GPT-6-Luna/high reviewer found no
|
||||
actionable scoped findings after examining the actual diff and running both
|
||||
focused regressions.
|
||||
|
||||
## Integrated checks and identity
|
||||
|
||||
Native `x86_64-unknown-linux-gnu` host; rustc/Cargo 1.97.1, rustfmt 1.9.0,
|
||||
Clippy 0.1.97. Commands below ran from the repository root and exited 0:
|
||||
|
||||
- `cargo test --locked creation_timestamp_discards_fractional_seconds_and_renders_as_valid_metadata`: 1 passed.
|
||||
- `cargo test --locked --test tui_workflow create_form_submits_automatic_dual_stack_client_without_changing_saved_state`: 1 passed.
|
||||
- `cargo fmt --all -- --check`.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`.
|
||||
- `cargo test --locked`: 345 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests.
|
||||
- `cargo build --locked --release`.
|
||||
- `git diff --check`.
|
||||
|
||||
Base commit: `6d8d6d572df73a534b9a8dc7c5af769f2447bcfa`. Dirty Rust
|
||||
inputs are the two files below; documentation changes record this repair.
|
||||
SHA256 of changed Rust files:
|
||||
|
||||
- `src/tui/forms.rs`: `322153ae33e85b8dc59d887ff7810751f15e2326ce77e7f1105c9aab947fe9be`
|
||||
- `tests/tui_workflow.rs`: `9f14c4bf9b80e65c3f6f5ccae93192015d738590b672f11d26aa824feb103438`
|
||||
|
||||
Native artifact `target/release/wireguard-admin` SHA256:
|
||||
`d59801b0de32038ae11bbfed3d4bd01f4c36fe4119e79f3b332fec5462793687`.
|
||||
|
||||
These fixture results do not verify a physical terminal, Alpine networking,
|
||||
or real systemd/D-Bus behavior. The previously retired M9 acceptance remains
|
||||
unverified. Previous Alpine and VM source/artifact identities do not identify
|
||||
this revision.
|
||||
@@ -0,0 +1,55 @@
|
||||
# TUI-FL-001 evidence: stable keyboard footer
|
||||
|
||||
Validated 2026-10-27T22:59:34+02:00 on the native
|
||||
`x86_64-unknown-linux-gnu` development host with synthetic
|
||||
Ratatui/TestBackend fixtures. No production configuration, host service, or
|
||||
systemd VM was used.
|
||||
|
||||
## Behavior and review
|
||||
|
||||
The standard TUI layout now reserves four footer rows from the first draw:
|
||||
two hint rows and their borders. The client-list region therefore keeps the
|
||||
same height as selection and available actions change. Hints use a stable
|
||||
priority order and fit into two bounded rows. A first packing pass shows every
|
||||
hint when they fit; only actual overflow triggers a second pass reserving a
|
||||
visible `… more` marker on the last row. The existing `? Help` route remains
|
||||
visible in narrow list layouts. The source of context-sensitive hints and the
|
||||
keyboard action handlers were not changed. Terminals with 8 or 9 rows use the
|
||||
compact layout so the client pane retains visible content.
|
||||
|
||||
Buffer-level tests cover empty/populated/selected list geometry at 80×24,
|
||||
narrow widths 34/36/40, the compact 80×9 case, and widths 102/103 where all
|
||||
actions fit without a false omission marker. Tests check complete rendered
|
||||
labels and the marker row, not just source-side packing. Existing TUI workflow
|
||||
tests cover the unchanged action behavior.
|
||||
|
||||
An independent GPT-6-Luna/high reviewer found TUI-FL-001.2-R1 (P2, clipped
|
||||
marker on the first row) and R2 (P2, border-only list at height 9). A separate
|
||||
GPT-6-Luna/high repairer fixed both; re-review closed them. Re-review then found
|
||||
R3 (P3, unnecessary omission from reserving marker space too early). The
|
||||
repairer added a two-pass packer and a 102/103-column regression. Independent
|
||||
re-review closed R3 with no further actionable finding.
|
||||
|
||||
## Checks and identity
|
||||
|
||||
Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
|
||||
The final integrated revision passed from the repository root:
|
||||
|
||||
- `cargo test --locked tui::render::tests`: 31 passed.
|
||||
- `cargo fmt --all -- --check`: exit 0.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`: exit 0.
|
||||
- `cargo test --locked`: 370 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests.
|
||||
- `cargo build --locked --release`: exit 0.
|
||||
- `git diff --check`: exit 0.
|
||||
|
||||
Base commit: `4b53aad20cff6cec5d23f779702b58f0de599d44`.
|
||||
Dirty Rust input `src/tui/render.rs` SHA256:
|
||||
`ceae0bec7add49304a38f38987fdac554fc83e9d7d23695cf148f2f8404d3016`.
|
||||
Native release artifact `target/release/wireguard-admin` SHA256:
|
||||
`a16214c426b0cc9b29ec03124e965352e4f64f07c82d3ac23e4caf8e45d6273b`.
|
||||
`PLANS.md` was also dirty with this maintenance record. The M16 private-systemd
|
||||
validation gap remains separate and unverified; no Alpine or VM suite was rerun.
|
||||
|
||||
Very narrow layouts intentionally show only priority hints plus the Help
|
||||
affordance; additional shortcuts remain available through the existing Help
|
||||
screen. A physical-terminal visual check was not performed.
|
||||
@@ -0,0 +1,42 @@
|
||||
# TUI navigation repair
|
||||
|
||||
Validated on 2026-10-26 at 22:32 +02:00. Bounded maintenance; M9 remains
|
||||
paused and unaccepted. No production, Alpine or VM execution was performed.
|
||||
|
||||
## Behavior and review
|
||||
|
||||
List Up/Down previously called the details-opening selection method. The repair
|
||||
separates highlighting from opening: arrows update selection, Enter opens the
|
||||
selected peer, and Esc returns to the list. Existing detail/help/message scrolling
|
||||
is preserved. Unsupported peers remain inspectable without mutation identity.
|
||||
Independent reviewer NAV-001 identified an initial-highlight mismatch; the repair
|
||||
treats the renderer's implicit first row as current. Re-review closed NAV-001
|
||||
with no remaining actionable scoped findings.
|
||||
|
||||
Regression coverage exercises initial Down, initial Up wrap, explicit selection,
|
||||
Enter, Esc, scroll reset and unsupported peer inspection. Focused command
|
||||
`cargo test --locked tui::terminal::tests::` passed 7/7.
|
||||
|
||||
## Integrated checks and identity
|
||||
|
||||
Rust/Cargo 1.97.1, native x86_64 GNU host. All commands exited 0:
|
||||
|
||||
- `cargo fmt --all -- --check`
|
||||
- `cargo clippy --locked --all-targets -- -D warnings`
|
||||
- `cargo test --locked`: 323 passed, 3 explicitly ignored, none failed,
|
||||
including 2 documentation tests. Ignored real-environment tests were not run.
|
||||
- `cargo build --locked --release`
|
||||
- `git diff --check`
|
||||
|
||||
Base commit: `f4a6540df9621c9c5e5d1430243f92e749356a59`. Dirty inputs include
|
||||
the navigation repair plus pre-existing documentation/VM preparation changes;
|
||||
the latter were preserved. SHA256 of changed Rust files:
|
||||
|
||||
- `src/tui/state.rs`: `3b9ea1540c26a372c781a8194cedff74bb56320a698ae28e56ce22f38d159c5d`
|
||||
- `src/tui/terminal.rs`: `368bf0be11c35202127b7ac5c95f62a312f9de9b464de6e6816cb4b7523cdb1e`
|
||||
|
||||
Native artifact `target/release/wireguard-admin` SHA256:
|
||||
`de5b7efe9f8615fcb13d8779a86451d62e64c3f1da976ba706d6773f8a573d9e`.
|
||||
This is host build and fixture evidence only; target-server deployment and
|
||||
interactive SSH confirmation remain unverified. Previous release artifact
|
||||
identities do not identify this build.
|
||||
@@ -0,0 +1,702 @@
|
||||
# Milestone 8 historical execution record
|
||||
|
||||
Archived on 2026-10-24 during documentation workflow cleanup. This is a
|
||||
historical snapshot, including superseded and contradictory status statements.
|
||||
Use [PLANS.md](../../PLANS.md) for current state. Evidence below is preserved
|
||||
as previously reported; no checks were rerun by this archival operation.
|
||||
|
||||
### Milestone 8 — Alpine Testcontainers integration
|
||||
|
||||
Status: in progress (2026-10-22; user removed IPv6 capability and traffic
|
||||
testing from M8. The selected disposable daemon remains the test environment;
|
||||
M8.4–M8.5 and real-container acceptance remain unrun).
|
||||
Prerequisites: milestones 1–7 accepted; pinned Rust toolchain; an explicitly
|
||||
selected disposable Docker-compatible Linux environment with kernel WireGuard,
|
||||
scoped NET_ADMIN and IPv4 support; an Alpine-compatible test executable.
|
||||
Missing prerequisites are blockers/incomplete evidence, never passing tests.
|
||||
|
||||
#### Outcome and scope
|
||||
|
||||
Add an opt-in Rust Testcontainers suite using small Alpine server/client images.
|
||||
Exercise the actual application, encrypted vault/persistence/delivery APIs, and
|
||||
M5's real rustix Generic Netlink reader inside the server container against real
|
||||
kernel WireGuard interfaces. Establish real tunnels, generate IPv4 traffic,
|
||||
and prove that saved changes remain separate from runtime application.
|
||||
The user explicitly excluded IPv6 capability and traffic tests on 2026-10-22.
|
||||
Production dual-stack behavior in SPEC.md is unchanged; real IPv6 behavior is
|
||||
therefore unverified by this milestone.
|
||||
|
||||
Read SPEC.md Operations and states, Connection delivery, Backups and restoration,
|
||||
Client screen and statistics, and acceptance criteria; ADRs 0001–0003; TESTING.md,
|
||||
ENVIRONMENT.md, RUNTIME.md, DELIVERY.md, VAULT_FORMAT.md, PERSISTENCE.md,
|
||||
BACKUP_FORMAT.md, DEPENDENCIES.md and current application/worker code.
|
||||
|
||||
This is real-WireGuard integration, not real-systemd acceptance. Alpine fixtures
|
||||
have no systemd; a test-only apply operation may configure the interface explicitly.
|
||||
Actual zbus service job behavior, production restart and VM acceptance remain M9.
|
||||
M7's synthetic TUI/PTY evidence remains separate; M8 does not require live terminal
|
||||
screens, phone QR decoding, public Internet access, external DNS, production
|
||||
configuration, new product commands, or a production apply mode.
|
||||
|
||||
#### Existing code and integration constraints
|
||||
|
||||
- M1–M7 are accepted. The normal executable now starts the management TUI; do not
|
||||
invoke it with host/production settings as a shortcut for a test driver.
|
||||
- Application has open-from-store, setup/import, creation, Commit, delivery/reissue,
|
||||
deletion, backup/list/restore and confirmed restart flows. Use these real APIs,
|
||||
not a parallel shell-only client manager that would leave application behavior
|
||||
untested. Public-key identity and source preservation remain authoritative.
|
||||
- Real observation is `runtime::transport::observe_interface`, currently crate-
|
||||
private, with the validated decoder and bounded rustix transport. Do not use
|
||||
only `wg show` output or a fake observation as evidence that this reader works.
|
||||
- `Application::restart` always invokes the actual zbus path, while injected
|
||||
restart functions are private. Plan an in-crate test seam so a test-only fixture
|
||||
service operation can apply peers and the real observation/persistence path can
|
||||
verify them. Preserve backup ordering and per-attempt authority from M5.
|
||||
- Prefer cfg(test) in-container test scenarios/driver code, compiled for musl and
|
||||
selected explicitly, to reach private adapters without exposing an unauthenticated
|
||||
public apply API. If another arrangement is needed, document its compile-time
|
||||
isolation before implementation. No environment variable may switch production
|
||||
restart to a fake service or bypass password/backup/root checks.
|
||||
- The TUI ApplicationWorker already has synthetic workflow tests. An optional
|
||||
in-container worker scenario may test its real statistics reader, but network
|
||||
acceptance must not depend on automating every terminal key sequence again.
|
||||
- No Testcontainers crate, container image recipe or integration driver is present.
|
||||
Add approved `testcontainers` as a dev-dependency with compatible pinned version
|
||||
and minimal features. Prefer its supported synchronous facade if it avoids a
|
||||
direct executor dependency. Other direct helpers, Docker client crates or
|
||||
Tokio still require prior agreement; transitive dependencies are not new direct
|
||||
approvals. Use Cargo.lock and inspect official APIs for the selected version.
|
||||
- Native tools use system Rust 1.97.1; musl readiness was previously unverified.
|
||||
Recheck rather than assuming rust-toolchain.toml installed targets or a linker.
|
||||
|
||||
#### Context and implementation steps
|
||||
|
||||
Use GPT-6-Sol/medium coordination and GPT-6-Luna/high implementation, fresh review
|
||||
and repairs. Execute bounded tasks sequentially with one writer and explicit file
|
||||
ownership. The coordinator alone updates milestone acceptance and evidence.
|
||||
|
||||
| Task | Owner | Files and deliverable |
|
||||
| --- | --- | --- |
|
||||
| M8.1 | implementer | `docs/TESTING.md`/ENVIRONMENT additions and test support: exact opt-in architecture, prerequisites, image/build and driver contracts |
|
||||
| M8.2 | implementer | `tests/containers/alpine/Dockerfile`, fixture-only setup scripts, musl build recipe: small reproducible cached image and executable compatibility checks |
|
||||
| M8.3 | implementer | `tests/testcontainers_wireguard.rs`, `tests/support/` modules and Cargo files: host orchestration, private network/container ownership, prerequisite checks, bounded execution and cleanup |
|
||||
| M8.4 | implementer | in-crate cfg(test) scenarios/private adapters, test driver coordination: real netlink reads, fixture apply and application state verification inside server |
|
||||
| M8.5 | implementer | container scenarios and TESTING/README documentation: required lifecycle, restore, delivery/import/failure assertions and executed evidence |
|
||||
| M8.6 | reviewer, repairer | Independent isolation, real-adapter coverage and negative-test review; bounded fixes and real-suite re-review |
|
||||
| M8.7 | coordinator | Default and explicit container gates, redacted results, prerequisites/limitations, acceptance and M9 handoff |
|
||||
|
||||
M8.1 defines two execution locations: host harness uses Testcontainers to manage
|
||||
containers; server-side code uses the real application and kernel reader in that
|
||||
container's namespaces. Document how driver sessions persist across staged create,
|
||||
Commit, first delivery and apply phases, and how the client is configured from the
|
||||
actual delivered file. Do not lose the creation session between Commit and first
|
||||
issue or reconstruct password-free authority on reopen.
|
||||
|
||||
Choose a named ignored integration target/scenarios or another explicit opt-in
|
||||
mechanism that default cargo test never starts. The host suite must also opt into
|
||||
all in-container scenario processes. Ensure --ignored does not select unrelated
|
||||
privileged tests. Fail an explicitly requested suite nonzero on missing required
|
||||
prerequisites; never return early with a successful test result labelled skipped.
|
||||
|
||||
M8.2 pins an Alpine base release/digest and records the resolved image identity
|
||||
and package versions. Install only wireguard-tools, iproute2 and minimal fixture
|
||||
requirements. Use BusyBox capabilities when sufficient; justify any extra package
|
||||
in the test recipe. Runtime images contain no Cargo registry/compiler or systemd.
|
||||
Build/cache image layers and compatible driver artifacts before repeated tests;
|
||||
use fresh mutable containers/volumes for each scenario, not reusable dirty servers.
|
||||
Keep build context small with explicit copies/ignore rules, no .git, host Backup,
|
||||
credentials or private configuration. Never bake generated keys into image layers.
|
||||
|
||||
Build server/client driver code for x86_64-unknown-linux-musl or in a pinned
|
||||
Alpine builder matching the toolchain. Inspect dynamic requirements and actually
|
||||
execute a harmless readiness command inside Alpine. Do not rely on a glibc host
|
||||
binary, target name or successful host compilation as runtime compatibility proof.
|
||||
Only build required test targets; avoid pulling compiler/debug layers into the
|
||||
small runtime fixture. No global package installation or toolchain update is
|
||||
implicitly authorized by a missing cross-build prerequisite.
|
||||
|
||||
M8.3 checks Docker API access and explicit disposable-environment selection.
|
||||
Do not use a configured remote daemon without establishing that it is the selected
|
||||
non-production test environment. Probe WireGuard interface creation inside
|
||||
a disposable probe container, never by creating host interfaces or loading modules.
|
||||
Container tools alone do not supply the host kernel module. Refuse to repair
|
||||
prerequisites via SYS_MODULE, privileged mode or host sysctl changes automatically.
|
||||
|
||||
Create a dedicated uniquely named IPv4 network with a test subnet that does
|
||||
not overlap the tunnel ranges. Run server/client as container root with scoped
|
||||
NET_ADMIN in their own network/PID namespaces; no host networking, namespace joins
|
||||
to host, host D-Bus, host /etc/wireguard, Docker socket mount, or production binds.
|
||||
No host UDP publication is needed for container-to-container endpoints. Use
|
||||
container-internal reachable endpoint/port values, not localhost mapped ports.
|
||||
All settings/server/vault/output paths are private fixture directories created
|
||||
with the application's required permissions.
|
||||
|
||||
Use Testcontainers copy/exec APIs or an explicitly safe bounded transport for
|
||||
fixture data. Secret-bearing configs/passwords never enter command arguments,
|
||||
image metadata, public environment variables or raw stdout/stderr logs. Any
|
||||
necessary copied plaintext exists only in private temporary/container storage.
|
||||
Generate keys per scenario. Reports carry public keys, phase/status and redacted
|
||||
categories, not full configurations, QR matrices or `wg show all dump` output.
|
||||
|
||||
Bound image/start/driver/readiness/traffic/teardown operations. Poll explicit
|
||||
conditions with monotonic deadlines, not fixed sleeps. Use unique ownership and
|
||||
RAII/explicit cleanup for containers, networks and volumes on failures as well as
|
||||
success; cleanup must not mask the original failure or delete unrelated resources.
|
||||
Do not default to retain-failed-containers with secrets. Report leftover owned
|
||||
resources if daemon failure prevents cleanup.
|
||||
|
||||
M8.4 keeps fixture `ip`/`wg` calls solely in setup or test-only apply code. It may
|
||||
use wg-quick strip or equivalent explicit fixture setup, but must not execute
|
||||
arbitrary imported hooks. Handle optional DNS/MTU fields in delivered configs via
|
||||
a documented fixture setup, without mutating the delivered payload just to pass.
|
||||
Avoid an unneeded resolvconf dependency by configuring networking explicitly when
|
||||
appropriate. No subprocess apply path is added to production.
|
||||
|
||||
Configure endpoint reachability outside the tunnel before full-tunnel policy so
|
||||
an encrypted UDP endpoint is not recursively routed into its own VPN. Set full
|
||||
IPv4 routes within the client's isolated namespace only. Exercise traffic
|
||||
to server tunnel addresses; an external Internet gateway/DNS is not required and
|
||||
successful tunnel reachability does not claim public Internet egress works.
|
||||
Ensure test success cannot come from the Docker underlay: target tunnel-only
|
||||
addresses, use an explicit interface/route assertion, and verify public counters
|
||||
through the actual rustix reader. Test-only service apply reports must be labelled
|
||||
fixture results, never actual systemd job evidence.
|
||||
|
||||
If fixture restart orchestration verifies and releases application reservations,
|
||||
use the existing private adapter seam with a real backup, fixture apply, actual
|
||||
netlink read/comparison and journaled verification. Do not simply call
|
||||
mark_applied or clear reservations to satisfy assertions. A direct fixture apply
|
||||
outside that orchestration proves only kernel state change; it cannot by itself
|
||||
prove the production successful-restart state transition.
|
||||
|
||||
M8.5 implements these required scenarios, splitting tests where isolation helps:
|
||||
|
||||
1. **Baseline and reader interoperability.** Bring up a server plus an existing
|
||||
client with synthetic configuration, establish real IPv4 tunnel traffic,
|
||||
and observe the configured interface's complete peer set, routes, nonzero fresh
|
||||
handshake and increasing counters through the actual Rust reader. Capture
|
||||
before/after counters, not exact packet-byte totals. Empty/not-yet-handshaken
|
||||
observations must not be misreported as a reader failure or online heuristic.
|
||||
2. **Create, Commit, deliver, then apply.** Use Application to prepare a new client,
|
||||
Commit and save the actual first-delivery file (QR optional). Assert saved
|
||||
server/vault change, mandatory pre-Commit archive, unchanged runtime peers and
|
||||
explicit activation warning. Configure a fresh client from delivered bytes;
|
||||
prove it cannot reach tunnel-only targets before apply. Apply through the
|
||||
test-only boundary, then require real handshake and IPv4 traffic.
|
||||
3. **Saved deletion versus live access.** Delete and Commit an active client;
|
||||
assert current vault record removal, existing backup retention, and unchanged
|
||||
runtime peer/access before apply. Reopen saved state to verify the IP remains
|
||||
reserved. After fixture restart plus real verification/persistence, assert
|
||||
peer removal, failed fresh tunnel traffic and address release. Use a finite
|
||||
negative-check window and establish a working baseline so unrelated setup
|
||||
failure cannot masquerade as successful revocation.
|
||||
4. **Backup/restore separation.** Save a known client set, mutate/Commit, preview
|
||||
and restore the archive without providing a vault password. Assert restored
|
||||
settings/server/vault/state and precautionary backup, plus unchanged kernel
|
||||
state until explicit fixture apply. Then verify the restored peer set and
|
||||
renewed tunnel traffic. Restoration alone never releases pending addresses.
|
||||
5. **Import, reissue and file safety.** Import a valid generated existing client
|
||||
plus invalid/conflicting candidates from private source files. Assert original
|
||||
bytes unchanged, anomalies visible and conflicts blocked. Reopen and reissue
|
||||
a matched client through password-authorized output; verify wrong password
|
||||
fails and a valid delivered file can configure a real tunnel. Preserve first-
|
||||
issue/password boundaries and do not add plaintext source fallback.
|
||||
6. **Failure prevents application.** Produce a deterministic backup failure in the
|
||||
container (not chmod-only when root can bypass it) and assert Commit/fixture
|
||||
restart does not install/apply prepared changes or release addresses. Cover
|
||||
default reader deadline/missing-interface behavior inside its namespace without
|
||||
changing host state. Keep already exhaustive malformed/archive/failure-unit
|
||||
cases in ordinary tests rather than duplicating them in every costly container.
|
||||
|
||||
For all negative reachability assertions, stop/restart client traffic as needed
|
||||
and use new requests; stale timestamps/counters or buffered packets cannot prove
|
||||
continued/revoked access. Use a fixture endpoint/traffic generator with its own
|
||||
bounded error classification. Record that IPv6 traffic is intentionally untested.
|
||||
|
||||
#### Acceptance and validation
|
||||
|
||||
- [ ] M8-A1: documented opt-in commands run an actual Testcontainers suite with
|
||||
pinned/cached Alpine runtime images and compatible in-container driver; default
|
||||
tests/help/version do not start Docker, require root or query host interfaces.
|
||||
- [ ] M8-A2: explicit prerequisite checks reject unavailable daemon/kernel/capability
|
||||
or incompatible artifacts as incomplete/failure; no production or automatic
|
||||
privileged/module/sysctl workaround is used.
|
||||
- [ ] M8-A3: isolated real peers establish IPv4 traffic, and the actual
|
||||
rustix reader reports complete public peer/routes, fresh handshake and traffic
|
||||
increments without relying solely on wg text or fake decoded observations.
|
||||
- [ ] M8-A4: new client creation/Commit/file delivery use real Application and vault
|
||||
APIs; the delivered configuration works only after explicit fixture application,
|
||||
and Commit itself never changes runtime state.
|
||||
- [ ] M8-A5: saved deletion removes only current secret, keeps archives and retains
|
||||
reservation/live peer until explicit apply; actual observation and journaled
|
||||
verification precede reservation release. Fresh traffic is revoked afterward.
|
||||
- [ ] M8-A6: password-free restore replaces the saved set but does not apply it;
|
||||
fixture apply restores real tunnel behavior. Source files remain unchanged and
|
||||
imported conflict/wrong-password outputs remain blocked.
|
||||
- [ ] M8-A7: deterministic mandatory-backup failure prevents protected write/apply;
|
||||
negative traffic and missing-interface results cannot pass due to unrelated
|
||||
broken setup or a silent empty/partial observation.
|
||||
- [ ] M8-A8: fixture adapter cannot be selected in production or bypass root/password/
|
||||
backup controls; no public alternate restart API or runtime environment bypass
|
||||
is introduced. Alpine results explicitly exclude actual zbus/systemd coverage.
|
||||
- [ ] M8-A9: secret-free diagnostics, private copied files, limits/deadlines and
|
||||
cleanup work on both success and failure; only test-owned resources are removed.
|
||||
- [ ] M8-A10: integrated default and real-container gates pass; a clean repeated run
|
||||
reuses image layers while starting fresh mutable scenarios and produces the same
|
||||
behavioral outcomes. Independent review closes actionable isolation/coverage issues.
|
||||
|
||||
Planned host commands once the proposed test target and documented image/driver
|
||||
build steps exist, from repository root:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo test --locked
|
||||
cargo build --locked --release
|
||||
cargo test --locked --test testcontainers_wireguard -- --ignored --test-threads=1
|
||||
./target/release/wireguard-admin --help
|
||||
./target/release/wireguard-admin --version
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Document and execute exact image-build and in-container driver-build commands in
|
||||
TESTING.md before the explicit suite command; include prerequisites and output
|
||||
artifact paths. The in-container target must exist before claiming those commands
|
||||
work. A musl cargo test --no-run build is one possible cfg(test) driver mechanism;
|
||||
resolve its actual compilation/link/dependency implications rather than listing
|
||||
an unverified artifact name. Network downloads/build setup are distinct from
|
||||
locked test execution, and new dependencies require the normal approval policy.
|
||||
|
||||
Record image identities, kernel/runtime/toolchain versions, target architecture,
|
||||
scenario names and actual outcomes/durations/counts. All required scenarios must
|
||||
execute and pass before M8 acceptance; compile-only/default-test success with
|
||||
unavailable Docker is not scoped acceptance of M8. Do not use a successful
|
||||
ignored-test summary as evidence that ignored tests ran. Real systemd remains M9.
|
||||
|
||||
#### Progress
|
||||
|
||||
- [x] Description prepared against accepted M1–M7 and approved Alpine methodology.
|
||||
- [x] M8.1 — implementer `/root/m8_1` prepared the host/container contract in
|
||||
`docs/TESTING.md` and `docs/ENVIRONMENT.md`; documentation link and diff checks
|
||||
passed. Fresh independent reviewer `/root/m8_1_review` raised M8.1-R1, which
|
||||
`/root/m8_1_repair` corrected and the reviewer closed on re-review. Exact
|
||||
driver transport remains an M8.4 decision against the pinned Testcontainers API.
|
||||
- [x] M8.2 — implementer `/root/m8_2` added a pinned Alpine runtime recipe,
|
||||
allowlisted context, package record and harmless readiness script, plus
|
||||
documentation. Fresh reviewer `/root/m8_2_review` raised M8.2-R1/R2;
|
||||
`/root/m8_2_repair` repaired both and independent re-review closed them.
|
||||
Selected-daemon image build and rebuild with 33 exact package constraints,
|
||||
image inspection (`sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5`,
|
||||
linux/amd64), read-only/networkless/capability-dropped readiness and full
|
||||
`apk info -v` lock match passed. Rust driver execution awaits M8.4.
|
||||
- [x] M8.3 — implementer `/root/m8_3` added the approved Testcontainers 0.28.0
|
||||
blocking development dependency and named ignored host probe. Focused fmt,
|
||||
Clippy and default locked tests passed; explicit selected-daemon run failed
|
||||
nonzero before network creation because Docker lacks IPv6 iptables support.
|
||||
Independent reviewer `/root/m8_3_review` raised M8.3-R1/R2/R3; targeted
|
||||
repairs by `/root/m8_3_repair` and independent re-reviews closed all three.
|
||||
This completes the bounded host-probe implementation, not the blocked
|
||||
real-container M8 acceptance. No tunnel scenario has run.
|
||||
- [x] M8.3 scope revision — `/root/m8_ipv4_revision` changed only the
|
||||
test harness and guidance to use isolated IPv4 networking as requested by the
|
||||
user. Default gates pass; the selected-daemon probe passed 1/1 after R4–R7
|
||||
repairs. Independent `/root/m8_ipv4_review` re-reviews closed all findings.
|
||||
- [x] M8.4 — implementer `/root/m8_4_driver` added the in-crate test-only driver
|
||||
and private runtime/application seams; musl builder prerequisite research is
|
||||
complete from `/root/m8_musl_explore`. The exact ignored readiness and
|
||||
Application smoke run in the pinned Alpine image. Independent
|
||||
`/root/m8_4_review` and `/root/m8_4_host_review` raised R1–R4; targeted
|
||||
repairers closed all findings on re-review. The coordinator rebuilt the final
|
||||
musl test binary, reran the selected-daemon smoke (1/1, 4.05s), and found no
|
||||
leftover named resources. M8.5 scenarios remain pending.
|
||||
- [ ] M8.5 — `/root/m8_5_traffic` owns the first bounded scenario set:
|
||||
delivered-file client transfer, baseline real peer observation, pre-Apply
|
||||
nonreachability and post-Apply IPv4 tunnel/handshake/counter proof. Deletion,
|
||||
restore, import/reissue and backup-failure scenarios follow sequentially;
|
||||
independent review and full real-suite evidence are pending. Local focused
|
||||
support tests currently pass 18/18 with one ignored privileged target, and
|
||||
default `cargo test --locked` passed 250 library tests plus other suites;
|
||||
the current musl driver built in the pinned Alpine builder (5m06s). The first
|
||||
selected-daemon traffic run reached the scenario but failed a stale pre-Apply
|
||||
assertion that expected zero peers even though the new required baseline peer
|
||||
remained applied. The assertion is being changed to require exactly that
|
||||
baseline and exclude the new peer; no traffic acceptance is claimed yet.
|
||||
A later bounded run reached fixture Apply but `preview_restart` rejected the
|
||||
raw baseline peer because it lacked managed alias metadata. The synthetic
|
||||
fixture is being corrected through `ConfigDocument::insert_legacy_metadata`
|
||||
before saved state opens. Cleanup completed after that failed run; real
|
||||
traffic acceptance is still pending.
|
||||
The next real run passed baseline handshake/counters, new-client Create,
|
||||
Commit and delivery, pre-Apply peer exclusion, verified fixture Apply and
|
||||
IPv4 ping. It failed only an overly exact host route-string assertion:
|
||||
actual new-peer routes include product-required `0.0.0.0/0` and `::/0`.
|
||||
The assertion is being made IPv4-specific; no IPv6 capability or traffic
|
||||
test is being added. A final successful traffic suite is still pending.
|
||||
A subsequent reader sample after IPv4 ping showed the new peer's fresh
|
||||
handshake and nonzero received/transmitted counters (916/732 bytes in that
|
||||
run). The host assertion still used the wrong route perspective: on the
|
||||
server, the peer's AllowedIPs are its assigned tunnel hosts, not the client's
|
||||
full-tunnel routes. The assertion is being corrected to the assigned IPv4
|
||||
`/32`; the delivered client file is where `0.0.0.0/0` belongs. No passing
|
||||
end-to-end traffic summary is claimed yet.
|
||||
The host then added an immediate post-Apply/pre-ping reader sample and
|
||||
requires both received and transmitted counters to rise after a new IPv4
|
||||
ping. The worker's final turn stalled after implementing this assertion;
|
||||
coordinator stopped that turn, ran the exact selected-daemon test once and
|
||||
observed 1/1 pass in 16.25s. Exact-name Docker container/network listings
|
||||
were empty afterward. Fresh independent `/root/m8_5_traffic_review` is
|
||||
inspecting the integrated scenario. That reviewer raised M8.5-R1 (P2):
|
||||
the fresh client used a host-derived setconf copy rather than consuming the
|
||||
intact copied first-delivery file, so traffic did not prove the artifact is
|
||||
usable. M8.5-R2 (P2): route assertions accepted unintended extra IPv4
|
||||
prefixes. `/root/m8_5_traffic_repair` owns targeted fixes and real-suite
|
||||
rerun. Exact route assertions apply to IPv4 only per user direction; no IPv6
|
||||
capability or route check is added. This bounded slice is not yet accepted;
|
||||
remaining M8.5 scenarios have not started.
|
||||
The repair added private client-copy byte comparison, in-container
|
||||
`wg-quick strip` consumption of the copied file, and exact IPv4 route-set
|
||||
assertions. The first sandboxed selected-daemon attempt was denied Docker
|
||||
access; the elevated rerun reached the scenario and failed nonzero at the
|
||||
fixed category `copied client configuration could not be safely stripped`.
|
||||
The strip prerequisite/fixture invocation is being diagnosed with synthetic
|
||||
inputs; no secret config was logged and no repaired traffic pass is claimed.
|
||||
Synthetic diagnostics isolated the cause to a copied `.conf` basename longer
|
||||
than wg-quick's 15-character interface-name limit. The repair uses short
|
||||
fixed private names without changing file bytes. The worker turn stalled
|
||||
after editing; coordinator stopped it and ran the repaired selected-daemon
|
||||
test once: 1/1 pass in 14.23s, with no named container/network afterward.
|
||||
Focused host target passed 21 tests with one ignored privileged test; fmt and
|
||||
diff checks passed. Independent R1/R2 re-review was interrupted by an agent
|
||||
usage limit before a finding report, so `/root/m8_5_traffic_review` resumed
|
||||
on 2026-10-24. That independent re-review closed both findings with no new
|
||||
issue: the copied first-delivery bytes are compared privately and consumed
|
||||
by wg-quick in the client namespace; exact IPv4 route sets reject extra
|
||||
prefixes. The bounded baseline/new-client traffic slice is complete as
|
||||
Alpine fixture evidence. `/root/m8_5_delete` now owns saved deletion versus
|
||||
live access. Its local fmt, full locked tests (251 library passed, two
|
||||
ignored), all-target Clippy, release build and focused saved-deletion test
|
||||
passed; pinned musl rebuild passed. The coordinator's exact selected-daemon
|
||||
run failed 1/1 after saved deletion: the baseline client's fresh IPv4 ping
|
||||
stopped before explicit fixture Apply. This is a real-scenario failure, so
|
||||
deletion is not accepted. `/root/m8_5_delete_repair` owns diagnosis and a
|
||||
targeted repair. A fresh continuity probe moved immediately after the prior
|
||||
new-client fixture Apply failed 1/1 on the same selected daemon in 14.25s;
|
||||
the baseline disruption therefore precedes deletion Commit. A separate
|
||||
sandbox-only attempt could not access Docker and supplies no scenario evidence.
|
||||
A bounded selected-daemon diagnostic run then failed 1/1 in 15.52s and
|
||||
reported the baseline peer still present with exact IPv4 route but no fresh
|
||||
handshake, zero reset counters, no learned endpoint and three lost pings
|
||||
after full fixture `wg setconf`. The repair is testing explicit reconnect of
|
||||
the existing client after that fixture apply, then requiring new traffic and
|
||||
reader evidence before continuing the deletion assertions. That reconnect
|
||||
passed far enough to reach deletion Apply. A pinned musl rebuild and exact
|
||||
selected-daemon run reported `RestartStage::Complete`, followed by fixture
|
||||
phase failure; inspection identified the release assertion parsing the
|
||||
client's CIDR as bare `IpAddr`. A targeted typed-address repair and rerun are
|
||||
complete. The next exact selected-daemon run reached post-deletion observation
|
||||
and exposed that full fixture `wg setconf` resets the baseline handshake and
|
||||
counters again; the harness now proves the exact retained/deleted peer set
|
||||
immediately, then reconnects the retained client and requires fresh traffic
|
||||
and reader counter deltas while fresh traffic from the deleted client fails.
|
||||
The coordinator's integrated exact run passed 1/1 in 24.88s, host support
|
||||
tests passed 22 with one privileged test ignored, focused CIDR validation
|
||||
passed, fmt/diff checks passed, and exact-name Docker container/network
|
||||
listings were empty. Fresh independent `/root/m8_5_delete_review` raised
|
||||
M8.5-R3 (P2): the negative ping helper treats any Docker CLI nonzero exit,
|
||||
including failed `docker exec`, as lost tunnel traffic. `/root/m8_5_delete_repair`
|
||||
is changing it to require a bounded ping summary with transmitted packets and
|
||||
zero received packets. The repaired host support target passed 23 tests with
|
||||
one ignored, the coordinator's exact selected-daemon rerun passed 1/1 in
|
||||
42.96s, and exact-name container/network listings were empty. Independent
|
||||
`/root/m8_5_delete_review` closed R3 on re-review with no new finding:
|
||||
completed zero-reply traffic is distinguished from Docker execution and
|
||||
timeout errors. The saved-deletion bounded slice is accepted as Alpine IPv4
|
||||
fixture evidence. `/root/m8_5_restore` implemented the two-restore scenario:
|
||||
pre-delete archive re-adds a saved client while runtime remains baseline-only;
|
||||
an earlier baseline archive later removes a saved live client and retains its
|
||||
address reservation until verified fixture Apply. Local fmt, focused tests,
|
||||
locked full tests, all-target Clippy, release build and diff check passed.
|
||||
Pinned musl rebuild passed, but the coordinator's first selected-daemon run
|
||||
failed 1/1 after 7.47s because the host archive-name parser selected the
|
||||
driver's `status=started` line rather than its completed result line. The
|
||||
targeted parser repair passed focused tests and the next real run advanced
|
||||
through the first password-free restore, explicit Apply and renewed restored
|
||||
client traffic. It failed 1/1 after 58.28s at the second restore of the
|
||||
baseline archive (`phase=restore status=started` only). A separate repairer
|
||||
is diagnosing that in-container phase with bounded diagnostics before another
|
||||
pinned musl rebuild, selected-daemon rerun and independent review.
|
||||
Import/reissue and backup-failure slices remain pending.
|
||||
- [ ] M8.6 — independent implementation reviews and repairs complete for
|
||||
M8.1–M8.3 only; M8.4–M8.5 and integrated real-scenario review pending.
|
||||
- [ ] M8.7 — prior default coordinator gates pass; the revised explicit
|
||||
real-container gate and remaining scenarios are pending. Acceptance incomplete.
|
||||
|
||||
#### Review and repair
|
||||
|
||||
Record stable finding IDs, iteration, location/impact,
|
||||
owner, fix evidence and disposition. Review actual namespace/daemon targeting,
|
||||
secret transport/logs, capability/mount boundaries, endpoint/full-route setup,
|
||||
real-reader invocation and state-persistence proof. Re-review negative tests so
|
||||
underlay connectivity or an unrelated failed setup cannot produce false success.
|
||||
The user-authorized IPv6 testing exclusion applies only to M8 integration tests;
|
||||
do not replace real IPv4 observations with mocks.
|
||||
|
||||
M8.1 R1 review dispatched after coordinator inspected the two-file documentation
|
||||
diff. M8.1-R1 (low): `docs/TESTING.md` linked `SyncRunner` under the wrong
|
||||
Testcontainers module; repair changed the link to `runners/trait.SyncRunner.html`.
|
||||
Independent re-review closed the finding. Both rounds reported `git diff --check`
|
||||
passing on the M8.1 docs; link/consistency checks passed. No Docker evidence.
|
||||
|
||||
M8.2 R1 review of the actual image recipe/docs raised two P2 findings:
|
||||
M8.2-R1 (`runtime-check.sh`): planned libtest artifact cannot accept the
|
||||
script's custom `--readiness` argument. M8.2-R2 (Dockerfile/package record):
|
||||
direct package pins still resolve dependencies from a mutable Alpine repository,
|
||||
and the record/inspection omits the installed closure. Targeted repair
|
||||
`/root/m8_2_repair` addressed both. Reviewer confirmed the pinned direct package
|
||||
versions and Alpine platform digest from primary indexes, plus passing shell
|
||||
syntax, documentation link and diff checks. R1 re-review closed both recipe
|
||||
findings after the repair. `runtime-check.sh` now uses a libtest-compatible
|
||||
exact ignored selector; the test itself remains pending M8.4. The full installed
|
||||
closure was recorded as 33 exact constraints; a second build succeeded with the
|
||||
lock, and read-only shell readiness and inspection passed on the selected daemon.
|
||||
The reviewer did not independently rerun the Docker commands. Coordinator also
|
||||
ran a disposable `--network none --cap-add NET_ADMIN` Alpine probe that created
|
||||
and removed `wg-m8-probe` and added an IPv6 address inside that namespace
|
||||
(exit 0); no host interface, module, or sysctl operation was performed.
|
||||
|
||||
M8.3 R1 review raised M8.3-R1 (P2, `tests/testcontainers_wireguard.rs`): the
|
||||
outer timeout detaches a worker that may later create/hold test resources, so
|
||||
timeout cleanup is not guaranteed. First repair used a killed child and
|
||||
label-verified cleanup, but independent re-review kept R1 open because a
|
||||
Testcontainers request can run for 120 seconds and finish after the 75-second
|
||||
kill/cleanup pass. Second repair made the probe synchronous, retained finite
|
||||
per-request limits, added conservative aggregate documentation and explicitly
|
||||
reports uncertain late daemon completion. Independent re-review closed R1.
|
||||
The same re-review raised M8.3-R2 (P2, `container_harness.rs`): fixture exec
|
||||
stream reads can block indefinitely if a command hangs, invalidating the
|
||||
documented aggregate bound. First R2 repair wrapped commands in BusyBox
|
||||
`timeout`, capped retained output and drained streams, but independent re-review
|
||||
kept R2 open because the host attach read can still block after the stream is
|
||||
established. Coordinator separately verified BusyBox timeout in the cached
|
||||
Alpine image with a harmless delayed command (one-second elapsed inside,
|
||||
status 137); this does not prove the host stream is bounded. Second R2 repair
|
||||
uses a bounded Docker CLI exec transport and independent re-review closed R2.
|
||||
That re-review raised M8.3-R3 (P2, `container_process.rs`): the CLI exit code
|
||||
for an in-container timeout was discarded, so a timed-out command could be
|
||||
misclassified as a generic capability failure. Targeted repair mapped CLI exit
|
||||
codes 124/137/143 to the fixed timeout category, and independent re-review
|
||||
closed R3. No open M8.1–M8.3 review finding remains. The host-side bounded CLI
|
||||
exec path is not runtime-tested because network creation fails first.
|
||||
The selected-daemon explicit harness failed before network/container creation
|
||||
with `Docker host lacks required IPv6 iptables support`; separate disposable
|
||||
regular, internal and routed dual-stack network attempts failed on missing
|
||||
IPv6 NAT, `DOCKER-INTERNAL` filter and `DOCKER` filter chains respectively.
|
||||
No host networking, firewall, module, or sysctl remediation was attempted.
|
||||
|
||||
M8.4 R1 independent review of `src/container_driver.rs` raised M8.4-R1 (P1):
|
||||
the only runnable ignored driver test is harmless readiness; no bounded
|
||||
in-container phase protocol retains an Application across create, Commit,
|
||||
delivery and fixture apply, so the new seam cannot yet reach real Rustix
|
||||
observation or lifecycle assertions. M8.4-R2 (P2): `SetWireGuard` lexical path
|
||||
validation allows a symlink under the fixture root to escape. Focused 7 driver
|
||||
argument tests passed, but no live fixture command ran. Targeted repair is
|
||||
pending; no M8.4 acceptance is claimed.
|
||||
|
||||
M8.4 R1/R2 first repair by `/root/m8_4_repair` added an ignored
|
||||
`alpine_scenario_driver`, a bounded typed stdin/stdout phase protocol retaining
|
||||
one Application through Create, Commit, first delivery and fixture Apply, plus
|
||||
real Rustix verification through the test-only restart seam. It also added
|
||||
symlink component rejection and a regular-file/confined-path check for
|
||||
`wg setconf`; 10 focused driver tests, default tests (244 library tests, two
|
||||
ignored), fmt, all-target Clippy, release build and diff check passed. One
|
||||
initial default run had a transient TUI `Busy` failure; immediate rerun passed.
|
||||
Independent `/root/m8_4_review` re-review closed R2. It kept R1 open (P2):
|
||||
the existing host harness has no bounded stdin channel to invoke the driver,
|
||||
and the protocol does not yet cover M8.5's remaining lifecycle operations.
|
||||
The next bounded repair is host protocol transport and a real in-container
|
||||
Create/Commit/Deliver/Apply smoke path; M8.5 owns the additional scenarios.
|
||||
After the 2026-10-23 daemon/session interruption, coordinator inspection found
|
||||
no host-transport edits from the interrupted worker. `/root/m8_4_host_repair`
|
||||
was dispatched with ownership of the host harness and test-only driver to
|
||||
resume R1; no scenario acceptance is inferred from the prior musl readiness.
|
||||
The worker added bounded `docker exec -i` phase exchange and a private synthetic
|
||||
fixture bootstrap. Its current musl driver rebuilt in the pinned Alpine builder
|
||||
in 3m04s; focused host/driver checks and all-target Clippy passed. On the
|
||||
selected daemon, a first smoke attempt failed before application phases with a
|
||||
generic Docker category and left no owned resources. A diagnostic rerun passed
|
||||
link-up, IPv4 address setup, binary copy and chmod, then failed inside the
|
||||
application driver protocol. The failure stage is being isolated with fixed
|
||||
redacted phase markers; neither run is scenario acceptance. The worker also
|
||||
changed generated settings/server secret files to mode 0600 at creation and
|
||||
discovers the fixture endpoint inside the server namespace.
|
||||
The phase markers subsequently localized the error to Create: the synthetic
|
||||
server document lacked `[Interface] Address`, which the actual application
|
||||
requires for server address ownership. The fixture now includes IPv4/IPv6
|
||||
server addresses in saved configuration while removing the wg-quick-only
|
||||
`Address` directive from a private 0600 copy passed to `wg setconf`. The
|
||||
authoritative saved file is unchanged. This is fixture setup, not an IPv6
|
||||
capability or traffic check. A unique per-apply copy and exact cleanup are
|
||||
being added for repeated M8.5 apply phases; the corrected real smoke rerun
|
||||
is pending. One subsequent scenario process exited zero, but libtest captured
|
||||
its success output because the host invocation omitted `--nocapture`, so the
|
||||
host could not verify the expected phase sequence. This is not accepted as
|
||||
scenario evidence; the host invocation is being repaired and rerun.
|
||||
The completed host repair then passed the explicit selected-daemon smoke
|
||||
(1/1, 3.80s), and the coordinator independently reran it (1/1, 3.60s) with
|
||||
no `wga-m83` container/network on follow-up listing. It exercised one exact
|
||||
ignored driver test and observed Create, Commit, first delivery activation
|
||||
warning, and fixture Apply with real Rustix verification. Final worker default
|
||||
gates passed: fmt, all-target Clippy, `cargo test --locked` (247 library tests,
|
||||
two ignored), release build, focused driver/harness checks and diff check.
|
||||
Fresh independent `/root/m8_4_host_review` found M8.4-R3 (P2: a successful
|
||||
`wg setconf` followed by cleanup failure was reported as not submitted) and
|
||||
M8.4-R4 (P2: preview or partial-write failure could leave a secret-bearing
|
||||
setconf copy). `/root/m8_4_cleanup_repair` separated setconf submission from
|
||||
copy-cleanup outcome and added an armed cleanup guard; focused 15 driver tests,
|
||||
default tests (250 library tests, two ignored), fmt, all-target Clippy, release
|
||||
build and diff check passed. Independent `/root/m8_4_host_review` re-review
|
||||
closed R3/R4 with no new finding. The current test-only driver is being rebuilt
|
||||
for a fresh Alpine smoke; M8.4 is not yet accepted and M8.5 has not started.
|
||||
|
||||
2026-10-23 resume: the prior `/root/m8_4_repair` turn hit an agent usage limit
|
||||
before changing files; the same GPT-6-Luna/high repairer was resumed for R1/R2.
|
||||
The official `rust:1.97.1-alpine3.22` linux/amd64 builder manifest was resolved
|
||||
to `sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02`,
|
||||
pulled onto the selected disposable daemon, and inspected as image ID
|
||||
`sha256:bc2b74a0f3f942897f678aa466c3cce54048cf1e094893af3596cf43766153c2`.
|
||||
A read-only/networkless/capability-dropped container reported rustc/Cargo
|
||||
1.97.1 and installed `x86_64-unknown-linux-musl`; no application harness has
|
||||
yet been used for a scenario. A subsequent read-only source-mounted builder
|
||||
`cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl`
|
||||
completed in 4m25s and produced a 114 MiB static PIE test binary under
|
||||
`/tmp/wga-m8-build-target/.../wireguard_admin-dcc9c812241bb031`.
|
||||
`file`/`readelf` showed no dynamic interpreter. A networkless, read-only,
|
||||
capability-dropped run in the pinned Alpine runtime selected only
|
||||
`container_driver::alpine_readiness` and passed 1/1. This proves the binary
|
||||
loads in Alpine; no real `wg`, application scenario, or Rustix observation ran.
|
||||
|
||||
After R3/R4 repair, coordinator rebuilt the current musl harness in the pinned
|
||||
builder (`cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl`,
|
||||
3m22s), then ran the exact ignored host smoke with matching selected daemon ID
|
||||
and `WIREGUARD_ADMIN_TEST_DRIVER` pointing at that artifact. It passed 1/1 in
|
||||
4.05s and verified the test-only Create/Commit/first delivery/fixture Apply
|
||||
phase sequence through the real Application and Rustix observation path.
|
||||
`docker ps -a --filter name=wga-m83` and `docker network ls --filter name=wga-m83`
|
||||
both returned empty afterward. This is Alpine fixture evidence; no client
|
||||
tunnel traffic, restore/delete/import, or actual systemd job ran yet.
|
||||
|
||||
After the user excluded IPv6 testing, `/root/m8_ipv4_revision` changed the
|
||||
probe and M8 test guidance to isolated IPv4 while keeping SPEC.md untouched.
|
||||
The revised default suite, fmt, all-target Clippy, release build and diff check
|
||||
passed. The elevated selected-daemon probe created its IPv4 network, but its
|
||||
first Testcontainers container start failed with a generic redacted category;
|
||||
exact-name inspection found no owned resources afterward. A coordinator-only
|
||||
disposable `docker run` on a separately named internal IPv4 network succeeded
|
||||
with `NET_ADMIN`, and that network was removed. Independent reviewer
|
||||
`/root/m8_ipv4_review` raised M8.3-R4 (P2: swallowed Testcontainers error
|
||||
prevents diagnosing this start failure) and M8.3-R5 (P2: route read did not
|
||||
exercise IPv4 route mutation claimed by the guidance). `/root/m8_ipv4_repair`
|
||||
added fixed typed error categories and route add/assert/delete; independent
|
||||
re-review closed R4/R5 but the real route commands remain unrun because the
|
||||
probe still fails at container creation. It raised M8.3-R6 (P2: the `PullImage`
|
||||
category conceals that any Docker create 404 triggers Testcontainers' image-pull
|
||||
fallback). `/root/m8_ipv4_repair_r6` owns the category repair and diagnosis.
|
||||
The repair changed it to a fixed `container creation not-found image fallback
|
||||
failed` category with redaction regression; independent `/root/m8_ipv4_review`
|
||||
re-review closed R6. The explicit probe still fails there, before runtime route
|
||||
commands. No open scoped review finding remains, but actual M8.3 probe
|
||||
acceptance has not passed.
|
||||
The selected daemon is Docker 29.1.3, API 1.52; a direct Docker CLI create/start
|
||||
using the fixture image, internal network, `NET_ADMIN`, label, entrypoint and
|
||||
command succeeded and exact resources were removed. Testcontainers 0.28.0 uses
|
||||
Bollard 0.21.1 (default API 1.53); this was investigated, but the source-level
|
||||
image-name override below explains the observed create 404 without an API
|
||||
version change. No real tunnel or application scenario has run.
|
||||
|
||||
Coordinator inspection of pinned Testcontainers 0.28.0 identified M8.3-R7
|
||||
(P1, startup blocker): the host harness used `ImageExt::with_name` for unique
|
||||
container names, but that method overrides the image name. The nonexistent
|
||||
image explains the create 404 and pull fallback. `with_container_name` is the
|
||||
intended API. `/root/m8_name_repair` owns a narrow repair and live-probe
|
||||
regression; `with_container_name` and descriptor/name/owner assertions are in
|
||||
place. Independent `/root/m8_ipv4_review` re-review closed R7. The repairer's
|
||||
elevated selected-daemon probe passed (1/1); a concurrent
|
||||
coordinator probe collided on the fixed subnet, then a separate coordinator
|
||||
rerun passed (1/1, 1.46 seconds) with cleanup. M8.3 prerequisite acceptance
|
||||
is satisfied for the user-scoped IPv4 test path; no application scenario ran.
|
||||
|
||||
#### Evidence and handoff
|
||||
|
||||
Planning inspected current Cargo/module/test layout, Application public/private
|
||||
restart and delivery boundaries, runtime reader visibility, M7 acceptance record,
|
||||
and current TESTING/ENVIRONMENT/runtime documentation. Existing M7 evidence is
|
||||
synthetic workflow/PTY and ordinary library integration; it is not a real tunnel.
|
||||
This planning task did not rerun Cargo tests, contact Docker, probe kernel support,
|
||||
start containers, install toolchains/packages, add dependencies, or inspect keys.
|
||||
|
||||
2026-10-22 execution preparation: the active coordinator session `turn_context`
|
||||
reported `gpt-6-sol`/`medium`; project role files and explicit dispatch select
|
||||
`gpt-6-luna`/`high`. M1–M7 are accepted in this plan. Native rustc/Cargo are
|
||||
1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. The host lacks rustup, a musl linker,
|
||||
and the musl standard-library directory. The default local Docker daemon is
|
||||
reachable with approved access and identifies as Gentoo Linux, ID
|
||||
`1baad80e-d79d-49ab-b19a-fb00fd3662d7`; the user explicitly selected it as
|
||||
the disposable M8 environment. The scoped container WireGuard/IPv6 capability
|
||||
probe described above passed. No host WireGuard probe ran. The pre-existing
|
||||
expanded M8 description edit is preserved.
|
||||
|
||||
2026-10-22 integrated coordinator gates on the final M8.1–M8.3 revision:
|
||||
`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`,
|
||||
`cargo test --locked`, `cargo build --locked --release`, release `--help` and
|
||||
`--version`, documentation local-link check, and `git diff --check` all exited
|
||||
zero. The default test run passed 235 library tests plus integration/doc tests;
|
||||
the Testcontainers target had 8 passing local support tests and one ignored
|
||||
privileged probe. The ignored count is not container evidence. The exact named
|
||||
probe was then explicitly selected with matching local Unix endpoint and daemon
|
||||
ID; it ran one test and exited 101 with `Docker host lacks required IPv6 iptables
|
||||
support`. The first sandboxed attempt could not access the socket, so the
|
||||
coordinator reran it with approved elevated access; only the latter establishes
|
||||
the daemon result. An elevated `docker network ls` and `docker ps -a` afterward
|
||||
showed no `wga-m83` network or container. Existing unrelated resources were
|
||||
untouched. No real peer traffic, netlink observation, application lifecycle,
|
||||
in-container Rust driver, or systemd behavior was verified.
|
||||
|
||||
2026-10-22 user scope change: skip IPv6 capability and traffic tests. The prior
|
||||
dual-stack network failure is historical evidence, no longer an M8 blocker.
|
||||
Use the already selected local disposable daemon with a unique isolated IPv4
|
||||
bridge and scoped `NET_ADMIN`; run the revised named probe. An elevated
|
||||
coordinator run reached container creation but failed after 5.9 seconds with
|
||||
`Testcontainers could not start an owned fixture container`; the fresh IPv4
|
||||
network and container were absent on follow-up inspection. Investigate this
|
||||
failure through scoped implementation/review before claiming the probe passes.
|
||||
Do not modify this
|
||||
host's firewall/modules/sysctls. Then settle the Alpine-compatible in-crate driver
|
||||
build/runtime path (host musl standard library/linker remain absent, so a pinned
|
||||
Alpine builder may be needed), execute M8.4–M8.5 sequentially, independently
|
||||
review/repair the complete scenarios, and rerun all M8 acceptance gates.
|
||||
|
||||
#### Decisions and discoveries
|
||||
|
||||
The production reader is bounded rustix netlink, not wireguard-uapi, and the
|
||||
restart adapter is private zbus. M8 must test that actual reader and preserve
|
||||
production API boundaries rather than resurrect a discarded library path or
|
||||
expose a fake-systemd switch. A test-only service apply plus real comparison is
|
||||
useful integration evidence but does not validate the systemd adapter itself.
|
||||
Image layers may be cached for speed/space while secret-bearing container state
|
||||
must be fresh. The container host shares its kernel with Alpine, so the fixture
|
||||
cannot manufacture missing kernel support by installing wireguard-tools.
|
||||
|
||||
Primary implementation references (check selected versions at dispatch):
|
||||
[Testcontainers requirements](https://rust.testcontainers.org/system_requirements/docker/),
|
||||
[Testcontainers image options](https://docs.rs/testcontainers/latest/testcontainers/core/trait.ImageExt.html),
|
||||
and [WireGuard namespaces](https://www.wireguard.com/netns/).
|
||||
|
||||
#### Outcome
|
||||
|
||||
M8.1–M8.3 were implemented and independently reviewed, with all original
|
||||
findings closed. The user removed IPv6 testing from M8, and an IPv4-only harness
|
||||
revision is in progress. M8.4–M8.5 and real-container acceptance remain pending;
|
||||
M8 is not accepted. Milestone 9 must not start until M8 acceptance; actual
|
||||
systemd/VM validation remains separate and pending.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
profile = "minimal"
|
||||
components = ["rustfmt", "clippy"]
|
||||
targets = ["x86_64-unknown-linux-musl"]
|
||||
+4636
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
//! Application-owned backup listing and creation scenarios.
|
||||
|
||||
use crate::backup::{self, ArchiveSummary, BackupReason};
|
||||
|
||||
use super::{AppError, Application, historical_apply_from_saved};
|
||||
|
||||
pub(super) fn create_backup(
|
||||
application: &Application,
|
||||
reason: BackupReason,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = application.store.read_saved()?;
|
||||
let historical_apply = historical_apply_from_saved(&installed)?;
|
||||
Ok(backup::create_archive(
|
||||
application.store.archives_directory(),
|
||||
reason,
|
||||
historical_apply,
|
||||
&installed,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(super) fn list_backups(application: &Application) -> Result<Vec<ArchiveSummary>, AppError> {
|
||||
match backup::list_archives(application.store.archives_directory()) {
|
||||
Ok(archives) => Ok(archives),
|
||||
Err(crate::backup::BackupError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::NotFound =>
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn create_pre_restart_backup(
|
||||
application: &mut Application,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
application.active_restart_attempt = None;
|
||||
create_backup(application, BackupReason::PreRestart)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Saved-state Commit scenario.
|
||||
//!
|
||||
//! The ordering is deliberately explicit: prepare and validate the complete
|
||||
//! candidate, archive the installed generation, install it through the
|
||||
//! journaled store, then publish only already-prepared in-memory values.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::backup::{BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
|
||||
use crate::domain::ClientStates;
|
||||
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::{AppError, Application, historical_apply_from_saved};
|
||||
|
||||
struct PreparedCommit {
|
||||
proposed: SavedBytes,
|
||||
document: ConfigDocument,
|
||||
states: ClientStates,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
/// Prepare a candidate without changing the live session or installed
|
||||
/// files. All parsing, serialization and store validation finish here.
|
||||
fn prepare_commit(
|
||||
&self,
|
||||
installed: &SavedBytes,
|
||||
) -> Result<(SavedBytes, ConfigDocument, ClientStates), AppError> {
|
||||
if let Some(existing_bytes) = &installed.vault {
|
||||
let existing_text =
|
||||
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
||||
let existing = VaultBundle::from_toml(existing_text)?;
|
||||
if self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
||||
{
|
||||
return Err(AppError::VaultIdentityChange);
|
||||
}
|
||||
}
|
||||
|
||||
let mut states = self.draft.states.clone();
|
||||
states.commit_draft();
|
||||
let reservations = states
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.map(IpAddr::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
||||
let vault = self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.map(VaultBundle::to_toml)
|
||||
.transpose()?
|
||||
.map(String::into_bytes);
|
||||
let proposed = SavedBytes {
|
||||
settings: installed.settings.clone(),
|
||||
server_config: self.draft.document.render().to_vec(),
|
||||
internal_state,
|
||||
vault,
|
||||
};
|
||||
self.store.validate_saved(&proposed)?;
|
||||
|
||||
// Reuse this parsed document after install; publication must not have a
|
||||
// fallible parse step after the filesystem has already advanced.
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
Ok((proposed, document, states))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn commit(
|
||||
application: &mut Application,
|
||||
create_backup: impl FnOnce(
|
||||
&std::path::Path,
|
||||
BackupReason,
|
||||
HistoricalApplyStatus,
|
||||
&SavedBytes,
|
||||
) -> Result<std::path::PathBuf, BackupError>,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = application.store.read_saved()?;
|
||||
let historical_apply = historical_apply_from_saved(&installed)?;
|
||||
let (proposed, document, states) = application.prepare_commit(&installed)?;
|
||||
let prepared = PreparedCommit {
|
||||
proposed,
|
||||
document,
|
||||
states,
|
||||
};
|
||||
|
||||
// Passing BackupComplete is the only path to installation, and this point
|
||||
// is reached only after the full archive has been published successfully.
|
||||
let archive = create_backup(
|
||||
application.store.archives_directory(),
|
||||
BackupReason::PreCommit,
|
||||
historical_apply,
|
||||
&installed,
|
||||
)?;
|
||||
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
|
||||
retain_first_issue_rights_after_failed_install(application);
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
application.saved = prepared.proposed;
|
||||
application.draft.states = prepared.states;
|
||||
application.draft.document = prepared.document;
|
||||
application
|
||||
.first_delivery
|
||||
.invalidate_active_authorizations();
|
||||
application.first_delivery.promote_matching(
|
||||
&application.draft.document,
|
||||
application.draft.vault.as_ref(),
|
||||
);
|
||||
application.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
application.verified_saved_projection = None;
|
||||
application.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
fn retain_first_issue_rights_after_failed_install(application: &mut Application) {
|
||||
match application.store.read_saved() {
|
||||
Ok(current) => {
|
||||
let document = ConfigDocument::parse(¤t.server_config).ok();
|
||||
let vault = current
|
||||
.vault
|
||||
.as_deref()
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
application
|
||||
.first_delivery
|
||||
.retain_matching(&document, vault.as_ref());
|
||||
} else {
|
||||
application.first_delivery.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => application.first_delivery.clear(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Coordinated in-memory draft state for one application session.
|
||||
//!
|
||||
//! This owner groups the authoritative lossless document with its restricted
|
||||
//! public-client projection, encrypted vault draft, and draft-only address
|
||||
//! reservations (held inside `ClientStates`). It performs no I/O or
|
||||
//! cryptographic operations itself.
|
||||
|
||||
use crate::domain::ClientStates;
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
pub(super) struct DraftChanges {
|
||||
pub(super) document: ConfigDocument,
|
||||
pub(super) states: ClientStates,
|
||||
pub(super) vault: Option<VaultBundle>,
|
||||
}
|
||||
|
||||
/// Fully prepared replacement for selected coordinated in-memory draft parts.
|
||||
///
|
||||
/// Callers build this only after all fallible editing, projection, and vault
|
||||
/// work has succeeded. Publishing it is a narrow, infallible operation and
|
||||
/// never writes saved files or changes runtime state.
|
||||
pub(super) struct StagedDraftReplacement {
|
||||
document: Option<ConfigDocument>,
|
||||
states: Option<ClientStates>,
|
||||
vault: VaultChange,
|
||||
}
|
||||
|
||||
enum VaultChange {
|
||||
Keep,
|
||||
Replace(Option<VaultBundle>),
|
||||
}
|
||||
|
||||
impl DraftChanges {
|
||||
pub(super) fn new(
|
||||
document: ConfigDocument,
|
||||
states: ClientStates,
|
||||
vault: Option<VaultBundle>,
|
||||
) -> Self {
|
||||
Self {
|
||||
document,
|
||||
states,
|
||||
vault,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_clients(
|
||||
document: ConfigDocument,
|
||||
states: ClientStates,
|
||||
) -> StagedDraftReplacement {
|
||||
StagedDraftReplacement {
|
||||
document: Some(document),
|
||||
states: Some(states),
|
||||
vault: VaultChange::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_clients_and_vault(
|
||||
document: ConfigDocument,
|
||||
states: ClientStates,
|
||||
vault: Option<VaultBundle>,
|
||||
) -> StagedDraftReplacement {
|
||||
StagedDraftReplacement {
|
||||
document: Some(document),
|
||||
states: Some(states),
|
||||
vault: VaultChange::Replace(vault),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_vault(vault: Option<VaultBundle>) -> StagedDraftReplacement {
|
||||
StagedDraftReplacement {
|
||||
document: None,
|
||||
states: None,
|
||||
vault: VaultChange::Replace(vault),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn publish_replacement(&mut self, replacement: StagedDraftReplacement) {
|
||||
if let Some(document) = replacement.document {
|
||||
self.document = document;
|
||||
}
|
||||
if let Some(states) = replacement.states {
|
||||
self.states = states;
|
||||
}
|
||||
if let VaultChange::Replace(vault) = replacement.vault {
|
||||
self.vault = vault;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Session-local ownership of password-free first-delivery eligibility.
|
||||
//!
|
||||
//! The session keeps each generated private key beside the saved peer and
|
||||
//! encrypted-record context that authorizes its single first-file publication.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::delivery::ClientConfigPayload;
|
||||
use crate::domain::PublicKey;
|
||||
use crate::vault::{VaultBundle, WireGuardPrivateKey};
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::{AppError, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueRetry};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub(super) struct IssueContext {
|
||||
peer_directives: String,
|
||||
encrypted_record: String,
|
||||
}
|
||||
|
||||
pub(super) struct FirstDeliverySession {
|
||||
next_authorization: u64,
|
||||
issues: BTreeMap<PublicKey, InitialIssue>,
|
||||
}
|
||||
|
||||
struct InitialIssue {
|
||||
private_key: WireGuardPrivateKey,
|
||||
context: IssueContext,
|
||||
stage: IssueStage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum IssueStage {
|
||||
PendingDraft,
|
||||
AwaitingFirstFile,
|
||||
RetryAvailable,
|
||||
Authorizing(u64),
|
||||
RetryNeedsOverwriteConfirmation,
|
||||
RetryNeedsManualCleanup,
|
||||
}
|
||||
|
||||
/// Read-only first-file availability with no access to the held private key,
|
||||
/// issue context, or payload.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum SessionFirstDeliveryAvailability {
|
||||
InitialFile,
|
||||
Retry,
|
||||
RetryNeedsOverwriteConfirmation,
|
||||
RetryNeedsManualCleanup,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(super) struct FirstIssueAuthorization {
|
||||
identity: PublicKey,
|
||||
context: IssueContext,
|
||||
token: u64,
|
||||
payload: ClientConfigPayload,
|
||||
}
|
||||
|
||||
impl FirstIssueAuthorization {
|
||||
pub(super) fn payload(&self) -> &ClientConfigPayload {
|
||||
&self.payload
|
||||
}
|
||||
}
|
||||
|
||||
impl FirstDeliverySession {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
next_authorization: 1,
|
||||
issues: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.issues.len()
|
||||
}
|
||||
|
||||
pub(super) fn read_model_availability(
|
||||
&self,
|
||||
identity: PublicKey,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> SessionFirstDeliveryAvailability {
|
||||
let Some(issue) = self.issues.get(&identity) else {
|
||||
return SessionFirstDeliveryAvailability::Unavailable;
|
||||
};
|
||||
let matching = vault
|
||||
.and_then(|vault| context_for(document, vault, identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context);
|
||||
if !matching {
|
||||
return SessionFirstDeliveryAvailability::Unavailable;
|
||||
}
|
||||
match issue.stage {
|
||||
IssueStage::AwaitingFirstFile => SessionFirstDeliveryAvailability::InitialFile,
|
||||
IssueStage::RetryAvailable => SessionFirstDeliveryAvailability::Retry,
|
||||
IssueStage::RetryNeedsOverwriteConfirmation => {
|
||||
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation
|
||||
}
|
||||
IssueStage::RetryNeedsManualCleanup => {
|
||||
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup
|
||||
}
|
||||
IssueStage::PendingDraft | IssueStage::Authorizing(_) => {
|
||||
SessionFirstDeliveryAvailability::Unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.issues.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn contains(&self, identity: &PublicKey) -> bool {
|
||||
self.issues.contains_key(identity)
|
||||
}
|
||||
|
||||
pub(super) fn remember_pending(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
private_key: WireGuardPrivateKey,
|
||||
context: IssueContext,
|
||||
) {
|
||||
self.issues.insert(
|
||||
identity,
|
||||
InitialIssue {
|
||||
private_key,
|
||||
context,
|
||||
stage: IssueStage::PendingDraft,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, identity: &PublicKey) {
|
||||
self.issues.remove(identity);
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.issues.clear();
|
||||
}
|
||||
|
||||
/// In-flight output can no longer be retried silently after a draft or
|
||||
/// runtime transition. Keep unrelated clients' rights intact.
|
||||
pub(super) fn invalidate_active_authorizations(&mut self) {
|
||||
for issue in self.issues.values_mut() {
|
||||
if matches!(issue.stage, IssueStage::Authorizing(_)) {
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only already committed first-file rights when a draft is dropped.
|
||||
pub(super) fn discard_draft(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) {
|
||||
self.issues
|
||||
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
|
||||
self.retain_matching(document, vault);
|
||||
}
|
||||
|
||||
pub(super) fn retain_matching(
|
||||
&mut self,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) {
|
||||
self.issues.retain(|identity, issue| {
|
||||
if issue.stage == IssueStage::PendingDraft {
|
||||
return true;
|
||||
}
|
||||
vault
|
||||
.and_then(|vault| context_for(document, vault, *identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context)
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn promote_matching(
|
||||
&mut self,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) {
|
||||
self.issues.retain(|identity, issue| {
|
||||
let matches = vault
|
||||
.and_then(|vault| context_for(document, vault, *identity).ok())
|
||||
.is_some_and(|actual| actual == issue.context);
|
||||
if matches && issue.stage == IssueStage::PendingDraft {
|
||||
issue.stage = IssueStage::AwaitingFirstFile;
|
||||
}
|
||||
matches
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn begin_attempt(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
render: impl FnOnce(&WireGuardPrivateKey) -> Result<ClientConfigPayload, AppError>,
|
||||
) -> Result<FirstIssueAuthorization, AppError> {
|
||||
self.retain_matching(document, vault);
|
||||
let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt {
|
||||
FirstIssueAttempt::Initial => (false, false, false),
|
||||
FirstIssueAttempt::Retry {
|
||||
confirm_possible_overwrite,
|
||||
confirm_manual_cleanup,
|
||||
} => (true, confirm_possible_overwrite, confirm_manual_cleanup),
|
||||
};
|
||||
let issue = self
|
||||
.issues
|
||||
.get(&identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
let allowed = match issue.stage {
|
||||
IssueStage::AwaitingFirstFile => !is_retry,
|
||||
IssueStage::RetryAvailable => is_retry,
|
||||
IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite,
|
||||
IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup,
|
||||
IssueStage::PendingDraft | IssueStage::Authorizing(_) => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let current_context = context_for(
|
||||
document,
|
||||
vault.ok_or(AppError::FirstIssueUnavailable)?,
|
||||
identity,
|
||||
)?;
|
||||
if current_context != issue.context {
|
||||
self.issues.remove(&identity);
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
let payload = render(&issue.private_key)?;
|
||||
let context = issue.context.clone();
|
||||
let token = self.next_authorization;
|
||||
let next_token = token
|
||||
.checked_add(1)
|
||||
.ok_or(AppError::IssueAuthorizationExhausted)?;
|
||||
let issue = self
|
||||
.issues
|
||||
.get_mut(&identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
self.next_authorization = next_token;
|
||||
issue.stage = IssueStage::Authorizing(token);
|
||||
Ok(FirstIssueAuthorization {
|
||||
identity,
|
||||
context,
|
||||
token,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_authorization(
|
||||
&mut self,
|
||||
authorization: &FirstIssueAuthorization,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> Result<(), AppError> {
|
||||
self.retain_matching(document, vault);
|
||||
let current = context_for(
|
||||
document,
|
||||
vault.ok_or(AppError::FirstIssueUnavailable)?,
|
||||
authorization.identity,
|
||||
)?;
|
||||
if current != authorization.context
|
||||
|| !matches!(
|
||||
self.issues.get(&authorization.identity),
|
||||
Some(issue)
|
||||
if issue.stage == IssueStage::Authorizing(authorization.token)
|
||||
&& issue.context == authorization.context
|
||||
)
|
||||
{
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
|
||||
if let Some(issue) = self.issues.get_mut(&identity)
|
||||
&& matches!(issue.stage, IssueStage::Authorizing(_))
|
||||
{
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn complete_publication(
|
||||
&mut self,
|
||||
authorization: FirstIssueAuthorization,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> Result<(), AppError> {
|
||||
self.validate_authorization(&authorization, document, vault)?;
|
||||
self.issues.remove(&authorization.identity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn fail_publication(
|
||||
&mut self,
|
||||
authorization: FirstIssueAuthorization,
|
||||
failure: FirstIssueDeliveryFailure,
|
||||
document: &ConfigDocument,
|
||||
vault: Option<&VaultBundle>,
|
||||
) -> Result<FirstIssueRetry, AppError> {
|
||||
self.validate_authorization(&authorization, document, vault)?;
|
||||
let issue = self
|
||||
.issues
|
||||
.get_mut(&authorization.identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?;
|
||||
let retry = match failure {
|
||||
FirstIssueDeliveryFailure::BeforePublication => {
|
||||
issue.stage = IssueStage::RetryAvailable;
|
||||
FirstIssueRetry::Available
|
||||
}
|
||||
FirstIssueDeliveryFailure::PublicationUncertain => {
|
||||
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
|
||||
FirstIssueRetry::OverwriteConfirmationRequired
|
||||
}
|
||||
FirstIssueDeliveryFailure::CleanupRequired => {
|
||||
issue.stage = IssueStage::RetryNeedsManualCleanup;
|
||||
FirstIssueRetry::ManualCleanupRequired
|
||||
}
|
||||
};
|
||||
Ok(retry)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn context_for(
|
||||
document: &ConfigDocument,
|
||||
vault: &VaultBundle,
|
||||
identity: PublicKey,
|
||||
) -> Result<IssueContext, AppError> {
|
||||
let matching = document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| {
|
||||
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let [peer] = matching.as_slice() else {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
};
|
||||
if peer.identity != Some(identity) || !peer.issues.is_empty() {
|
||||
return Err(AppError::FirstIssueUnavailable);
|
||||
}
|
||||
// Alias/admin comments are descriptive metadata; only WireGuard directives
|
||||
// bind first-issue authority, so renames do not invalidate the creation.
|
||||
let peer_directives = document
|
||||
.source_slice(peer.section)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let encrypted_record = vault
|
||||
.record_context(identity)
|
||||
.ok_or(AppError::FirstIssueUnavailable)?
|
||||
.to_owned();
|
||||
Ok(IssueContext {
|
||||
peer_directives,
|
||||
encrypted_record,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Password-free first-file delivery after Commit, with optional QR disclosure.
|
||||
|
||||
use super::{
|
||||
AppError, Application, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome,
|
||||
FirstIssueFileAndQr, FirstIssueFileDelivery,
|
||||
};
|
||||
use crate::delivery::files::{self, FileOutputError, PreparedDestination};
|
||||
use crate::delivery::qr;
|
||||
use crate::delivery::{ClientConfigPayload, QrCodeOutput, QrError};
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
impl Application {
|
||||
/// Publish a password-free first issue while the committed peer, encrypted
|
||||
/// record, and one-use token are valid. The mutable app borrow remains held
|
||||
/// across `publish`, so no application mutation can interleave after the
|
||||
/// final saved-state validation and before the file adapter runs.
|
||||
#[allow(dead_code)] // Retained as a unit-test seam for generic publication closures.
|
||||
pub(crate) fn deliver_first_issue<T, E>(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_with_warning<T, E>(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
|
||||
let authorization = self.authorize_first_issue_inner(identity, attempt)?;
|
||||
let activation_warning = match self.validate_first_issue_authorization(&authorization) {
|
||||
Ok(warning) => warning,
|
||||
Err(error) => {
|
||||
self.require_overwrite_confirmation(identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
match publish(authorization.payload(), activation_warning) {
|
||||
Ok(value) => {
|
||||
if let Err(error) = self.mark_first_issue_delivered(authorization) {
|
||||
// Publication is definite. Drop the session-held key even
|
||||
// when saved-state bookkeeping cannot be completed.
|
||||
self.first_delivery.remove(&identity);
|
||||
return Ok(
|
||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||
value,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(FirstIssueDeliveryOutcome::Delivered(value))
|
||||
}
|
||||
Err((error, failure)) => {
|
||||
let retry = match self.mark_first_issue_delivery_failed(authorization, failure) {
|
||||
Ok(retry) => retry,
|
||||
Err(error) => {
|
||||
self.require_overwrite_confirmation(identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(FirstIssueDeliveryOutcome::Failed {
|
||||
error,
|
||||
retry,
|
||||
activation_warning,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a caller-selected plaintext destination without writing it. The
|
||||
/// returned destination records any existing regular file so a later
|
||||
/// overwrite confirmation is bound to this exact selection.
|
||||
pub fn prepare_first_issue_destination(
|
||||
&self,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<PreparedDestination, FileOutputError> {
|
||||
let protection = self.store.delivery_output_protection()?;
|
||||
files::prepare_destination(path, &protection)
|
||||
}
|
||||
|
||||
/// Deliver the committed new-client configuration through the app-owned
|
||||
/// one-use authorization closure. A failed write retains retry eligibility;
|
||||
/// a post-publication sync failure requires an explicit overwrite retry.
|
||||
pub fn deliver_first_issue_file(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_with(identity, attempt, destination, files::publish)
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_file_with(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
publish_file: impl FnOnce(
|
||||
PreparedDestination,
|
||||
&ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||
publish_file(destination, payload).map_err(|error| {
|
||||
let failure = match error {
|
||||
FileOutputError::PublicationUncertain => {
|
||||
FirstIssueDeliveryFailure::PublicationUncertain
|
||||
}
|
||||
FileOutputError::ManualCleanupRequired => {
|
||||
FirstIssueDeliveryFailure::CleanupRequired
|
||||
}
|
||||
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||
};
|
||||
(error, failure)
|
||||
})?;
|
||||
Ok(FirstIssueFileDelivery { activation_warning })
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the mandatory plaintext file, then optionally encode a QR from
|
||||
/// that same authorized payload. QR failure is returned separately and the
|
||||
/// successful file publication still consumes first-issue eligibility.
|
||||
pub fn deliver_first_issue_file_and_qr(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| {
|
||||
qr::encode_payload(payload)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_file_and_qr_with(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_file_and_qr_with_publisher(
|
||||
identity,
|
||||
attempt,
|
||||
destination,
|
||||
files::publish,
|
||||
encode,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn deliver_first_issue_file_and_qr_with_publisher(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
attempt: FirstIssueAttempt,
|
||||
destination: PreparedDestination,
|
||||
publish_file: impl FnOnce(
|
||||
PreparedDestination,
|
||||
&ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError>,
|
||||
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
|
||||
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
|
||||
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
|
||||
publish_file(destination, payload).map_err(|error| {
|
||||
let failure = match error {
|
||||
FileOutputError::PublicationUncertain => {
|
||||
FirstIssueDeliveryFailure::PublicationUncertain
|
||||
}
|
||||
FileOutputError::ManualCleanupRequired => {
|
||||
FirstIssueDeliveryFailure::CleanupRequired
|
||||
}
|
||||
_ => FirstIssueDeliveryFailure::BeforePublication,
|
||||
};
|
||||
(error, failure)
|
||||
})?;
|
||||
Ok(FirstIssueFileAndQr {
|
||||
activation_warning,
|
||||
qr: encode(payload),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//! Secret-free application facts prepared for the terminal and other readers.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::domain::{ApplyStatus, Client, PublicKey};
|
||||
use crate::wireguard::{ConfigDocument, PeerInspection, PeerIssue};
|
||||
|
||||
use super::Application;
|
||||
use super::first_delivery::SessionFirstDeliveryAvailability;
|
||||
|
||||
/// A presentation action is available only as far as the current application
|
||||
/// facts can establish. The operation itself remains the final authority.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ActionEligibility {
|
||||
pub available: bool,
|
||||
pub reason: Option<ActionUnavailableReason>,
|
||||
}
|
||||
|
||||
impl ActionEligibility {
|
||||
fn available() -> Self {
|
||||
Self {
|
||||
available: true,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(reason: ActionUnavailableReason) -> Self {
|
||||
Self {
|
||||
available: false,
|
||||
reason: Some(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ActionUnavailableReason {
|
||||
NoUniquePeer,
|
||||
PeerNotInDraft,
|
||||
PeerNotInSavedState,
|
||||
UnsafePeerOrDocument,
|
||||
ExactHostRoutesRequired,
|
||||
VaultNotInitialized,
|
||||
VaultRecordMissing,
|
||||
VaultRecordConflict,
|
||||
FirstDeliveryNotAvailable,
|
||||
RestartPreviewRejected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FirstDeliveryEligibility {
|
||||
InitialFile,
|
||||
Retry,
|
||||
RetryNeedsOverwriteConfirmation,
|
||||
RetryNeedsManualCleanup,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientActions {
|
||||
pub rename: ActionEligibility,
|
||||
pub delete: ActionEligibility,
|
||||
pub first_delivery: FirstDeliveryEligibility,
|
||||
pub reissue: ActionEligibility,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PeerIssueClass {
|
||||
MalformedOrAmbiguous,
|
||||
UnsupportedCapability,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClassifiedPeerIssue {
|
||||
pub issue: PeerIssue,
|
||||
pub class: PeerIssueClass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ClientStateChange {
|
||||
Added,
|
||||
Removed,
|
||||
Changed,
|
||||
Unchanged,
|
||||
UnsupportedOrUnmanaged,
|
||||
}
|
||||
|
||||
/// Compatibility row for the original `display_projection` API.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientDisplay {
|
||||
pub peer_index: usize,
|
||||
pub line: usize,
|
||||
pub identity: Option<PublicKey>,
|
||||
pub label: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub has_subnet_routes: bool,
|
||||
pub issues: Vec<PeerIssue>,
|
||||
pub mutable_identity: Option<PublicKey>,
|
||||
}
|
||||
|
||||
impl ClientDisplay {
|
||||
/// Whether an issue is a valid configuration shape outside the current
|
||||
/// client model's capabilities.
|
||||
pub fn is_capability_limitation_issue(&self, issue: &PeerIssue) -> bool {
|
||||
classify_issue(issue) == PeerIssueClass::UnsupportedCapability
|
||||
}
|
||||
|
||||
pub fn presented_issues(&self) -> impl Iterator<Item = &PeerIssue> {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| !self.is_capability_limitation_issue(issue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret-free authoritative peer record used by the application read model.
|
||||
/// Saved-only rows have no draft peer index and retain the former saved index.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PeerRecord {
|
||||
pub peer_index: Option<usize>,
|
||||
pub draft_peer_index: Option<usize>,
|
||||
pub saved_peer_index: Option<usize>,
|
||||
pub line: usize,
|
||||
pub identity: Option<PublicKey>,
|
||||
pub label: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub has_subnet_routes: bool,
|
||||
pub issues: Vec<PeerIssue>,
|
||||
pub classified_issues: Vec<ClassifiedPeerIssue>,
|
||||
pub mutable_identity: Option<PublicKey>,
|
||||
pub draft_client: Option<Client>,
|
||||
pub saved_client: Option<Client>,
|
||||
pub state_change: ClientStateChange,
|
||||
pub actions: ClientActions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct ReservationFacts {
|
||||
pub committed: Vec<IpAddr>,
|
||||
pub pending_host_routes: Vec<IpAddr>,
|
||||
}
|
||||
|
||||
/// Compatibility projection for existing TUI callers. New consumers should
|
||||
/// use [`ApplicationView`] for saved-state and action facts.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SessionDisplay {
|
||||
pub clients: Vec<ClientDisplay>,
|
||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||
}
|
||||
|
||||
/// Public projection of application facts. It intentionally has no document,
|
||||
/// saved-byte, vault, password, or client-payload owner.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ApplicationView {
|
||||
pub clients: Vec<PeerRecord>,
|
||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||
pub saved_peer_count: usize,
|
||||
pub draft_client_count: usize,
|
||||
pub saved_client_count: usize,
|
||||
pub reservations: ReservationFacts,
|
||||
pub applied: ApplyStatus,
|
||||
pub vault_initialized: bool,
|
||||
pub has_draft: bool,
|
||||
pub restart: ActionEligibility,
|
||||
}
|
||||
|
||||
pub(super) fn legacy_session_display(application: &Application) -> SessionDisplay {
|
||||
SessionDisplay {
|
||||
clients: application
|
||||
.draft
|
||||
.document
|
||||
.peers()
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
let projected = peer
|
||||
.identity
|
||||
.and_then(|identity| application.draft.document.project_client(&identity).ok());
|
||||
let label = match &peer.metadata {
|
||||
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||
Some(metadata.alias.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_else(|| {
|
||||
peer.identity.map_or_else(
|
||||
|| format!("Peer at line {}", peer.section_line),
|
||||
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
|
||||
)
|
||||
});
|
||||
ClientDisplay {
|
||||
peer_index: peer.index,
|
||||
line: peer.section_line,
|
||||
identity: peer.identity,
|
||||
label,
|
||||
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
||||
has_subnet_routes: peer.has_subnet_routes,
|
||||
issues: peer.issues.clone(),
|
||||
mutable_identity: projected
|
||||
.filter(|client| {
|
||||
application
|
||||
.draft
|
||||
.states
|
||||
.draft()
|
||||
.get(&client.identity)
|
||||
.is_some()
|
||||
})
|
||||
.map(|client| client.identity),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
document_issues: application.draft.document.issues().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn session_display(application: &Application) -> ApplicationView {
|
||||
let draft_document = &application.draft.document;
|
||||
// The saved document was parsed when the session opened and every saved
|
||||
// replacement is parsed before publication. Keep this projection total if
|
||||
// that internal invariant is ever violated: no saved records are inferred.
|
||||
let saved_document = ConfigDocument::parse(&application.saved.server_config).ok();
|
||||
let saved_peer_count = saved_document.as_ref().map_or(0, |doc| doc.peers().len());
|
||||
let mut clients = draft_document
|
||||
.peers()
|
||||
.iter()
|
||||
.map(|peer| display_peer(application, peer, saved_document.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let draft_peer_identities = draft_document
|
||||
.peers()
|
||||
.iter()
|
||||
.flat_map(|peer| peer.public_key_candidates.iter().copied())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let mut saved_only_identities = std::collections::BTreeSet::new();
|
||||
if let Some(document) = saved_document.as_ref() {
|
||||
for saved_peer in document.peers() {
|
||||
let Some(identity) = saved_peer.identity else {
|
||||
continue;
|
||||
};
|
||||
if draft_peer_identities.contains(&identity)
|
||||
|| !saved_only_identities.insert(identity)
|
||||
|| document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| peer.identity == Some(identity))
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let saved_client = application.draft.states.saved().get(&identity).cloned();
|
||||
let label = saved_client.as_ref().map_or_else(
|
||||
|| match &saved_peer.metadata {
|
||||
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||
metadata.alias.clone()
|
||||
}
|
||||
_ => format!(
|
||||
"{}…",
|
||||
identity.to_string().chars().take(8).collect::<String>()
|
||||
),
|
||||
},
|
||||
|client| client.alias.clone(),
|
||||
);
|
||||
clients.push(PeerRecord {
|
||||
peer_index: None,
|
||||
draft_peer_index: None,
|
||||
saved_peer_index: Some(saved_peer.index),
|
||||
line: saved_peer.section_line,
|
||||
identity: Some(identity),
|
||||
label,
|
||||
allowed_ips: saved_peer
|
||||
.allowed_ips
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
has_subnet_routes: saved_peer.has_subnet_routes,
|
||||
issues: saved_peer.issues.clone(),
|
||||
classified_issues: classify_issues(&saved_peer.issues),
|
||||
mutable_identity: None,
|
||||
draft_client: None,
|
||||
saved_client,
|
||||
state_change: ClientStateChange::Removed,
|
||||
actions: actions_for(application, Some(identity), false),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A saved client can be absent from a malformed or unsupported saved
|
||||
// document projection. Retain the state-backed row only when no
|
||||
// authoritative peer row represented that identity above.
|
||||
for client in application.draft.states.saved().clients() {
|
||||
if draft_peer_identities.contains(&client.identity)
|
||||
|| clients.iter().any(|peer| {
|
||||
peer.state_change == ClientStateChange::Removed
|
||||
&& peer.identity == Some(client.identity)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
clients.push(PeerRecord {
|
||||
peer_index: None,
|
||||
draft_peer_index: None,
|
||||
saved_peer_index: None,
|
||||
line: 0,
|
||||
identity: Some(client.identity),
|
||||
label: client.alias.clone(),
|
||||
allowed_ips: Vec::new(),
|
||||
has_subnet_routes: false,
|
||||
issues: Vec::new(),
|
||||
classified_issues: Vec::new(),
|
||||
mutable_identity: None,
|
||||
draft_client: None,
|
||||
saved_client: Some(client.clone()),
|
||||
state_change: ClientStateChange::Removed,
|
||||
actions: actions_for(application, Some(client.identity), false),
|
||||
});
|
||||
}
|
||||
|
||||
let reservations = application.draft.states.address_reservations();
|
||||
let restart = if restart_is_eligible(application) {
|
||||
ActionEligibility::available()
|
||||
} else {
|
||||
ActionEligibility::unavailable(ActionUnavailableReason::RestartPreviewRejected)
|
||||
};
|
||||
ApplicationView {
|
||||
clients,
|
||||
document_issues: draft_document.issues().to_vec(),
|
||||
saved_peer_count,
|
||||
draft_client_count: application.draft.states.draft().clients().count(),
|
||||
saved_client_count: application.draft.states.saved().clients().count(),
|
||||
reservations: ReservationFacts {
|
||||
committed: reservations.committed().iter().copied().collect(),
|
||||
pending_host_routes: reservations.pending_host_routes().collect(),
|
||||
},
|
||||
applied: *application.draft.states.applied(),
|
||||
vault_initialized: application.draft.vault.is_some(),
|
||||
has_draft: application.has_draft(),
|
||||
restart,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_peer(
|
||||
application: &Application,
|
||||
peer: &PeerInspection,
|
||||
saved_document: Option<&ConfigDocument>,
|
||||
) -> PeerRecord {
|
||||
let identity = peer.identity;
|
||||
let draft_client = identity.and_then(|key| application.draft.states.draft().get(&key).cloned());
|
||||
let saved_client = identity.and_then(|key| application.draft.states.saved().get(&key).cloned());
|
||||
let projected = identity.and_then(|key| application.draft.document.project_client(&key).ok());
|
||||
let metadata_label = match &peer.metadata {
|
||||
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||
Some(metadata.alias.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let label = metadata_label.unwrap_or_else(|| {
|
||||
identity.map_or_else(
|
||||
|| format!("Peer at line {}", peer.section_line),
|
||||
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
|
||||
)
|
||||
});
|
||||
let saved_peer_index = identity.and_then(|key| {
|
||||
saved_document.and_then(|document| {
|
||||
document
|
||||
.peers()
|
||||
.iter()
|
||||
.find(|saved_peer| saved_peer.identity == Some(key))
|
||||
.map(|saved_peer| saved_peer.index)
|
||||
})
|
||||
});
|
||||
let mutable_identity = projected
|
||||
.filter(|client| {
|
||||
draft_client
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.identity == client.identity)
|
||||
})
|
||||
.map(|client| client.identity);
|
||||
let state_change = match (&draft_client, &saved_client) {
|
||||
(Some(_), None) => ClientStateChange::Added,
|
||||
(None, Some(_)) => ClientStateChange::UnsupportedOrUnmanaged,
|
||||
(Some(draft), Some(saved)) if draft == saved => ClientStateChange::Unchanged,
|
||||
(Some(_), Some(_)) => ClientStateChange::Changed,
|
||||
(None, None) => ClientStateChange::UnsupportedOrUnmanaged,
|
||||
};
|
||||
PeerRecord {
|
||||
peer_index: Some(peer.index),
|
||||
draft_peer_index: Some(peer.index),
|
||||
saved_peer_index,
|
||||
line: peer.section_line,
|
||||
identity,
|
||||
label,
|
||||
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
||||
has_subnet_routes: peer.has_subnet_routes,
|
||||
issues: peer.issues.clone(),
|
||||
classified_issues: classify_issues(&peer.issues),
|
||||
mutable_identity,
|
||||
draft_client,
|
||||
saved_client,
|
||||
state_change,
|
||||
actions: actions_for(application, identity, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn actions_for(
|
||||
application: &Application,
|
||||
identity: Option<PublicKey>,
|
||||
peer_is_in_draft: bool,
|
||||
) -> ClientActions {
|
||||
let (rename, delete) = match identity {
|
||||
None => {
|
||||
let reason = ActionUnavailableReason::NoUniquePeer;
|
||||
(
|
||||
ActionEligibility::unavailable(reason),
|
||||
ActionEligibility::unavailable(reason),
|
||||
)
|
||||
}
|
||||
Some(_identity) if !peer_is_in_draft => {
|
||||
let reason = ActionUnavailableReason::PeerNotInDraft;
|
||||
(
|
||||
ActionEligibility::unavailable(reason),
|
||||
ActionEligibility::unavailable(reason),
|
||||
)
|
||||
}
|
||||
Some(identity) => (
|
||||
if application
|
||||
.draft
|
||||
.document
|
||||
.rename_client(&identity, "eligibility probe")
|
||||
.is_ok()
|
||||
{
|
||||
ActionEligibility::available()
|
||||
} else {
|
||||
ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument)
|
||||
},
|
||||
delete_eligibility(application, identity),
|
||||
),
|
||||
};
|
||||
|
||||
let first_delivery =
|
||||
identity.map_or(
|
||||
FirstDeliveryEligibility::Unavailable,
|
||||
|identity| match application.first_delivery.read_model_availability(
|
||||
identity,
|
||||
&application.draft.document,
|
||||
application.draft.vault.as_ref(),
|
||||
) {
|
||||
SessionFirstDeliveryAvailability::InitialFile => {
|
||||
FirstDeliveryEligibility::InitialFile
|
||||
}
|
||||
SessionFirstDeliveryAvailability::Retry => FirstDeliveryEligibility::Retry,
|
||||
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation => {
|
||||
FirstDeliveryEligibility::RetryNeedsOverwriteConfirmation
|
||||
}
|
||||
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup => {
|
||||
FirstDeliveryEligibility::RetryNeedsManualCleanup
|
||||
}
|
||||
SessionFirstDeliveryAvailability::Unavailable => {
|
||||
FirstDeliveryEligibility::Unavailable
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let reissue = match identity {
|
||||
None => ActionEligibility::unavailable(ActionUnavailableReason::NoUniquePeer),
|
||||
Some(identity) => reissue_eligibility(application, identity),
|
||||
};
|
||||
ClientActions {
|
||||
rename,
|
||||
delete,
|
||||
first_delivery,
|
||||
reissue,
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
|
||||
if application.draft.document.delete_client(&identity).is_err() {
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
|
||||
}
|
||||
let saved_peer = ConfigDocument::parse(&application.saved.server_config)
|
||||
.ok()
|
||||
.and_then(|document| {
|
||||
document
|
||||
.peers()
|
||||
.iter()
|
||||
.find(|peer| peer.identity == Some(identity))
|
||||
.cloned()
|
||||
});
|
||||
if application.draft.states.saved().get(&identity).is_some() || saved_peer.is_none() {
|
||||
return ActionEligibility::available();
|
||||
}
|
||||
let Some(peer) = saved_peer else {
|
||||
return ActionEligibility::available();
|
||||
};
|
||||
let exact_host_routes = !peer.allowed_ips.is_empty()
|
||||
&& peer.allowed_ips.iter().all(|route| match route {
|
||||
ipnet::IpNet::V4(net) => net.prefix_len() == 32,
|
||||
ipnet::IpNet::V6(net) => net.prefix_len() == 128,
|
||||
});
|
||||
if exact_host_routes {
|
||||
ActionEligibility::available()
|
||||
} else {
|
||||
ActionEligibility::unavailable(ActionUnavailableReason::ExactHostRoutesRequired)
|
||||
}
|
||||
}
|
||||
|
||||
fn reissue_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
|
||||
let saved_document = match ConfigDocument::parse(&application.saved.server_config) {
|
||||
Ok(document) => document,
|
||||
Err(_) => {
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
|
||||
}
|
||||
};
|
||||
let matching_peers = saved_document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| peer.identity == Some(identity))
|
||||
.collect::<Vec<_>>();
|
||||
let Some(peer) = matching_peers.first() else {
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::PeerNotInSavedState);
|
||||
};
|
||||
if matching_peers.len() != 1
|
||||
|| peer.identity != peer.public_key_candidates.first().copied()
|
||||
|| peer.public_key_candidates.len() != 1
|
||||
|| !peer.issues.is_empty()
|
||||
|| peer.allowed_ips.is_empty()
|
||||
{
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
|
||||
}
|
||||
let Some(bytes) = application.saved.vault.as_deref() else {
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
|
||||
};
|
||||
let vault_text = match std::str::from_utf8(bytes) {
|
||||
Ok(text) => text,
|
||||
Err(_) => {
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
|
||||
}
|
||||
};
|
||||
let vault = match crate::vault::VaultBundle::from_toml(vault_text) {
|
||||
Ok(vault) => vault,
|
||||
Err(_) => {
|
||||
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
|
||||
}
|
||||
};
|
||||
if vault.contains_record(identity) {
|
||||
if vault.reissue_blocked(identity).unwrap_or(true) {
|
||||
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordConflict)
|
||||
} else {
|
||||
ActionEligibility::available()
|
||||
}
|
||||
} else {
|
||||
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordMissing)
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_is_eligible(application: &Application) -> bool {
|
||||
let Ok(document) = ConfigDocument::parse(&application.saved.server_config) else {
|
||||
return false;
|
||||
};
|
||||
document
|
||||
.sections()
|
||||
.iter()
|
||||
.filter(|section| section.kind == crate::wireguard::SectionKind::Interface)
|
||||
.count()
|
||||
== 1
|
||||
&& !document.has_unsafe_save_config()
|
||||
&& super::restart::restart_projection_from_document(&document).is_ok()
|
||||
}
|
||||
|
||||
fn classify_issues(issues: &[PeerIssue]) -> Vec<ClassifiedPeerIssue> {
|
||||
issues
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|issue| ClassifiedPeerIssue {
|
||||
class: classify_issue(&issue),
|
||||
issue,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn classify_issue(issue: &PeerIssue) -> PeerIssueClass {
|
||||
match issue {
|
||||
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
|
||||
PeerIssueClass::UnsupportedCapability
|
||||
}
|
||||
_ => PeerIssueClass::MalformedOrAmbiguous,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Freshly authenticated publication of a saved client's configuration.
|
||||
|
||||
use super::{Application, ReissueDelivery, ReissueError, ReissueFailureKind, ReissueStage};
|
||||
use crate::delivery::files::{self, PreparedDestination};
|
||||
use crate::delivery::qr;
|
||||
use crate::delivery::{self, DeliverySettings};
|
||||
use crate::domain::PublicKey;
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
impl Application {
|
||||
/// Reissue a saved client's configuration after freshly unlocking its
|
||||
/// installed vault record with this action's password. This operation reads
|
||||
/// only installed saved bytes, never uses a draft or retained source file,
|
||||
/// and publishes the required file before attempting optional QR encoding.
|
||||
pub fn reissue_client_file(
|
||||
&self,
|
||||
identity: PublicKey,
|
||||
password: &str,
|
||||
destination: PreparedDestination,
|
||||
include_qr: bool,
|
||||
) -> Result<ReissueDelivery, ReissueError> {
|
||||
let installed = self.store.read_saved().map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let document =
|
||||
ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let encoded_vault = installed.vault.as_deref().ok_or(ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::VaultUnavailable,
|
||||
})?;
|
||||
// Retrieval performs the password unlock and authenticated conflict
|
||||
// check on every invocation. The short-lived owner is dropped when
|
||||
// this method returns and is never kept in Application state.
|
||||
let record = vault
|
||||
.retrieve_client_record(identity, password)
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||
})?;
|
||||
let resolved = self
|
||||
.store
|
||||
.resolved_settings_for(&installed)
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::SavedState,
|
||||
kind: ReissueFailureKind::SavedStateUnavailable,
|
||||
})?;
|
||||
let delivery_settings = DeliverySettings {
|
||||
server_endpoint: &resolved.server_endpoint,
|
||||
dns_addresses: &resolved.dns_addresses,
|
||||
};
|
||||
let payload = if record.is_new_template() {
|
||||
delivery::render_new_client(
|
||||
identity,
|
||||
record.private_key().ok_or(ReissueError {
|
||||
stage: ReissueStage::Authorization,
|
||||
kind: ReissueFailureKind::PasswordOrRecordRejected,
|
||||
})?,
|
||||
&document,
|
||||
&delivery_settings,
|
||||
)
|
||||
} else {
|
||||
delivery::render_imported_client(identity, &record, &document, &delivery_settings)
|
||||
}
|
||||
.map_err(|_| ReissueError {
|
||||
stage: ReissueStage::Rendering,
|
||||
kind: ReissueFailureKind::ConfigurationRejected,
|
||||
})?;
|
||||
|
||||
files::publish(destination, &payload).map_err(|error| ReissueError {
|
||||
stage: ReissueStage::FilePublication,
|
||||
kind: ReissueFailureKind::FileOutput(error),
|
||||
})?;
|
||||
|
||||
// A historical timestamp is deliberately insufficient: after reopen,
|
||||
// the runtime observation is unknown. Warn unless this live session
|
||||
// has verified the exact saved peer snapshot currently on disk.
|
||||
let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document);
|
||||
let qr = include_qr.then(|| qr::encode_payload(&payload));
|
||||
Ok(ReissueDelivery {
|
||||
activation_warning,
|
||||
qr,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! Saved-generation-bound restart preparation, execution, and publication.
|
||||
//!
|
||||
//! This module coordinates the service job, runtime observation and comparison,
|
||||
//! durable verification bookkeeping, and applied state publication. Low-level
|
||||
//! service and runtime adapters remain separate.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::backup::{self, BackupReason, HistoricalApplyStatus, SavedBytes};
|
||||
use crate::config::ResolvedSettings;
|
||||
use crate::domain::{PublicKey, Snapshot};
|
||||
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
|
||||
use crate::runtime::transport::TransportError;
|
||||
use crate::runtime::{self, RuntimeObservation, SavedPeerProjection, TechnicalMatch};
|
||||
use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome};
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::{ConfigDocument, SectionKind};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use super::{AppError, Application, clone_saved, historical_apply_from_saved, same_saved_bytes};
|
||||
|
||||
/// Read-only, saved-generation-bound preview used by the explicit restart confirmation flow.
|
||||
pub struct RestartPreview {
|
||||
saved: SavedBytes,
|
||||
interface: String,
|
||||
unit: String,
|
||||
timeout: Duration,
|
||||
projection: SavedPeerProjection,
|
||||
expected_clients: Option<Snapshot>,
|
||||
has_draft: bool,
|
||||
}
|
||||
|
||||
impl RestartPreview {
|
||||
pub fn interface(&self) -> &str {
|
||||
&self.interface
|
||||
}
|
||||
|
||||
pub fn systemd_unit(&self) -> &str {
|
||||
&self.unit
|
||||
}
|
||||
|
||||
pub const fn has_draft(&self) -> bool {
|
||||
self.has_draft
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartChoice {
|
||||
Cancel,
|
||||
RestartSavedState,
|
||||
DiscardDraftAndRestart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartStage {
|
||||
Cancelled,
|
||||
Preparation,
|
||||
Backup,
|
||||
Service,
|
||||
RuntimeRead,
|
||||
Verification,
|
||||
Persistence,
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartFailureKind {
|
||||
InvalidOrStaleSavedGeneration,
|
||||
UnsafeSaveConfig,
|
||||
UnsupportedSavedShape,
|
||||
BackupFailed,
|
||||
DraftDiscardFailed,
|
||||
ServiceFailed,
|
||||
RuntimeReadFailed,
|
||||
RuntimeNoDevice,
|
||||
RuntimePermissionDenied,
|
||||
RuntimeMalformed,
|
||||
RuntimeTransport,
|
||||
RuntimeTimeout,
|
||||
RuntimeLimitExceeded,
|
||||
RuntimeMismatch,
|
||||
VerificationPersistenceFailed,
|
||||
}
|
||||
|
||||
/// Secret-free explanation for saved configuration that cannot be used as a
|
||||
/// restart verification target. The identity is a public WireGuard key.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RestartDiagnostic {
|
||||
pub identity: Option<PublicKey>,
|
||||
pub section_line: Option<usize>,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RestartReport {
|
||||
pub stage: RestartStage,
|
||||
pub failure: Option<RestartFailureKind>,
|
||||
pub service_failure: Option<service::RestartFailure>,
|
||||
pub service_phase: Option<service::RestartPhase>,
|
||||
pub systemd_unit: Option<String>,
|
||||
pub backup_path: Option<std::path::PathBuf>,
|
||||
pub restart_submitted: bool,
|
||||
pub restart_may_still_complete: bool,
|
||||
pub manual_recovery_required: bool,
|
||||
}
|
||||
|
||||
pub(super) struct RestartAttempt {
|
||||
pub(super) id: u64,
|
||||
pub(super) saved: SavedBytes,
|
||||
}
|
||||
|
||||
pub(super) struct PreparedRestart {
|
||||
pub(super) attempt_id: u64,
|
||||
pub(super) installed: SavedBytes,
|
||||
pub(super) settings: ResolvedSettings,
|
||||
pub(super) projection: SavedPeerProjection,
|
||||
pub(super) expected_clients: Option<Snapshot>,
|
||||
pub(super) backup_path: std::path::PathBuf,
|
||||
pub(super) deadline: service::RestartDeadline,
|
||||
}
|
||||
|
||||
pub(super) fn preview_restart(application: &Application) -> Result<RestartPreview, AppError> {
|
||||
let installed = application.store.read_saved()?;
|
||||
if !same_saved_bytes(&installed, &application.saved) {
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
let settings = application.store.resolved_settings_for(&installed)?;
|
||||
let document = ConfigDocument::parse(&installed.server_config)?;
|
||||
if document
|
||||
.sections()
|
||||
.iter()
|
||||
.filter(|section| section.kind == SectionKind::Interface)
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::InvalidSavedState);
|
||||
}
|
||||
if document.has_unsafe_save_config() {
|
||||
return Err(AppError::UnsafeSaveConfig);
|
||||
}
|
||||
let (projection, expected_clients) =
|
||||
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
|
||||
let expected_clients =
|
||||
expected_clients.filter(|snapshot| snapshot == application.draft.states.saved());
|
||||
Ok(RestartPreview {
|
||||
saved: clone_saved(&installed),
|
||||
interface: settings.interface_name,
|
||||
unit: settings.systemd_unit,
|
||||
timeout: settings.restart_timeout,
|
||||
projection,
|
||||
expected_clients,
|
||||
has_draft: application.has_draft(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the reviewed preview and preserve the established preparation
|
||||
/// order: finalize the pre-restart backup before discarding any draft, then bind
|
||||
/// the one-use attempt and initialize the single deadline before service work.
|
||||
pub(super) fn prepare_confirmed_restart(
|
||||
application: &mut Application,
|
||||
preview: RestartPreview,
|
||||
choice: RestartChoice,
|
||||
now: &impl Fn() -> Instant,
|
||||
) -> Result<PreparedRestart, RestartReport> {
|
||||
if choice == RestartChoice::Cancel {
|
||||
return Err(report(
|
||||
RestartStage::Cancelled,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
application.active_restart_attempt = None;
|
||||
if application.has_draft() != preview.has_draft
|
||||
|| (preview.has_draft && choice != RestartChoice::DiscardDraftAndRestart)
|
||||
|| (!preview.has_draft && choice == RestartChoice::DiscardDraftAndRestart)
|
||||
{
|
||||
return Err(report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
let installed = match application.store.read_saved() {
|
||||
Ok(saved)
|
||||
if same_saved_bytes(&saved, &preview.saved)
|
||||
&& same_saved_bytes(&saved, &application.saved) =>
|
||||
{
|
||||
saved
|
||||
}
|
||||
_ => {
|
||||
application.active_restart_attempt = None;
|
||||
return Err(report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
};
|
||||
let settings = match application.store.resolved_settings_for(&installed) {
|
||||
Ok(settings) => settings,
|
||||
Err(_) => return Err(preparation_failure()),
|
||||
};
|
||||
let document = match ConfigDocument::parse(&installed.server_config) {
|
||||
Ok(document) => document,
|
||||
Err(_) => return Err(preparation_failure()),
|
||||
};
|
||||
if document
|
||||
.sections()
|
||||
.iter()
|
||||
.filter(|section| section.kind == SectionKind::Interface)
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
return Err(preparation_failure());
|
||||
}
|
||||
if document.has_unsafe_save_config() {
|
||||
return Err(report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::UnsafeSaveConfig),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
let (projection, expected_clients) = match restart_projection_from_document(&document) {
|
||||
Ok(projection) => projection,
|
||||
Err(_) => {
|
||||
return Err(report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::UnsupportedSavedShape),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
};
|
||||
let expected_clients =
|
||||
expected_clients.filter(|snapshot| snapshot == application.draft.states.saved());
|
||||
if settings.interface_name != preview.interface
|
||||
|| settings.systemd_unit != preview.unit
|
||||
|| settings.restart_timeout != preview.timeout
|
||||
|| projection != preview.projection
|
||||
|| expected_clients != preview.expected_clients
|
||||
{
|
||||
return Err(preparation_failure());
|
||||
}
|
||||
let historical_apply = match historical_apply_from_saved(&installed) {
|
||||
Ok(status) => status,
|
||||
Err(_) => return Err(preparation_failure()),
|
||||
};
|
||||
let backup_path = match backup::create_archive(
|
||||
application.store.archives_directory(),
|
||||
BackupReason::PreRestart,
|
||||
historical_apply,
|
||||
&installed,
|
||||
) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
application.active_restart_attempt = None;
|
||||
return Err(report(
|
||||
RestartStage::Backup,
|
||||
Some(RestartFailureKind::BackupFailed),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let id = application.next_restart_attempt;
|
||||
let Some(next_id) = id.checked_add(1) else {
|
||||
return Err(report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
|
||||
Some(backup_path),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
};
|
||||
application.next_restart_attempt = next_id;
|
||||
application.active_restart_attempt = Some(id);
|
||||
if choice == RestartChoice::DiscardDraftAndRestart && application.discard_draft().is_err() {
|
||||
application.active_restart_attempt = None;
|
||||
return Err(report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::DraftDiscardFailed),
|
||||
Some(backup_path),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
application.draft.states.runtime_knowledge_unknown();
|
||||
let deadline = service::RestartDeadline::new(now(), settings.restart_timeout);
|
||||
Ok(PreparedRestart {
|
||||
attempt_id: id,
|
||||
installed,
|
||||
settings,
|
||||
projection,
|
||||
expected_clients,
|
||||
backup_path,
|
||||
deadline,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the confirmed restart scenario after preparation has completed. The
|
||||
/// backup is already durable before this function is entered; exactly one
|
||||
/// shared deadline bounds the service job and the subsequent runtime read.
|
||||
pub(super) fn execute_confirmed_restart(
|
||||
application: &mut Application,
|
||||
prepared: PreparedRestart,
|
||||
service: impl FnOnce(&str, RestartDeadline) -> RestartExecution,
|
||||
runtime_read: impl FnOnce(&str, Duration) -> Result<RuntimeObservation, TransportError>,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
now: impl Fn() -> Instant,
|
||||
) -> RestartReport {
|
||||
let PreparedRestart {
|
||||
attempt_id,
|
||||
installed,
|
||||
settings,
|
||||
projection,
|
||||
expected_clients,
|
||||
backup_path,
|
||||
deadline,
|
||||
} = prepared;
|
||||
let execution = service(&settings.systemd_unit, deadline);
|
||||
let job = execution.outcome;
|
||||
let submitted = execution.submitted;
|
||||
let service_phase = execution.phase;
|
||||
if job != RestartOutcome::Succeeded {
|
||||
application.active_restart_attempt = None;
|
||||
let may_complete = submitted
|
||||
&& !matches!(
|
||||
job,
|
||||
RestartOutcome::Failed(
|
||||
service::RestartFailure::JobFailed
|
||||
| service::RestartFailure::JobDependency
|
||||
| service::RestartFailure::JobTimedOut
|
||||
| service::RestartFailure::JobCanceled
|
||||
| service::RestartFailure::JobSkipped
|
||||
| service::RestartFailure::JobResultUnknown
|
||||
)
|
||||
);
|
||||
let mut report = report(
|
||||
RestartStage::Service,
|
||||
Some(RestartFailureKind::ServiceFailed),
|
||||
Some(backup_path),
|
||||
submitted,
|
||||
may_complete,
|
||||
submitted,
|
||||
);
|
||||
report.service_failure = match job {
|
||||
RestartOutcome::Failed(failure) => Some(failure),
|
||||
RestartOutcome::Waiting | RestartOutcome::Succeeded => None,
|
||||
};
|
||||
report.service_phase = Some(service_phase);
|
||||
report.systemd_unit = Some(settings.systemd_unit);
|
||||
return report;
|
||||
}
|
||||
let remaining = deadline.remaining_at(now());
|
||||
if remaining.is_zero() {
|
||||
application.active_restart_attempt = None;
|
||||
return report(
|
||||
RestartStage::RuntimeRead,
|
||||
Some(RestartFailureKind::RuntimeTimeout),
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let observation = match runtime_read(&settings.interface_name, remaining) {
|
||||
Ok(observation) => observation,
|
||||
Err(error) => {
|
||||
application.active_restart_attempt = None;
|
||||
return report(
|
||||
RestartStage::RuntimeRead,
|
||||
Some(match error {
|
||||
TransportError::NoDevice => RestartFailureKind::RuntimeNoDevice,
|
||||
TransportError::PermissionDenied => RestartFailureKind::RuntimePermissionDenied,
|
||||
TransportError::Malformed => RestartFailureKind::RuntimeMalformed,
|
||||
TransportError::Transport => RestartFailureKind::RuntimeTransport,
|
||||
TransportError::Timeout => RestartFailureKind::RuntimeTimeout,
|
||||
TransportError::LimitExceeded => RestartFailureKind::RuntimeLimitExceeded,
|
||||
}),
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
if deadline.is_expired_at(now()) {
|
||||
application.active_restart_attempt = None;
|
||||
return report(
|
||||
RestartStage::Verification,
|
||||
Some(RestartFailureKind::RuntimeTimeout),
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if runtime::compare_peers(&projection, &settings.interface_name, &observation)
|
||||
!= TechnicalMatch::Match
|
||||
{
|
||||
application.active_restart_attempt = None;
|
||||
return report(
|
||||
RestartStage::Verification,
|
||||
Some(RestartFailureKind::RuntimeMismatch),
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let Some(verified_at) = OffsetDateTime::now_utc()
|
||||
.replace_nanosecond(0)
|
||||
.ok()
|
||||
.and_then(|timestamp| timestamp.format(&Rfc3339).ok())
|
||||
else {
|
||||
application.active_restart_attempt = None;
|
||||
return report(
|
||||
RestartStage::Persistence,
|
||||
Some(RestartFailureKind::VerificationPersistenceFailed),
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
};
|
||||
let attempt = RestartAttempt {
|
||||
id: attempt_id,
|
||||
saved: clone_saved(&installed),
|
||||
};
|
||||
if mark_verified_with_attempt(application, attempt, expected_clients, verified_at, install)
|
||||
.is_err()
|
||||
{
|
||||
application.active_restart_attempt = None;
|
||||
return report(
|
||||
RestartStage::Persistence,
|
||||
Some(RestartFailureKind::VerificationPersistenceFailed),
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
report(
|
||||
RestartStage::Complete,
|
||||
None,
|
||||
Some(backup_path),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist verified apply knowledge using the active restart's one-use attempt
|
||||
/// and exact saved generation. Session publication follows durable success.
|
||||
pub(super) fn mark_verified_with_attempt(
|
||||
application: &mut Application,
|
||||
attempt: RestartAttempt,
|
||||
observed_management_snapshot: Option<Snapshot>,
|
||||
verified_at_utc: String,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<(), AppError> {
|
||||
if application.active_restart_attempt != Some(attempt.id) {
|
||||
return Err(AppError::PreRestartBackupRequired);
|
||||
}
|
||||
let installed = application.store.read_saved()?;
|
||||
if !same_saved_bytes(&installed, &application.saved)
|
||||
|| !same_saved_bytes(&installed, &attempt.saved)
|
||||
{
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
let mut next_states = application.draft.states.clone();
|
||||
next_states.prepare_technically_verified_result(observed_management_snapshot)?;
|
||||
let mut persisted = match &installed.internal_state {
|
||||
Some(bytes) => PersistedState::from_toml(bytes)?,
|
||||
None => PersistedState::new(
|
||||
next_states
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.map(ToString::to_string),
|
||||
None,
|
||||
)?,
|
||||
};
|
||||
persisted.reserved_addresses = next_states
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let historical_apply = HistoricalApplyStatus::Verified {
|
||||
verified_at_utc: verified_at_utc.clone(),
|
||||
};
|
||||
persisted.last_verified_at_utc = Some(verified_at_utc);
|
||||
let mut proposed = installed;
|
||||
proposed.internal_state = Some(persisted.to_toml()?);
|
||||
application.store.validate_saved(&proposed)?;
|
||||
if let Err(error) = install(&application.store, &proposed, BackupComplete(())) {
|
||||
application
|
||||
.first_delivery
|
||||
.invalidate_active_authorizations();
|
||||
match application.store.read_saved() {
|
||||
Ok(current) => {
|
||||
let document = ConfigDocument::parse(¤t.server_config).ok();
|
||||
let vault = current
|
||||
.vault
|
||||
.as_deref()
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
application
|
||||
.first_delivery
|
||||
.retain_matching(&document, vault.as_ref());
|
||||
} else {
|
||||
application.first_delivery.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => application.first_delivery.clear(),
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
application.saved = proposed;
|
||||
application.draft.states = next_states;
|
||||
application.verified_saved_projection = Some(SavedPeerProjection::from_document(
|
||||
&application.draft.document,
|
||||
));
|
||||
application.historical_apply = historical_apply;
|
||||
application.active_restart_attempt = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preparation_failure() -> RestartReport {
|
||||
report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn report(
|
||||
stage: RestartStage,
|
||||
failure: Option<RestartFailureKind>,
|
||||
backup_path: Option<std::path::PathBuf>,
|
||||
restart_submitted: bool,
|
||||
restart_may_still_complete: bool,
|
||||
manual_recovery_required: bool,
|
||||
) -> RestartReport {
|
||||
RestartReport {
|
||||
stage,
|
||||
failure,
|
||||
service_failure: None,
|
||||
service_phase: None,
|
||||
systemd_unit: None,
|
||||
backup_path,
|
||||
restart_submitted,
|
||||
restart_may_still_complete,
|
||||
manual_recovery_required,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn restart_projection_from_document(
|
||||
document: &ConfigDocument,
|
||||
) -> Result<(SavedPeerProjection, Option<Snapshot>), Vec<RestartDiagnostic>> {
|
||||
use crate::wireguard::{DocumentIssue, PeerIssue};
|
||||
|
||||
let mut diagnostics = Vec::new();
|
||||
for issue in document.issues() {
|
||||
let (line, reason) = match issue {
|
||||
DocumentIssue::MalformedSectionHeader { line } => (*line, "malformed section header"),
|
||||
DocumentIssue::DirectiveOutsideSection { line } => {
|
||||
(*line, "directive appears outside a section")
|
||||
}
|
||||
DocumentIssue::MalformedDirective { line } => (*line, "malformed directive"),
|
||||
DocumentIssue::ManagedMetadata(_) => continue,
|
||||
};
|
||||
diagnostics.push(RestartDiagnostic {
|
||||
identity: None,
|
||||
section_line: Some(line),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
for peer in document.peers() {
|
||||
let identity = peer
|
||||
.identity
|
||||
.or_else(|| peer.public_key_candidates.first().copied());
|
||||
for issue in &peer.issues {
|
||||
let reason = match issue {
|
||||
PeerIssue::MissingPublicKey => Some("peer is missing a public key"),
|
||||
PeerIssue::MalformedPublicKey { .. } => Some("peer has a malformed public key"),
|
||||
PeerIssue::DuplicatePublicKeyField { .. } => {
|
||||
Some("peer has duplicate public key fields")
|
||||
}
|
||||
PeerIssue::DuplicatePeerIdentity { .. } => {
|
||||
Some("public key is used by multiple peer sections")
|
||||
}
|
||||
PeerIssue::MalformedAllowedIp { .. } => {
|
||||
Some("peer has a malformed AllowedIPs item")
|
||||
}
|
||||
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
diagnostics.push(RestartDiagnostic {
|
||||
identity,
|
||||
section_line: Some(peer.section_line),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let projection = SavedPeerProjection::from_document(document);
|
||||
if (projection.document_unverifiable
|
||||
|| !projection.unverifiable_peer_indices.is_empty()
|
||||
|| !projection.duplicate_public_keys.is_empty())
|
||||
&& diagnostics.is_empty()
|
||||
{
|
||||
diagnostics.push(RestartDiagnostic {
|
||||
identity: None,
|
||||
section_line: None,
|
||||
reason: "saved peer set is not uniquely verifiable",
|
||||
});
|
||||
}
|
||||
if !diagnostics.is_empty() {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
|
||||
let mut clients = Vec::with_capacity(document.peers().len());
|
||||
for peer in document.peers() {
|
||||
let Some(identity) = peer.identity else {
|
||||
return Err(vec![RestartDiagnostic {
|
||||
identity: None,
|
||||
section_line: Some(peer.section_line),
|
||||
reason: "peer identity is unavailable",
|
||||
}]);
|
||||
};
|
||||
let Ok(client) = document.project_client(&identity) else {
|
||||
clients.clear();
|
||||
break;
|
||||
};
|
||||
clients.push(client);
|
||||
}
|
||||
let management_snapshot = (clients.len() == document.peers().len())
|
||||
.then(|| Snapshot::new(clients).ok())
|
||||
.flatten();
|
||||
Ok((projection, management_snapshot))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Previewed, saved-state-only restore scenario.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::backup::{self, BackupReason, SavedBytes};
|
||||
use crate::domain::{ApplyStatus, ClientStates};
|
||||
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::{ConfigDocument, PeerInspection};
|
||||
|
||||
use super::{
|
||||
AppError, Application, RestorePreview, historical_apply_from_saved, safe_archive_name,
|
||||
same_saved_bytes, snapshot_from_document,
|
||||
};
|
||||
|
||||
struct PreparedRestore {
|
||||
proposed: SavedBytes,
|
||||
resolved_settings: crate::config::ResolvedSettings,
|
||||
document: ConfigDocument,
|
||||
vault: Option<VaultBundle>,
|
||||
states: ClientStates,
|
||||
}
|
||||
|
||||
pub(super) fn preview_restore(
|
||||
application: &Application,
|
||||
file_name: &str,
|
||||
) -> Result<RestorePreview, AppError> {
|
||||
if !safe_archive_name(file_name) {
|
||||
return Err(AppError::InvalidArchiveSelection);
|
||||
}
|
||||
let installed_generation = application.store.read_saved()?;
|
||||
let current_document = ConfigDocument::parse(&installed_generation.server_config)?;
|
||||
let selected_path = application.store.archives_directory().join(file_name);
|
||||
let archive = backup::preview_archive(&selected_path)?;
|
||||
application.store.validate_saved(&archive.saved_bytes)?;
|
||||
let mut proposed = archive.saved_bytes;
|
||||
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
let mut states = ClientStates::new(
|
||||
snapshot_from_document(&document),
|
||||
None,
|
||||
ApplyStatus::Unknown,
|
||||
)?;
|
||||
|
||||
let mut carried_reservations = application
|
||||
.draft
|
||||
.states
|
||||
.address_reservations()
|
||||
.all()
|
||||
.collect::<BTreeSet<_>>();
|
||||
carried_reservations.extend(
|
||||
snapshot_from_document(¤t_document)
|
||||
.clients()
|
||||
.flat_map(|client| client.addresses.iter()),
|
||||
);
|
||||
// Saved peers outside the manageable client projection can still own
|
||||
// exact host routes. Keep those addresses if this restore removes them;
|
||||
// never enumerate broad routes or infer other addresses from a subnet.
|
||||
carried_reservations.extend(exact_host_route_reservations(¤t_document));
|
||||
|
||||
let archived_had_state = proposed.internal_state.is_some();
|
||||
let mut archived_reservations = BTreeSet::new();
|
||||
if let Some(state_bytes) = &proposed.internal_state {
|
||||
let archived = PersistedState::from_toml(state_bytes)?;
|
||||
archived_reservations = archived
|
||||
.reserved_addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
address
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::InvalidSavedState)
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
}
|
||||
let restored_exact_hosts = exact_host_routes(&document);
|
||||
let mut reservations = carried_reservations.clone();
|
||||
reservations.extend(archived_reservations.iter().copied());
|
||||
reservations.retain(|address| {
|
||||
let assigned_to_restored_client = states
|
||||
.saved()
|
||||
.clients()
|
||||
.any(|client| client.addresses.iter().any(|saved| saved == *address));
|
||||
!assigned_to_restored_client
|
||||
&& (archived_reservations.contains(address)
|
||||
|| !carried_reservations.contains(address)
|
||||
|| !restored_exact_hosts.contains(address))
|
||||
});
|
||||
if !reservations.is_empty() || archived_had_state {
|
||||
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
|
||||
state.apply_to(&mut states)?;
|
||||
proposed.internal_state = Some(state.to_toml()?);
|
||||
}
|
||||
|
||||
application.store.validate_saved(&proposed)?;
|
||||
Ok(RestorePreview {
|
||||
created_at_utc: archive.created_at_utc,
|
||||
reason: archive.reason,
|
||||
historical_apply: archive.historical_apply,
|
||||
installed_generation,
|
||||
saved_bytes: proposed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn confirm_restore(
|
||||
application: &mut Application,
|
||||
preview: RestorePreview,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let current = application.store.read_saved()?;
|
||||
if !same_saved_bytes(¤t, &preview.installed_generation) {
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
|
||||
let prepared = prepare_restore(application, preview.saved_bytes)?;
|
||||
let historical_apply = historical_apply_from_saved(¤t)?;
|
||||
let archive = backup::create_archive(
|
||||
application.store.archives_directory(),
|
||||
BackupReason::PreRestore,
|
||||
historical_apply,
|
||||
¤t,
|
||||
)?;
|
||||
|
||||
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
|
||||
// A partial journaled install may leave disk in a recovery-required
|
||||
// state. Revoke session-only delivery rights until the store is read
|
||||
// again and the actual installed generation is known.
|
||||
application.first_delivery.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
application.saved = prepared.proposed;
|
||||
application
|
||||
.store
|
||||
.set_resolved_settings(prepared.resolved_settings);
|
||||
application.draft.document = prepared.document;
|
||||
application.draft.vault = prepared.vault;
|
||||
application.draft.states = prepared.states;
|
||||
application.first_delivery.clear();
|
||||
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
|
||||
application.verified_saved_projection = None;
|
||||
application.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
fn prepare_restore(
|
||||
application: &Application,
|
||||
proposed: SavedBytes,
|
||||
) -> Result<PreparedRestore, AppError> {
|
||||
application.store.validate_saved(&proposed)?;
|
||||
// Check installation path compatibility and parse every in-memory value
|
||||
// before the precautionary archive or file replacement begins.
|
||||
let resolved_settings = application.store.resolved_settings_for(&proposed)?;
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
let vault = proposed
|
||||
.vault
|
||||
.as_deref()
|
||||
.map(std::str::from_utf8)
|
||||
.transpose()
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
let mut states = ClientStates::new(
|
||||
snapshot_from_document(&document),
|
||||
None,
|
||||
ApplyStatus::Unknown,
|
||||
)?;
|
||||
if let Some(bytes) = &proposed.internal_state {
|
||||
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
|
||||
}
|
||||
Ok(PreparedRestore {
|
||||
proposed,
|
||||
resolved_settings,
|
||||
document,
|
||||
vault,
|
||||
states,
|
||||
})
|
||||
}
|
||||
|
||||
fn exact_host_route_reservations(document: &ConfigDocument) -> BTreeSet<IpAddr> {
|
||||
let mut reservations = BTreeSet::new();
|
||||
for peer in document.peers() {
|
||||
if manageable_peer(document, peer) {
|
||||
continue;
|
||||
}
|
||||
for route in &peer.allowed_ips {
|
||||
match route {
|
||||
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||
reservations.insert(IpAddr::V4(network.addr()));
|
||||
}
|
||||
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||
reservations.insert(IpAddr::V6(network.addr()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
reservations
|
||||
}
|
||||
|
||||
fn exact_host_routes(document: &ConfigDocument) -> BTreeSet<IpAddr> {
|
||||
document
|
||||
.peers()
|
||||
.iter()
|
||||
.flat_map(|peer| peer.allowed_ips.iter())
|
||||
.filter_map(|route| match route {
|
||||
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||
Some(IpAddr::V4(network.addr()))
|
||||
}
|
||||
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||
Some(IpAddr::V6(network.addr()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn manageable_peer(document: &ConfigDocument, peer: &PeerInspection) -> bool {
|
||||
peer.identity
|
||||
.is_some_and(|identity| document.project_client(&identity).is_ok())
|
||||
}
|
||||
+973
@@ -0,0 +1,973 @@
|
||||
//! Bounded, versioned backups of installed saved bytes.
|
||||
//!
|
||||
//! This module never extracts archive paths and never performs runtime VPN operations.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use rustix::fs::{self as rfs, AtFlags, Mode, OFlags};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
const BLOCK: usize = 512;
|
||||
const MAX_ARCHIVE: usize = 26 * 1024 * 1024;
|
||||
const MAX_SETTINGS: usize = 1024 * 1024;
|
||||
const MAX_SERVER: usize = 4 * 1024 * 1024;
|
||||
const MAX_STATE: usize = 2 * 1024 * 1024;
|
||||
const MAX_VAULT: usize = 16 * 1024 * 1024;
|
||||
const MAX_LISTING_ENTRIES: usize = 4096;
|
||||
const MANIFEST: &str = "manifest.toml";
|
||||
const SETTINGS: &str = "settings.toml";
|
||||
const SERVER: &str = "server.conf";
|
||||
const STATE: &str = "internal-state.toml";
|
||||
const VAULT: &str = "vault.toml";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ArchiveCheckpoint {
|
||||
Write,
|
||||
Sync,
|
||||
Publish,
|
||||
TemporaryCleanup,
|
||||
DirectorySync,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum BackupReason {
|
||||
Manual,
|
||||
PreCommit,
|
||||
PreRestore,
|
||||
PreRestart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub enum HistoricalApplyStatus {
|
||||
Unknown,
|
||||
Verified { verified_at_utc: String },
|
||||
}
|
||||
|
||||
/// Exact installed bytes captured by a trusted caller. `None` means the owned
|
||||
/// vault/state component was genuinely absent at capture time.
|
||||
pub struct SavedBytes {
|
||||
pub settings: Vec<u8>,
|
||||
pub server_config: Vec<u8>,
|
||||
pub internal_state: Option<Vec<u8>>,
|
||||
pub vault: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for SavedBytes {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("SavedBytes")
|
||||
.field("settings_len", &self.settings.len())
|
||||
.field("server_config_len", &self.server_config.len())
|
||||
.field("internal_state_present", &self.internal_state.is_some())
|
||||
.field("vault_present", &self.vault.is_some())
|
||||
.field("contents", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
version: u32,
|
||||
created_at_utc: String,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
members: Vec<MemberRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MemberRecord {
|
||||
name: String,
|
||||
present: bool,
|
||||
length: Option<u64>,
|
||||
}
|
||||
|
||||
/// Validated read-only archive contents. Byte payloads are deliberately omitted by Debug.
|
||||
pub struct ArchivePreview {
|
||||
pub created_at_utc: String,
|
||||
pub reason: BackupReason,
|
||||
pub historical_apply: HistoricalApplyStatus,
|
||||
pub saved_bytes: SavedBytes,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ArchivePreview {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ArchivePreview")
|
||||
.field("created_at_utc", &self.created_at_utc)
|
||||
.field("reason", &self.reason)
|
||||
.field("historical_apply", &self.historical_apply)
|
||||
.field("saved_bytes", &self.saved_bytes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ArchiveSummary {
|
||||
pub file_name: String,
|
||||
pub created_at_utc: String,
|
||||
pub reason: BackupReason,
|
||||
pub historical_apply: HistoricalApplyStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BackupError {
|
||||
#[error("backup input exceeds a size limit")]
|
||||
TooLarge,
|
||||
#[error("backup archive is invalid or incomplete")]
|
||||
InvalidArchive,
|
||||
#[error("backup manifest version is not supported")]
|
||||
UnsupportedVersion,
|
||||
#[error("backup timestamp is invalid")]
|
||||
InvalidTimestamp,
|
||||
#[error("backup filesystem operation failed")]
|
||||
Io(#[source] io::Error),
|
||||
#[error("backup archive serialization failed")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl From<io::Error> for BackupError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
impl From<rustix::io::Errno> for BackupError {
|
||||
fn from(error: rustix::io::Errno) -> Self {
|
||||
Self::Io(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete archive to a private temporary file and publish it without replacement.
|
||||
pub(crate) fn create_archive(
|
||||
archive_directory: &Path,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
) -> Result<PathBuf, BackupError> {
|
||||
create_archive_with_hook(archive_directory, reason, historical_apply, saved, |_| {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create_archive_with_hook(
|
||||
archive_directory: &Path,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
mut checkpoint: impl FnMut(ArchiveCheckpoint) -> io::Result<()>,
|
||||
) -> Result<PathBuf, BackupError> {
|
||||
validate_saved(saved)?;
|
||||
validate_status(&historical_apply)?;
|
||||
let created_at_utc = OffsetDateTime::now_utc()
|
||||
.replace_nanosecond(0)
|
||||
.map_err(|_| BackupError::InvalidTimestamp)?
|
||||
.format(&Rfc3339)
|
||||
.map_err(|_| BackupError::InvalidTimestamp)?;
|
||||
let bytes = encode_archive(&created_at_utc, reason, historical_apply, saved)?;
|
||||
let directory = ensure_archive_directory(archive_directory)?;
|
||||
|
||||
let stamp = created_at_utc.replace([':', '-'], "");
|
||||
for suffix in 0_u32..1000 {
|
||||
let name = if suffix == 0 {
|
||||
format!("wireguard-admin-{stamp}.tar")
|
||||
} else {
|
||||
format!("wireguard-admin-{stamp}-{suffix}.tar")
|
||||
};
|
||||
let target = archive_directory.join(name);
|
||||
let temporary = format!(
|
||||
".wireguard-admin-backup-{}-{}",
|
||||
std::process::id(),
|
||||
getrandom::u32().map_err(|_| BackupError::Serialization)?
|
||||
);
|
||||
let fd = rfs::openat(
|
||||
&directory,
|
||||
temporary.as_str(),
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::from_bits_truncate(0o600),
|
||||
)?;
|
||||
let mut file = File::from(fd);
|
||||
let write_result = (|| -> Result<(), BackupError> {
|
||||
checkpoint(ArchiveCheckpoint::Write)?;
|
||||
file.write_all(&bytes)?;
|
||||
checkpoint(ArchiveCheckpoint::Sync)?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = write_result {
|
||||
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
|
||||
return Err(error);
|
||||
}
|
||||
let target_name = target.file_name().ok_or(BackupError::InvalidArchive)?;
|
||||
if let Err(error) = checkpoint(ArchiveCheckpoint::Publish) {
|
||||
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
|
||||
return Err(BackupError::Io(error));
|
||||
}
|
||||
match rfs::linkat(
|
||||
&directory,
|
||||
temporary.as_str(),
|
||||
&directory,
|
||||
target_name,
|
||||
AtFlags::empty(),
|
||||
) {
|
||||
Ok(()) => {
|
||||
checkpoint(ArchiveCheckpoint::TemporaryCleanup)?;
|
||||
rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?;
|
||||
checkpoint(ArchiveCheckpoint::DirectorySync)?;
|
||||
File::from(directory.try_clone()?).sync_all()?;
|
||||
return Ok(target);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
|
||||
return Err(BackupError::Io(error.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(BackupError::Io(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"archive name collisions",
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse and validate a complete bounded archive without extracting any member.
|
||||
pub fn preview_archive_bytes(input: &[u8]) -> Result<ArchivePreview, BackupError> {
|
||||
if input.len() > MAX_ARCHIVE || input.len() < BLOCK * 4 || !input.len().is_multiple_of(BLOCK) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut offset = 0;
|
||||
let mut records = Vec::new();
|
||||
let mut previous_name: Option<String> = None;
|
||||
loop {
|
||||
let header = input
|
||||
.get(offset..offset + BLOCK)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if header.iter().all(|byte| *byte == 0) {
|
||||
let second = input
|
||||
.get(offset + BLOCK..offset + BLOCK * 2)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if second.iter().any(|byte| *byte != 0)
|
||||
|| input[offset + BLOCK * 2..].iter().any(|byte| *byte != 0)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if records.len() >= 5 {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let (name, size) = parse_header(header)?;
|
||||
if previous_name
|
||||
.as_ref()
|
||||
.is_some_and(|previous| previous == &name)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
previous_name = Some(name.clone());
|
||||
let data_start = offset + BLOCK;
|
||||
let size_usize = usize::try_from(size).map_err(|_| BackupError::InvalidArchive)?;
|
||||
let padded = size_usize
|
||||
.checked_add(BLOCK - 1)
|
||||
.ok_or(BackupError::InvalidArchive)?
|
||||
/ BLOCK
|
||||
* BLOCK;
|
||||
let data_end = data_start
|
||||
.checked_add(size_usize)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
let next = data_start
|
||||
.checked_add(padded)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
let body = input
|
||||
.get(data_start..data_end)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if input
|
||||
.get(data_end..next)
|
||||
.ok_or(BackupError::InvalidArchive)?
|
||||
.iter()
|
||||
.any(|byte| *byte != 0)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
records.push((name, body.to_vec()));
|
||||
offset = next;
|
||||
}
|
||||
let first = records.first().ok_or(BackupError::InvalidArchive)?;
|
||||
if first.0 != MANIFEST {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let manifest: Manifest =
|
||||
toml::from_str(std::str::from_utf8(&first.1).map_err(|_| BackupError::InvalidArchive)?)
|
||||
.map_err(|_| BackupError::InvalidArchive)?;
|
||||
if manifest.version != VERSION {
|
||||
return Err(BackupError::UnsupportedVersion);
|
||||
}
|
||||
validate_timestamp(&manifest.created_at_utc)?;
|
||||
validate_status(&manifest.historical_apply)?;
|
||||
let expected_names = [SETTINGS, SERVER, STATE, VAULT];
|
||||
if manifest.members.len() != expected_names.len() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut payloads: Vec<Option<Vec<u8>>> = vec![None; 4];
|
||||
for (idx, expected) in expected_names.iter().enumerate() {
|
||||
let spec = &manifest.members[idx];
|
||||
if spec.name != *expected || spec.present != spec.length.is_some() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let max = limit_for(expected);
|
||||
if spec.length.is_some_and(|len| len > max as u64) {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
}
|
||||
if records.len() != 1 + manifest.members.iter().filter(|m| m.present).count() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut last_payload_index = None;
|
||||
for (name, body) in records.iter().skip(1) {
|
||||
let idx = expected_names
|
||||
.iter()
|
||||
.position(|expected| *expected == name)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if last_payload_index.is_some_and(|last| idx <= last) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
last_payload_index = Some(idx);
|
||||
let spec = &manifest.members[idx];
|
||||
if !spec.present || spec.length != Some(body.len() as u64) || body.len() > limit_for(name) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
if payloads[idx].replace(body.clone()).is_some() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
}
|
||||
for (idx, spec) in manifest.members.iter().enumerate() {
|
||||
if spec.present != payloads[idx].is_some() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
}
|
||||
Ok(ArchivePreview {
|
||||
created_at_utc: manifest.created_at_utc,
|
||||
reason: manifest.reason,
|
||||
historical_apply: manifest.historical_apply,
|
||||
saved_bytes: SavedBytes {
|
||||
settings: payloads[0].take().ok_or(BackupError::InvalidArchive)?,
|
||||
server_config: payloads[1].take().ok_or(BackupError::InvalidArchive)?,
|
||||
internal_state: payloads[2].take(),
|
||||
vault: payloads[3].take(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn preview_archive(path: &Path) -> Result<ArchivePreview, BackupError> {
|
||||
validate_path_components(path)?;
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_file() || metadata.len() > MAX_ARCHIVE as u64 {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(0o400000)
|
||||
.open(path)?;
|
||||
if !file.metadata()?.file_type().is_file() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
file.take((MAX_ARCHIVE + 1) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
preview_archive_bytes(&bytes)
|
||||
}
|
||||
|
||||
/// List only validated archives. Invalid files are omitted rather than trusted for display.
|
||||
pub fn list_archives(directory: &Path) -> Result<Vec<ArchiveSummary>, BackupError> {
|
||||
validate_path_components(directory)?;
|
||||
if !fs::symlink_metadata(directory)?.is_dir() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut summaries = Vec::new();
|
||||
let mut inspected = 0;
|
||||
for entry in fs::read_dir(directory)? {
|
||||
inspected += 1;
|
||||
if inspected > MAX_LISTING_ENTRIES {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
let name = entry.file_name();
|
||||
let Some(name_text) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file()
|
||||
|| !name_text.starts_with("wireguard-admin-")
|
||||
|| !name_text.ends_with(".tar")
|
||||
|| !name_text
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(preview) = preview_archive(&entry.path()) {
|
||||
summaries.push(ArchiveSummary {
|
||||
file_name: name_text.to_owned(),
|
||||
created_at_utc: preview.created_at_utc,
|
||||
reason: preview.reason,
|
||||
historical_apply: preview.historical_apply,
|
||||
});
|
||||
}
|
||||
}
|
||||
summaries.sort_by(|a, b| {
|
||||
b.created_at_utc
|
||||
.cmp(&a.created_at_utc)
|
||||
.then_with(|| a.file_name.cmp(&b.file_name))
|
||||
});
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
fn ensure_archive_directory(path: &Path) -> Result<std::os::fd::OwnedFd, BackupError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut directory = rfs::open(
|
||||
"/",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
for component in path.components().skip(1) {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
};
|
||||
match rfs::mkdirat(&directory, name, Mode::from_bits_truncate(0o700)) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(BackupError::Io(error.into())),
|
||||
}
|
||||
directory = rfs::openat(
|
||||
&directory,
|
||||
name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
}
|
||||
rfs::fchmod(&directory, Mode::from_bits_truncate(0o700))?;
|
||||
File::from(directory.try_clone()?).sync_all()?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn validate_path_components(path: &Path) -> Result<(), BackupError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut directory = rfs::open(
|
||||
"/",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
let components = path.components().skip(1).collect::<Vec<_>>();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
};
|
||||
if index + 1 == components.len() {
|
||||
// Final item may be a regular archive file, so reject symlinks via
|
||||
// no-follow open at the call site instead of requiring a directory.
|
||||
match rfs::openat(
|
||||
&directory,
|
||||
*name,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
) {
|
||||
Ok(fd) => {
|
||||
let metadata = File::from(fd).metadata()?;
|
||||
if !metadata.is_file() && !metadata.is_dir() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(BackupError::Io(error.into())),
|
||||
}
|
||||
} else {
|
||||
directory = rfs::openat(
|
||||
&directory,
|
||||
*name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_saved(saved: &SavedBytes) -> Result<(), BackupError> {
|
||||
for (bytes, max) in [
|
||||
(&saved.settings, MAX_SETTINGS),
|
||||
(&saved.server_config, MAX_SERVER),
|
||||
] {
|
||||
if bytes.len() > max {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
}
|
||||
if saved
|
||||
.internal_state
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| bytes.len() > MAX_STATE)
|
||||
|| saved
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| bytes.len() > MAX_VAULT)
|
||||
{
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_status(status: &HistoricalApplyStatus) -> Result<(), BackupError> {
|
||||
if let HistoricalApplyStatus::Verified { verified_at_utc } = status {
|
||||
validate_timestamp(verified_at_utc)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_timestamp(value: &str) -> Result<(), BackupError> {
|
||||
let parsed =
|
||||
OffsetDateTime::parse(value, &Rfc3339).map_err(|_| BackupError::InvalidTimestamp)?;
|
||||
if parsed.offset().whole_seconds() != 0
|
||||
|| parsed.nanosecond() != 0
|
||||
|| parsed
|
||||
.format(&Rfc3339)
|
||||
.map_err(|_| BackupError::InvalidTimestamp)?
|
||||
!= value
|
||||
{
|
||||
return Err(BackupError::InvalidTimestamp);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_archive(
|
||||
created_at: &str,
|
||||
reason: BackupReason,
|
||||
status: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
) -> Result<Vec<u8>, BackupError> {
|
||||
encode_archive_version(VERSION, created_at, reason, status, saved)
|
||||
}
|
||||
|
||||
fn encode_archive_version(
|
||||
version: u32,
|
||||
created_at: &str,
|
||||
reason: BackupReason,
|
||||
status: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
) -> Result<Vec<u8>, BackupError> {
|
||||
let members = vec![
|
||||
MemberRecord {
|
||||
name: SETTINGS.into(),
|
||||
present: true,
|
||||
length: Some(saved.settings.len() as u64),
|
||||
},
|
||||
MemberRecord {
|
||||
name: SERVER.into(),
|
||||
present: true,
|
||||
length: Some(saved.server_config.len() as u64),
|
||||
},
|
||||
MemberRecord {
|
||||
name: STATE.into(),
|
||||
present: saved.internal_state.is_some(),
|
||||
length: saved.internal_state.as_ref().map(|v| v.len() as u64),
|
||||
},
|
||||
MemberRecord {
|
||||
name: VAULT.into(),
|
||||
present: saved.vault.is_some(),
|
||||
length: saved.vault.as_ref().map(|v| v.len() as u64),
|
||||
},
|
||||
];
|
||||
let manifest = toml::to_string(&Manifest {
|
||||
version,
|
||||
created_at_utc: created_at.into(),
|
||||
reason,
|
||||
historical_apply: status,
|
||||
members,
|
||||
})
|
||||
.map_err(|_| BackupError::Serialization)?;
|
||||
let mut builder = tar::Builder::new(Vec::new());
|
||||
builder.mode(tar::HeaderMode::Deterministic);
|
||||
append(&mut builder, MANIFEST, manifest.as_bytes())?;
|
||||
append(&mut builder, SETTINGS, &saved.settings)?;
|
||||
append(&mut builder, SERVER, &saved.server_config)?;
|
||||
if let Some(state) = &saved.internal_state {
|
||||
append(&mut builder, STATE, state)?;
|
||||
}
|
||||
if let Some(vault) = &saved.vault {
|
||||
append(&mut builder, VAULT, vault)?;
|
||||
}
|
||||
builder.finish().map_err(|_| BackupError::Serialization)?;
|
||||
let bytes = builder
|
||||
.into_inner()
|
||||
.map_err(|_| BackupError::Serialization)?;
|
||||
if bytes.len() > MAX_ARCHIVE {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn append(
|
||||
builder: &mut tar::Builder<Vec<u8>>,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), BackupError> {
|
||||
let mut header = tar::Header::new_ustar();
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
header
|
||||
.set_path(name)
|
||||
.map_err(|_| BackupError::Serialization)?;
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o600);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mtime(0);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append(&header, bytes)
|
||||
.map_err(|_| BackupError::Serialization)
|
||||
}
|
||||
|
||||
fn parse_header(header: &[u8]) -> Result<(String, u64), BackupError> {
|
||||
if header.len() != BLOCK
|
||||
|| &header[257..263] != b"ustar\0"
|
||||
|| &header[263..265] != b"00"
|
||||
|| header[156] != 0 && header[156] != b'0'
|
||||
|| header[157..257].iter().any(|byte| *byte != 0)
|
||||
|| header[265..345].iter().any(|byte| *byte != 0)
|
||||
|| header[345..500].iter().any(|byte| *byte != 0)
|
||||
|| header[500..512].iter().any(|byte| *byte != 0)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let stored_checksum = parse_octal(&header[148..156])?;
|
||||
let checksum = header[..148]
|
||||
.iter()
|
||||
.chain(&header[156..])
|
||||
.map(|byte| *byte as u64)
|
||||
.sum::<u64>()
|
||||
+ 8 * b' ' as u64;
|
||||
if stored_checksum != checksum {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
if parse_octal(&header[100..108])? != 0o600
|
||||
|| parse_octal(&header[108..116])? != 0
|
||||
|| parse_octal(&header[116..124])? != 0
|
||||
|| parse_octal(&header[136..148])? != 0
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let raw_name = &header[..100];
|
||||
let end = raw_name
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.unwrap_or(raw_name.len());
|
||||
if raw_name[end..].iter().any(|byte| *byte != 0) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let name = std::str::from_utf8(&raw_name[..end])
|
||||
.map_err(|_| BackupError::InvalidArchive)?
|
||||
.to_owned();
|
||||
if ![MANIFEST, SETTINGS, SERVER, STATE, VAULT].contains(&name.as_str()) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let size = parse_octal(&header[124..136])?;
|
||||
if size > limit_for(&name) as u64 && name != MANIFEST {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
if name == MANIFEST && size > 64 * 1024 {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
Ok((name, size))
|
||||
}
|
||||
|
||||
fn parse_octal(field: &[u8]) -> Result<u64, BackupError> {
|
||||
if field.is_empty() || field[0] & 0x80 != 0 {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let text = std::str::from_utf8(field).map_err(|_| BackupError::InvalidArchive)?;
|
||||
let text = text.trim_matches(['\0', ' ']);
|
||||
if text.is_empty() || !text.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
u64::from_str_radix(text, 8).map_err(|_| BackupError::InvalidArchive)
|
||||
}
|
||||
|
||||
fn limit_for(name: &str) -> usize {
|
||||
match name {
|
||||
SETTINGS => MAX_SETTINGS,
|
||||
SERVER => MAX_SERVER,
|
||||
STATE => MAX_STATE,
|
||||
VAULT => MAX_VAULT,
|
||||
_ => 64 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
fn fixture() -> SavedBytes {
|
||||
SavedBytes {
|
||||
settings: b"settings fixture".to_vec(),
|
||||
server_config: b"[Interface]\nPrivateKey = synthetic\n".to_vec(),
|
||||
internal_state: None,
|
||||
vault: Some(b"encrypted fixture".to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_round_trip_preserves_bytes_and_absence() {
|
||||
let saved = fixture();
|
||||
let bytes = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::PreCommit,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive fixture");
|
||||
let preview = preview_archive_bytes(&bytes).expect("preview");
|
||||
assert_eq!(preview.saved_bytes.settings, saved.settings);
|
||||
assert_eq!(preview.saved_bytes.server_config, saved.server_config);
|
||||
assert_eq!(preview.saved_bytes.internal_state, None);
|
||||
assert_eq!(preview.saved_bytes.vault, saved.vault);
|
||||
assert_eq!(preview.reason, BackupReason::PreCommit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_publication_is_private_and_listing_validates() {
|
||||
let dir = tempfile::tempdir().expect("temporary directory");
|
||||
let path = create_archive(
|
||||
dir.path(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&fixture(),
|
||||
)
|
||||
.expect("create");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(list_archives(dir.path()).expect("list").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_archive_write_and_finalization_failures_never_report_success() {
|
||||
let saved = fixture();
|
||||
for point in [
|
||||
ArchiveCheckpoint::Write,
|
||||
ArchiveCheckpoint::Sync,
|
||||
ArchiveCheckpoint::Publish,
|
||||
ArchiveCheckpoint::TemporaryCleanup,
|
||||
ArchiveCheckpoint::DirectorySync,
|
||||
] {
|
||||
let dir = tempfile::tempdir().expect("temporary directory");
|
||||
let result = create_archive_with_hook(
|
||||
dir.path(),
|
||||
BackupReason::PreCommit,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
|current| {
|
||||
if current == point {
|
||||
Err(io::Error::other("archive fault injection"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(result.is_err(), "{point:?} must not report archive success");
|
||||
let entries = fs::read_dir(dir.path())
|
||||
.expect("archive directory remains readable")
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(entries.iter().all(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.starts_with(".") || name.starts_with("wireguard-admin-")
|
||||
})
|
||||
}));
|
||||
for path in entries.iter().filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("wireguard-admin-"))
|
||||
}) {
|
||||
assert!(
|
||||
preview_archive(path).is_ok(),
|
||||
"published archive is complete"
|
||||
);
|
||||
}
|
||||
if matches!(
|
||||
point,
|
||||
ArchiveCheckpoint::Write | ArchiveCheckpoint::Sync | ArchiveCheckpoint::Publish
|
||||
) {
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"pre-publication failure leaves no artifact"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation_and_nonzero_trailing_bytes() {
|
||||
let saved = fixture();
|
||||
let mut bytes = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
assert!(matches!(
|
||||
preview_archive_bytes(&bytes[..bytes.len() - BLOCK]),
|
||||
Err(BackupError::InvalidArchive)
|
||||
));
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] = 1;
|
||||
assert!(preview_archive_bytes(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_version_and_extensions() {
|
||||
let saved = fixture();
|
||||
let unsupported = encode_archive_version(
|
||||
99,
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
assert!(matches!(
|
||||
preview_archive_bytes(&unsupported),
|
||||
Err(BackupError::UnsupportedVersion)
|
||||
));
|
||||
let mut bytes = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
bytes[156] = b'x';
|
||||
let checksum = bytes[..148]
|
||||
.iter()
|
||||
.chain(&bytes[156..BLOCK])
|
||||
.map(|byte| *byte as u64)
|
||||
.sum::<u64>()
|
||||
+ 8 * b' ' as u64;
|
||||
let checksum_text = format!("{checksum:06o}\0 ");
|
||||
bytes[148..156].copy_from_slice(checksum_text.as_bytes());
|
||||
assert!(preview_archive_bytes(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_link_headers_even_when_checksums_match() {
|
||||
let saved = fixture();
|
||||
let original = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
for (offset, field) in [(0, 0), (0, 1)] {
|
||||
let mut bytes = original.clone();
|
||||
match field {
|
||||
0 => {
|
||||
bytes[offset..offset + 100].fill(0);
|
||||
bytes[offset..offset + 14].copy_from_slice(b"../server.conf");
|
||||
}
|
||||
_ => bytes[offset + 156] = b'2',
|
||||
}
|
||||
let checksum = bytes[offset..offset + 148]
|
||||
.iter()
|
||||
.chain(&bytes[offset + 156..offset + BLOCK])
|
||||
.map(|byte| *byte as u64)
|
||||
.sum::<u64>()
|
||||
+ 8 * b' ' as u64;
|
||||
let checksum_text = format!("{checksum:06o}\0 ");
|
||||
bytes[offset + 148..offset + 156].copy_from_slice(checksum_text.as_bytes());
|
||||
assert!(preview_archive_bytes(&bytes).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_creation_does_not_overwrite_and_absence_is_explicit() {
|
||||
let dir = tempfile::tempdir().expect("temporary directory");
|
||||
let saved = SavedBytes {
|
||||
settings: b"settings".to_vec(),
|
||||
server_config: b"server".to_vec(),
|
||||
internal_state: None,
|
||||
vault: None,
|
||||
};
|
||||
let first = create_archive(
|
||||
dir.path(),
|
||||
BackupReason::PreRestart,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("first archive");
|
||||
let second = create_archive(
|
||||
dir.path(),
|
||||
BackupReason::PreRestart,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("second archive");
|
||||
assert_ne!(first, second);
|
||||
let preview = preview_archive(&second).expect("preview");
|
||||
assert_eq!(preview.saved_bytes.internal_state, None);
|
||||
assert_eq!(preview.saved_bytes.vault, None);
|
||||
assert_eq!(list_archives(dir.path()).expect("list").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_directory_and_selected_file_symlinks_are_rejected() {
|
||||
let root = tempfile::tempdir().expect("temporary directory");
|
||||
let real_directory = root.path().join("real-archives");
|
||||
fs::create_dir(&real_directory).expect("archive target directory");
|
||||
let linked_directory = root.path().join("archives-link");
|
||||
symlink(&real_directory, &linked_directory).expect("directory symlink");
|
||||
assert!(
|
||||
create_archive(
|
||||
&linked_directory,
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&fixture(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
fs::read_dir(&real_directory)
|
||||
.expect("real directory is readable")
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let archive = create_archive(
|
||||
&real_directory,
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&fixture(),
|
||||
)
|
||||
.expect("regular archive");
|
||||
let linked_archive = root.path().join("archive-link.tar");
|
||||
symlink(archive, &linked_archive).expect("archive symlink");
|
||||
assert!(preview_archive(&linked_archive).is_err());
|
||||
}
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
//! Typed application settings, validation, and executable-relative paths.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Fixed settings filename, located beside the executable.
|
||||
pub const SETTINGS_FILE_NAME: &str = "wireguard-admin.toml";
|
||||
|
||||
/// TOML representation. Parsing is separate from semantic validation and path resolution.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SettingsFile {
|
||||
pub interface_name: String,
|
||||
pub server_config_path: PathBuf,
|
||||
pub client_source_directory: PathBuf,
|
||||
pub server_endpoint: String,
|
||||
pub ipv4_allocation_range: String,
|
||||
pub ipv6_allocation_range: String,
|
||||
pub dns_addresses: Vec<String>,
|
||||
pub systemd_unit: String,
|
||||
pub restart_timeout_seconds: u64,
|
||||
#[serde(default)]
|
||||
pub data_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Settings with validated values but paths not yet anchored to the executable.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ValidatedSettings {
|
||||
pub interface_name: String,
|
||||
pub server_config_path: PathBuf,
|
||||
pub client_source_directory: PathBuf,
|
||||
pub server_endpoint: ServerEndpoint,
|
||||
pub ipv4_allocation_range: ipnet::Ipv4Net,
|
||||
pub ipv6_allocation_range: ipnet::Ipv6Net,
|
||||
pub dns_addresses: Vec<IpAddr>,
|
||||
pub systemd_unit: String,
|
||||
pub restart_timeout: Duration,
|
||||
pub data_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Fully resolved application paths. No configured path is checked for existence here.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResolvedSettings {
|
||||
pub interface_name: String,
|
||||
pub server_config_path: PathBuf,
|
||||
pub client_source_directory: PathBuf,
|
||||
pub server_endpoint: ServerEndpoint,
|
||||
pub ipv4_allocation_range: ipnet::Ipv4Net,
|
||||
pub ipv6_allocation_range: ipnet::Ipv6Net,
|
||||
pub dns_addresses: Vec<IpAddr>,
|
||||
pub systemd_unit: String,
|
||||
pub restart_timeout: Duration,
|
||||
pub data_root: PathBuf,
|
||||
pub data_directory: PathBuf,
|
||||
pub archives_directory: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ServerEndpoint {
|
||||
pub host: EndpointHost,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum EndpointHost {
|
||||
Hostname(String),
|
||||
Ip(IpAddr),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum SettingsError {
|
||||
#[error("invalid TOML settings: {0}")]
|
||||
Parse(String),
|
||||
#[error("invalid {field}: {reason}")]
|
||||
InvalidField { field: &'static str, reason: String },
|
||||
}
|
||||
|
||||
impl SettingsFile {
|
||||
/// Parse TOML only; semantic constraints are checked by [`Self::validate`].
|
||||
pub fn parse_toml(input: &str) -> Result<Self, SettingsError> {
|
||||
toml::from_str(input).map_err(|error| SettingsError::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
/// Validate names, network values, endpoint syntax, and timeout without filesystem or network access.
|
||||
pub fn validate(self) -> Result<ValidatedSettings, SettingsError> {
|
||||
validate_interface_name(&self.interface_name)?;
|
||||
validate_systemd_unit(&self.systemd_unit)?;
|
||||
validate_path("server_config_path", &self.server_config_path)?;
|
||||
validate_path("client_source_directory", &self.client_source_directory)?;
|
||||
if let Some(data_root) = &self.data_root {
|
||||
validate_path("data_root", data_root)?;
|
||||
}
|
||||
let server_endpoint = parse_endpoint(&self.server_endpoint)?;
|
||||
let ipv4_allocation_range = self
|
||||
.ipv4_allocation_range
|
||||
.parse()
|
||||
.map_err(|_| invalid("ipv4_allocation_range", "must be a valid IPv4 CIDR range"))?;
|
||||
let ipv6_allocation_range = self
|
||||
.ipv6_allocation_range
|
||||
.parse()
|
||||
.map_err(|_| invalid("ipv6_allocation_range", "must be a valid IPv6 CIDR range"))?;
|
||||
if self.restart_timeout_seconds == 0 {
|
||||
return Err(invalid(
|
||||
"restart_timeout_seconds",
|
||||
"must be greater than zero",
|
||||
));
|
||||
}
|
||||
if self.dns_addresses.is_empty() {
|
||||
return Err(invalid(
|
||||
"dns_addresses",
|
||||
"must contain at least one IP address",
|
||||
));
|
||||
}
|
||||
let dns_addresses = self
|
||||
.dns_addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
address.parse::<IpAddr>().map_err(|_| {
|
||||
invalid(
|
||||
"dns_addresses",
|
||||
format!("{address:?} is not an IPv4 or IPv6 address"),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ValidatedSettings {
|
||||
interface_name: self.interface_name,
|
||||
server_config_path: self.server_config_path,
|
||||
client_source_directory: self.client_source_directory,
|
||||
server_endpoint,
|
||||
ipv4_allocation_range,
|
||||
ipv6_allocation_range,
|
||||
dns_addresses,
|
||||
systemd_unit: self.systemd_unit,
|
||||
restart_timeout: Duration::from_secs(self.restart_timeout_seconds),
|
||||
data_root: self.data_root,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidatedSettings {
|
||||
/// Resolve relative configured paths from the executable directory, independent of cwd.
|
||||
pub fn resolve_paths(self, executable_directory: &Path) -> ResolvedSettings {
|
||||
let resolve = |path: PathBuf| resolve_from(executable_directory, path);
|
||||
let data_root = self
|
||||
.data_root
|
||||
.map(resolve)
|
||||
.unwrap_or_else(|| executable_directory.join("Backup"));
|
||||
let server_config_path = resolve(self.server_config_path);
|
||||
let client_source_directory = resolve(self.client_source_directory);
|
||||
|
||||
ResolvedSettings {
|
||||
interface_name: self.interface_name,
|
||||
server_config_path,
|
||||
client_source_directory,
|
||||
server_endpoint: self.server_endpoint,
|
||||
ipv4_allocation_range: self.ipv4_allocation_range,
|
||||
ipv6_allocation_range: self.ipv6_allocation_range,
|
||||
dns_addresses: self.dns_addresses,
|
||||
systemd_unit: self.systemd_unit,
|
||||
restart_timeout: self.restart_timeout,
|
||||
data_directory: data_root.join("data"),
|
||||
archives_directory: data_root.join("archives"),
|
||||
data_root,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the fixed settings file path beside the executable directory.
|
||||
pub fn settings_path(executable_directory: &Path) -> PathBuf {
|
||||
executable_directory.join(SETTINGS_FILE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedSettings {
|
||||
pub fn settings_path(executable_directory: &Path) -> PathBuf {
|
||||
ValidatedSettings::settings_path(executable_directory)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_from(executable_directory: &Path, path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
executable_directory.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_interface_name(name: &str) -> Result<(), SettingsError> {
|
||||
if name.is_empty()
|
||||
|| name.len() > 15
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
|
||||
{
|
||||
return Err(invalid(
|
||||
"interface_name",
|
||||
"must be 1–15 ASCII letters, digits, '.', '_' or '-', and not '.' or '..'",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_systemd_unit(unit: &str) -> Result<(), SettingsError> {
|
||||
if unit.is_empty()
|
||||
|| unit.len() > 255
|
||||
|| !unit.ends_with(".service")
|
||||
|| !unit.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'@' | b':')
|
||||
})
|
||||
{
|
||||
return Err(invalid(
|
||||
"systemd_unit",
|
||||
"must be a service unit name ending in .service, without a path or whitespace",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_path(field: &'static str, path: &Path) -> Result<(), SettingsError> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(invalid(field, "must not be empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_endpoint(value: &str) -> Result<ServerEndpoint, SettingsError> {
|
||||
let (host_text, port_text) = if let Some(bracketed) = value.strip_prefix('[') {
|
||||
let (host, suffix) = bracketed.split_once(']').ok_or_else(|| {
|
||||
invalid(
|
||||
"server_endpoint",
|
||||
"bracketed IPv6 address is missing ']' or a port",
|
||||
)
|
||||
})?;
|
||||
let port = suffix
|
||||
.strip_prefix(':')
|
||||
.ok_or_else(|| invalid("server_endpoint", "expected a port after bracketed host"))?;
|
||||
let address = IpAddr::from_str(host).map_err(|_| {
|
||||
invalid(
|
||||
"server_endpoint",
|
||||
"brackets are only valid around an IPv6 address",
|
||||
)
|
||||
})?;
|
||||
if !address.is_ipv6() {
|
||||
return Err(invalid(
|
||||
"server_endpoint",
|
||||
"brackets are only valid around IPv6",
|
||||
));
|
||||
}
|
||||
(EndpointHost::Ip(address), port)
|
||||
} else {
|
||||
let (host, port) = value.rsplit_once(':').ok_or_else(|| {
|
||||
invalid(
|
||||
"server_endpoint",
|
||||
"must include a port; bracket IPv6 addresses",
|
||||
)
|
||||
})?;
|
||||
if host.contains(':') {
|
||||
return Err(invalid(
|
||||
"server_endpoint",
|
||||
"IPv6 addresses must be enclosed in brackets",
|
||||
));
|
||||
}
|
||||
let parsed_host = if let Ok(address) = host.parse::<Ipv4Addr>() {
|
||||
EndpointHost::Ip(IpAddr::V4(address))
|
||||
} else if valid_hostname(host) {
|
||||
EndpointHost::Hostname(host.to_owned())
|
||||
} else {
|
||||
return Err(invalid(
|
||||
"server_endpoint",
|
||||
"host must be a valid hostname or IP address",
|
||||
));
|
||||
};
|
||||
(parsed_host, port)
|
||||
};
|
||||
|
||||
let port = port_text
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|port| *port != 0)
|
||||
.ok_or_else(|| {
|
||||
invalid(
|
||||
"server_endpoint",
|
||||
"port must be an integer from 1 through 65535",
|
||||
)
|
||||
})?;
|
||||
Ok(ServerEndpoint {
|
||||
host: host_text,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_hostname(host: &str) -> bool {
|
||||
!host.is_empty()
|
||||
&& host.len() <= 253
|
||||
&& host.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& 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'-')
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid(field: &'static str, reason: impl Into<String>) -> SettingsError {
|
||||
SettingsError::InvalidField {
|
||||
field,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{EndpointHost, SETTINGS_FILE_NAME, SettingsError, SettingsFile};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
const VALID: &str = r#"
|
||||
interface_name = "wg0"
|
||||
server_config_path = "etc/wireguard/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", "fd80::1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
"#;
|
||||
|
||||
fn validated(input: &str) -> super::ValidatedSettings {
|
||||
SettingsFile::parse_toml(input)
|
||||
.and_then(SettingsFile::validate)
|
||||
.unwrap_or_else(|error| panic!("fixture settings should validate: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_required_values_and_resolves_defaults_relative_to_executable() {
|
||||
let settings = validated(VALID);
|
||||
assert_eq!(settings.server_endpoint.port, 51820);
|
||||
assert_eq!(
|
||||
settings.dns_addresses[0],
|
||||
IpAddr::V4(Ipv4Addr::new(10, 80, 0, 1))
|
||||
);
|
||||
assert_eq!(settings.restart_timeout, Duration::from_secs(30));
|
||||
|
||||
let resolved = settings.resolve_paths(Path::new("/opt/wireguard-admin"));
|
||||
assert_eq!(
|
||||
resolved.server_config_path,
|
||||
Path::new("/opt/wireguard-admin/etc/wireguard/wg0.conf")
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.client_source_directory,
|
||||
Path::new("/opt/wireguard-admin/clients")
|
||||
);
|
||||
assert_eq!(resolved.data_root, Path::new("/opt/wireguard-admin/Backup"));
|
||||
assert_eq!(
|
||||
resolved.data_directory,
|
||||
Path::new("/opt/wireguard-admin/Backup/data")
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.archives_directory,
|
||||
Path::new("/opt/wireguard-admin/Backup/archives")
|
||||
);
|
||||
assert_eq!(
|
||||
super::ResolvedSettings::settings_path(Path::new("/opt/wireguard-admin")),
|
||||
Path::new("/opt/wireguard-admin").join(SETTINGS_FILE_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_data_root_and_absolute_paths_are_preserved() {
|
||||
let input = VALID.replace(
|
||||
"server_config_path = \"etc/wireguard/wg0.conf\"",
|
||||
"server_config_path = \"/etc/wireguard/wg0.conf\"",
|
||||
) + "data_root = \"state\"\n";
|
||||
let resolved = validated(&input).resolve_paths(Path::new("/opt/app"));
|
||||
assert_eq!(
|
||||
resolved.server_config_path,
|
||||
Path::new("/etc/wireguard/wg0.conf")
|
||||
);
|
||||
assert_eq!(resolved.data_root, Path::new("/opt/app/state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hostname_ipv4_and_bracketed_ipv6_endpoints() {
|
||||
assert!(matches!(
|
||||
validated(VALID).server_endpoint.host,
|
||||
EndpointHost::Hostname(_)
|
||||
));
|
||||
let ipv4 = VALID.replace("vpn.example.org:51820", "192.0.2.2:51820");
|
||||
assert_eq!(
|
||||
validated(&ipv4).server_endpoint.host,
|
||||
EndpointHost::Ip("192.0.2.2".parse().unwrap())
|
||||
);
|
||||
let ipv6 = VALID.replace("vpn.example.org:51820", "[2001:db8::2]:51820");
|
||||
assert_eq!(
|
||||
validated(&ipv6).server_endpoint.host,
|
||||
EndpointHost::Ip("2001:db8::2".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_malformed_values_with_field_context() {
|
||||
let missing = VALID.replace("systemd_unit = \"wg-quick@wg0.service\"\n", "");
|
||||
assert!(matches!(
|
||||
SettingsFile::parse_toml(&missing),
|
||||
Err(SettingsError::Parse(_))
|
||||
));
|
||||
|
||||
for (input, field) in [
|
||||
(
|
||||
VALID.replace(
|
||||
"interface_name = \"wg0\"",
|
||||
"interface_name = \"interface/name\"",
|
||||
),
|
||||
"interface_name",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"server_config_path = \"etc/wireguard/wg0.conf\"",
|
||||
"server_config_path = \"\"",
|
||||
),
|
||||
"server_config_path",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"server_endpoint = \"vpn.example.org:51820\"",
|
||||
"server_endpoint = \"2001:db8::2:51820\"",
|
||||
),
|
||||
"server_endpoint",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"ipv4_allocation_range = \"10.80.0.0/24\"",
|
||||
"ipv4_allocation_range = \"fd80::/64\"",
|
||||
),
|
||||
"ipv4_allocation_range",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"ipv6_allocation_range = \"fd80::/64\"",
|
||||
"ipv6_allocation_range = \"10.80.0.0/24\"",
|
||||
),
|
||||
"ipv6_allocation_range",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"dns_addresses = [\"10.80.0.1\", \"fd80::1\"]",
|
||||
"dns_addresses = [\"vpn.example.org\"]",
|
||||
),
|
||||
"dns_addresses",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"systemd_unit = \"wg-quick@wg0.service\"",
|
||||
"systemd_unit = \"../wg0.service\"",
|
||||
),
|
||||
"systemd_unit",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"restart_timeout_seconds = 30",
|
||||
"restart_timeout_seconds = 0",
|
||||
),
|
||||
"restart_timeout_seconds",
|
||||
),
|
||||
(
|
||||
VALID.replace(
|
||||
"server_endpoint = \"vpn.example.org:51820\"",
|
||||
"server_endpoint = \"vpn.example.org:0\"",
|
||||
),
|
||||
"server_endpoint",
|
||||
),
|
||||
] {
|
||||
let error = SettingsFile::parse_toml(&input)
|
||||
.and_then(SettingsFile::validate)
|
||||
.expect_err("invalid fixture must fail validation");
|
||||
assert!(error.to_string().contains(field), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_are_only_resolved_and_not_checked_for_existence() {
|
||||
let mut input = VALID.replace(
|
||||
"server_config_path = \"etc/wireguard/wg0.conf\"",
|
||||
"server_config_path = \"does-not-exist/server.conf\"",
|
||||
);
|
||||
input = input.replace(
|
||||
"client_source_directory = \"clients\"",
|
||||
"client_source_directory = \"also-absent\"",
|
||||
);
|
||||
assert!(
|
||||
validated(&input)
|
||||
.resolve_paths(Path::new("/fixture/bin"))
|
||||
.server_config_path
|
||||
.ends_with("does-not-exist/server.conf")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+807
@@ -0,0 +1,807 @@
|
||||
//! Deterministic, bounded rendering of explicitly authorized client configurations.
|
||||
//!
|
||||
//! Rendering is pure: callers supply the authoritative saved document, the derived
|
||||
//! server identity, and settings. Plaintext output is held in [`ClientConfigPayload`]
|
||||
//! and exposed only through its explicit byte accessor.
|
||||
//!
|
||||
//! QR encoding is intentionally not a public API. It is only reachable through
|
||||
//! the application's file-and-QR delivery operation, which publishes the
|
||||
//! mandatory plaintext file before encoding those same bytes.
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use wireguard_admin::delivery::qr::encode_payload;
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::str::FromStr;
|
||||
|
||||
use ipnet::IpNet;
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub mod files;
|
||||
pub(crate) mod qr;
|
||||
pub use qr::{QrCodeOutput, QrError};
|
||||
|
||||
use crate::config::{EndpointHost, ServerEndpoint};
|
||||
use crate::domain::PublicKey;
|
||||
use crate::vault::{RetrievedClientRecord, WireGuardPrivateKey};
|
||||
use crate::wireguard::{ConfigDocument, PeerInspection};
|
||||
|
||||
const MAX_CONFIG_BYTES: usize = 64 * 1024;
|
||||
const MAX_DNS_ENTRIES: usize = 32;
|
||||
const MAX_ROUTE_ENTRIES: usize = 256;
|
||||
|
||||
/// Validated connection defaults used for new clients and missing imported values.
|
||||
///
|
||||
/// Values are checked again here because public settings structs can be constructed
|
||||
/// directly by library callers without passing through settings-file validation.
|
||||
pub struct DeliverySettings<'a> {
|
||||
pub server_endpoint: &'a ServerEndpoint,
|
||||
pub dns_addresses: &'a [IpAddr],
|
||||
}
|
||||
|
||||
/// Secret-bearing rendered file bytes. There is no `Display` or serialization API.
|
||||
pub struct ClientConfigPayload(Zeroizing<Vec<u8>>);
|
||||
|
||||
impl ClientConfigPayload {
|
||||
/// Explicitly expose rendered configuration bytes to a delivery operation.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
self.0.as_slice()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientConfigPayload {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientConfigPayload")
|
||||
.field("length", &self.0.len())
|
||||
.field("contents", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret-free rendering failures. Variants never contain source text or key material.
|
||||
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
|
||||
pub enum DeliveryError {
|
||||
#[error("client identity does not match the authorized private key")]
|
||||
ClientIdentityMismatch,
|
||||
#[error("client peer is missing from the authoritative saved configuration")]
|
||||
MissingSavedPeer,
|
||||
#[error("client identity is ambiguous in the authoritative saved configuration")]
|
||||
AmbiguousSavedPeer,
|
||||
#[error("authoritative saved peer data is malformed or unsupported")]
|
||||
InvalidSavedPeer,
|
||||
#[error("retained client addresses do not match authoritative saved addresses")]
|
||||
AddressMismatch,
|
||||
#[error("retained server identity does not match the derived saved server identity")]
|
||||
ServerIdentityMismatch,
|
||||
#[error("authoritative saved server identity is missing, ambiguous, or invalid")]
|
||||
InvalidSavedServerIdentity,
|
||||
#[error("client connection settings are invalid or unsupported")]
|
||||
InvalidConnectionField,
|
||||
#[error("rendered client configuration exceeds its size limit")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// Render a newly created client's committed connection using a full-tunnel template.
|
||||
pub fn render_new_client(
|
||||
identity: PublicKey,
|
||||
private_key: &WireGuardPrivateKey,
|
||||
saved: &ConfigDocument,
|
||||
settings: &DeliverySettings<'_>,
|
||||
) -> Result<ClientConfigPayload, DeliveryError> {
|
||||
if !private_key.matches_public_key(&identity) {
|
||||
return Err(DeliveryError::ClientIdentityMismatch);
|
||||
}
|
||||
let server_public_key = authoritative_server_public_key(saved)?;
|
||||
let addresses = authoritative_addresses(saved, identity, true)?;
|
||||
let endpoint = render_endpoint(settings.server_endpoint)?;
|
||||
let dns = render_dns(settings.dns_addresses)?;
|
||||
let private_text = Zeroizing::new(private_key.to_base64());
|
||||
let mut output = Zeroizing::new(Vec::with_capacity(256));
|
||||
append(&mut output, "[Interface]\nPrivateKey = ")?;
|
||||
append(&mut output, private_text.as_str())?;
|
||||
append(&mut output, "\nAddress = ")?;
|
||||
append(&mut output, &addresses)?;
|
||||
append(&mut output, "\nDNS = ")?;
|
||||
append(&mut output, &dns)?;
|
||||
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
|
||||
append(&mut output, &server_public_key.to_string())?;
|
||||
append(&mut output, "\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ")?;
|
||||
append(&mut output, &endpoint)?;
|
||||
append(&mut output, "\n")?;
|
||||
finish(output)
|
||||
}
|
||||
|
||||
/// Render a password-authorized imported record, retaining validated supported fields.
|
||||
///
|
||||
/// The record type is only obtainable through fresh vault retrieval, which rejects
|
||||
/// authenticated `reissue_blocked` records. Its retained fields are immutable to
|
||||
/// library callers, and the renderer verifies their authenticated provenance and
|
||||
/// server binding against the authoritative saved configuration.
|
||||
pub fn render_imported_client(
|
||||
identity: PublicKey,
|
||||
record: &RetrievedClientRecord,
|
||||
saved: &ConfigDocument,
|
||||
settings: &DeliverySettings<'_>,
|
||||
) -> Result<ClientConfigPayload, DeliveryError> {
|
||||
let server_public_key = authoritative_server_public_key(saved)?;
|
||||
if !record.is_imported() {
|
||||
return Err(DeliveryError::ServerIdentityMismatch);
|
||||
}
|
||||
let private_key = record
|
||||
.private_key()
|
||||
.ok_or(DeliveryError::ClientIdentityMismatch)?;
|
||||
if !private_key.matches_public_key(&identity) {
|
||||
return Err(DeliveryError::ClientIdentityMismatch);
|
||||
}
|
||||
let addresses = authoritative_addresses(saved, identity, false)?;
|
||||
validate_retained_addresses(record.client_addresses(), &addresses)?;
|
||||
let expected_server_public_key = server_public_key.to_string();
|
||||
if record.server_public_key() != Some(expected_server_public_key.as_str()) {
|
||||
return Err(DeliveryError::ServerIdentityMismatch);
|
||||
}
|
||||
|
||||
let interface_addresses = parse_address_string(&addresses)?;
|
||||
let dns_value = match record.interface_dns() {
|
||||
Some(value) => validate_dns(value)?,
|
||||
None => render_dns(settings.dns_addresses)?,
|
||||
};
|
||||
let endpoint = match record.endpoint() {
|
||||
Some(value) => validate_endpoint(value)?,
|
||||
None => render_endpoint(settings.server_endpoint)?,
|
||||
};
|
||||
let mtu = record.interface_mtu().map(validate_mtu).transpose()?;
|
||||
let keepalive = record
|
||||
.persistent_keepalive()
|
||||
.map(validate_keepalive)
|
||||
.transpose()?;
|
||||
let routes = validate_routes(record.allowed_ips())?;
|
||||
let psk = record
|
||||
.preshared_key_base64()
|
||||
.map(validate_secret_key)
|
||||
.transpose()?;
|
||||
let private_text = Zeroizing::new(private_key.to_base64());
|
||||
|
||||
let mut output = Zeroizing::new(Vec::with_capacity(512));
|
||||
append(&mut output, "[Interface]\nPrivateKey = ")?;
|
||||
append(&mut output, private_text.as_str())?;
|
||||
append(&mut output, "\nAddress = ")?;
|
||||
append(&mut output, &interface_addresses)?;
|
||||
append(&mut output, "\nDNS = ")?;
|
||||
append(&mut output, &dns_value)?;
|
||||
if let Some(mtu) = mtu {
|
||||
append(&mut output, "\nMTU = ")?;
|
||||
append(&mut output, &mtu)?;
|
||||
}
|
||||
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
|
||||
append(&mut output, &server_public_key.to_string())?;
|
||||
if let Some(psk) = psk {
|
||||
append(&mut output, "\nPresharedKey = ")?;
|
||||
append(&mut output, psk.as_str())?;
|
||||
}
|
||||
append(&mut output, "\nAllowedIPs = ")?;
|
||||
append(&mut output, &routes)?;
|
||||
append(&mut output, "\nEndpoint = ")?;
|
||||
append(&mut output, &endpoint)?;
|
||||
if let Some(keepalive) = keepalive {
|
||||
append(&mut output, "\nPersistentKeepalive = ")?;
|
||||
append(&mut output, &keepalive)?;
|
||||
}
|
||||
append(&mut output, "\n")?;
|
||||
finish(output)
|
||||
}
|
||||
|
||||
/// Derive the server identity only from one valid `[Interface] PrivateKey` in
|
||||
/// the authoritative saved document. The private key and source text remain
|
||||
/// borrowed/local to this call and never enter an error or owned long-lived value.
|
||||
fn authoritative_server_public_key(saved: &ConfigDocument) -> Result<PublicKey, DeliveryError> {
|
||||
use crate::wireguard::SectionKind;
|
||||
|
||||
let interfaces = saved
|
||||
.sections()
|
||||
.iter()
|
||||
.filter(|section| section.kind == SectionKind::Interface)
|
||||
.collect::<Vec<_>>();
|
||||
let [interface] = interfaces.as_slice() else {
|
||||
return Err(DeliveryError::InvalidSavedServerIdentity);
|
||||
};
|
||||
let section = saved
|
||||
.source_slice(interface.span)
|
||||
.ok_or(DeliveryError::InvalidSavedServerIdentity)?;
|
||||
let mut private_key_value = None;
|
||||
for line in section.lines() {
|
||||
let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
if trimmed.is_empty() || trimmed.starts_with(['#', ';']) {
|
||||
continue;
|
||||
}
|
||||
let has_private_key_name = trimmed
|
||||
.split(['=', ' ', '\t'])
|
||||
.next()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("PrivateKey"));
|
||||
if !has_private_key_name {
|
||||
continue;
|
||||
}
|
||||
if private_key_value.is_some() {
|
||||
return Err(DeliveryError::InvalidSavedServerIdentity);
|
||||
}
|
||||
let Some((name, value)) = trimmed.split_once('=') else {
|
||||
return Err(DeliveryError::InvalidSavedServerIdentity);
|
||||
};
|
||||
if !name
|
||||
.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.eq_ignore_ascii_case("PrivateKey")
|
||||
{
|
||||
return Err(DeliveryError::InvalidSavedServerIdentity);
|
||||
}
|
||||
private_key_value = Some(value.trim_matches(|ch: char| ch.is_ascii_whitespace()));
|
||||
}
|
||||
let private_key = WireGuardPrivateKey::parse_base64(
|
||||
private_key_value.ok_or(DeliveryError::InvalidSavedServerIdentity)?,
|
||||
)
|
||||
.map_err(|_| DeliveryError::InvalidSavedServerIdentity)?;
|
||||
Ok(private_key.public_key())
|
||||
}
|
||||
|
||||
fn authoritative_addresses(
|
||||
saved: &ConfigDocument,
|
||||
identity: PublicKey,
|
||||
require_dual_stack: bool,
|
||||
) -> Result<String, DeliveryError> {
|
||||
let matching = saved
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| {
|
||||
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let peer = match matching.as_slice() {
|
||||
[] => return Err(DeliveryError::MissingSavedPeer),
|
||||
[peer] => *peer,
|
||||
_ => return Err(DeliveryError::AmbiguousSavedPeer),
|
||||
};
|
||||
validate_peer(peer)?;
|
||||
let mut v4 = None;
|
||||
let mut v6 = None;
|
||||
for network in &peer.allowed_ips {
|
||||
match network {
|
||||
IpNet::V4(network)
|
||||
if network.prefix_len() == 32 && v4.replace(network.addr()).is_none() => {}
|
||||
IpNet::V6(network)
|
||||
if network.prefix_len() == 128 && v6.replace(network.addr()).is_none() => {}
|
||||
_ => return Err(DeliveryError::InvalidSavedPeer),
|
||||
}
|
||||
}
|
||||
let v4 = v4.ok_or(DeliveryError::InvalidSavedPeer)?;
|
||||
if require_dual_stack && v6.is_none() {
|
||||
return Err(DeliveryError::InvalidSavedPeer);
|
||||
}
|
||||
Ok(format_addresses(v4, v6))
|
||||
}
|
||||
|
||||
fn validate_peer(peer: &PeerInspection) -> Result<(), DeliveryError> {
|
||||
if peer.identity.is_none()
|
||||
|| peer.identity != peer.public_key_candidates.first().copied()
|
||||
|| peer.public_key_candidates.len() != 1
|
||||
|| !peer.issues.is_empty()
|
||||
|| peer.allowed_ips.is_empty()
|
||||
{
|
||||
return Err(DeliveryError::InvalidSavedPeer);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_addresses(v4: Ipv4Addr, v6: Option<Ipv6Addr>) -> String {
|
||||
let mut output = format!("{v4}/32");
|
||||
if let Some(v6) = v6 {
|
||||
output.push_str(&format!(", {v6}/128"));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn parse_address_string(value: &str) -> Result<String, DeliveryError> {
|
||||
let items = value.split(", ").collect::<Vec<_>>();
|
||||
if items.is_empty() || items.len() > 2 {
|
||||
return Err(DeliveryError::InvalidSavedPeer);
|
||||
}
|
||||
Ok(items.join(", "))
|
||||
}
|
||||
|
||||
fn validate_retained_addresses(
|
||||
retained: &[String],
|
||||
authoritative: &str,
|
||||
) -> Result<(), DeliveryError> {
|
||||
if retained.is_empty() || retained.len() > 2 || retained.iter().any(|value| value.len() > 64) {
|
||||
return Err(DeliveryError::AddressMismatch);
|
||||
}
|
||||
let expected = authoritative
|
||||
.split(", ")
|
||||
.map(str::to_owned)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut actual = BTreeSet::new();
|
||||
for value in retained {
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let network = IpNet::from_str(value).map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
match network {
|
||||
IpNet::V4(net) if net.prefix_len() == 32 => {}
|
||||
IpNet::V6(net) if net.prefix_len() == 128 => {}
|
||||
_ => return Err(DeliveryError::InvalidConnectionField),
|
||||
}
|
||||
actual.insert(network.to_string());
|
||||
}
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DeliveryError::AddressMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dns(dns: &[IpAddr]) -> Result<String, DeliveryError> {
|
||||
if dns.is_empty() || dns.len() > MAX_DNS_ENTRIES {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
Ok(dns
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "))
|
||||
}
|
||||
|
||||
fn validate_dns(value: &str) -> Result<String, DeliveryError> {
|
||||
if value.len() > 2048 || value.chars().any(char::is_control) {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
|
||||
if items.is_empty() || items.len() > MAX_DNS_ENTRIES || items.iter().any(|item| item.is_empty())
|
||||
{
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let addresses = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item.parse::<IpAddr>()
|
||||
.map_err(|_| DeliveryError::InvalidConnectionField)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
render_dns(&addresses)
|
||||
}
|
||||
|
||||
fn render_endpoint(endpoint: &ServerEndpoint) -> Result<String, DeliveryError> {
|
||||
if endpoint.port == 0 {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
match &endpoint.host {
|
||||
EndpointHost::Ip(IpAddr::V6(address)) => Ok(format!("[{address}]:{}", endpoint.port)),
|
||||
EndpointHost::Ip(address) => Ok(format!("{address}:{}", endpoint.port)),
|
||||
EndpointHost::Hostname(host) => {
|
||||
validate_hostname(host)?;
|
||||
Ok(format!("{host}:{}", endpoint.port))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_endpoint(value: &str) -> Result<String, DeliveryError> {
|
||||
if value.is_empty() || value.chars().any(char::is_control) || value.len() > 255 {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let (host, port) = if let Some(rest) = value.strip_prefix('[') {
|
||||
let (host, suffix) = rest
|
||||
.split_once(']')
|
||||
.ok_or(DeliveryError::InvalidConnectionField)?;
|
||||
let port = suffix
|
||||
.strip_prefix(':')
|
||||
.ok_or(DeliveryError::InvalidConnectionField)?;
|
||||
let address = host
|
||||
.parse::<Ipv6Addr>()
|
||||
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
(format!("[{address}]"), parse_port(port)?)
|
||||
} else {
|
||||
let (host, port) = value
|
||||
.rsplit_once(':')
|
||||
.ok_or(DeliveryError::InvalidConnectionField)?;
|
||||
if host.contains(':') {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let formatted = if let Ok(address) = host.parse::<Ipv4Addr>() {
|
||||
address.to_string()
|
||||
} else {
|
||||
validate_hostname(host)?;
|
||||
host.to_owned()
|
||||
};
|
||||
(formatted, parse_port(port)?)
|
||||
};
|
||||
Ok(format!("{host}:{port}"))
|
||||
}
|
||||
|
||||
fn validate_hostname(host: &str) -> Result<(), DeliveryError> {
|
||||
if host.is_empty()
|
||||
|| host.len() > 253
|
||||
|| host.split('.').any(|label| {
|
||||
label.is_empty()
|
||||
|| label.len() > 63
|
||||
|| label.starts_with('-')
|
||||
|| label.ends_with('-')
|
||||
|| !label.as_bytes()[0].is_ascii_alphanumeric()
|
||||
|| !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|
||||
|| !label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
})
|
||||
{
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_port(value: &str) -> Result<u16, DeliveryError> {
|
||||
let port = value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
if port == 0 || port.to_string() != value {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn validate_mtu(value: &str) -> Result<String, DeliveryError> {
|
||||
let mtu = value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
if mtu == 0 || mtu.to_string() != value {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
Ok(mtu.to_string())
|
||||
}
|
||||
|
||||
fn validate_keepalive(value: &str) -> Result<String, DeliveryError> {
|
||||
if value.len() > 5 {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let keepalive = value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
if keepalive.to_string() != value {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
Ok(keepalive.to_string())
|
||||
}
|
||||
|
||||
fn validate_routes(routes: &[String]) -> Result<String, DeliveryError> {
|
||||
if routes.is_empty()
|
||||
|| routes.len() > MAX_ROUTE_ENTRIES
|
||||
|| routes.iter().any(|route| route.len() > 128)
|
||||
{
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let mut parsed = Vec::with_capacity(routes.len());
|
||||
for route in routes {
|
||||
if route.chars().any(char::is_control) {
|
||||
return Err(DeliveryError::InvalidConnectionField);
|
||||
}
|
||||
let network = IpNet::from_str(route).map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
parsed.push(network.to_string());
|
||||
}
|
||||
Ok(parsed.join(", "))
|
||||
}
|
||||
|
||||
fn validate_secret_key(value: &str) -> Result<Zeroizing<String>, DeliveryError> {
|
||||
let key = WireGuardPrivateKey::parse_base64(value)
|
||||
.map_err(|_| DeliveryError::InvalidConnectionField)?;
|
||||
// Parsing confirms canonical 32-byte standard Base64; retain only a zeroizing copy.
|
||||
Ok(Zeroizing::new(key.to_base64()))
|
||||
}
|
||||
|
||||
fn append(output: &mut Zeroizing<Vec<u8>>, value: &str) -> Result<(), DeliveryError> {
|
||||
if output.len().saturating_add(value.len()) > MAX_CONFIG_BYTES {
|
||||
return Err(DeliveryError::TooLarge);
|
||||
}
|
||||
output.extend_from_slice(value.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(output: Zeroizing<Vec<u8>>) -> Result<ClientConfigPayload, DeliveryError> {
|
||||
if output.is_empty() || output.len() > MAX_CONFIG_BYTES {
|
||||
return Err(DeliveryError::TooLarge);
|
||||
}
|
||||
Ok(ClientConfigPayload(output))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::config::{EndpointHost, ServerEndpoint};
|
||||
use crate::vault::{
|
||||
VaultBundle, WireGuardPrivateKey,
|
||||
import::{parse_client_config, reconcile},
|
||||
};
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::{DeliveryError, DeliverySettings, render_imported_client, render_new_client};
|
||||
|
||||
const PASSWORD: &str = "synthetic delivery test password";
|
||||
|
||||
fn key(byte: u8) -> WireGuardPrivateKey {
|
||||
WireGuardPrivateKey::parse_base64(
|
||||
&base64::engine::general_purpose::STANDARD.encode([byte; 32]),
|
||||
)
|
||||
.expect("synthetic key parses")
|
||||
}
|
||||
|
||||
fn document(client: &WireGuardPrivateKey, addresses: &str) -> ConfigDocument {
|
||||
document_with_server(client, addresses, &key(9))
|
||||
}
|
||||
|
||||
fn document_with_server(
|
||||
client: &WireGuardPrivateKey,
|
||||
addresses: &str,
|
||||
server: &WireGuardPrivateKey,
|
||||
) -> ConfigDocument {
|
||||
let source = format!(
|
||||
"[Interface]\nPrivateKey = {}\n\n[Peer]\nPublicKey = {}\nAllowedIPs = {addresses}\n",
|
||||
server.to_base64(),
|
||||
client.public_key()
|
||||
);
|
||||
ConfigDocument::parse(source).expect("synthetic server document parses")
|
||||
}
|
||||
|
||||
fn imported_record(
|
||||
client: &WireGuardPrivateKey,
|
||||
addresses: &str,
|
||||
server: &WireGuardPrivateKey,
|
||||
) -> crate::vault::RetrievedClientRecord {
|
||||
let saved = document_with_server(client, addresses, server);
|
||||
let source = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {addresses}\nDNS = 10.80.0.1, fd80::1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51821\nAllowedIPs = 10.0.0.0/8, ::/0\nPersistentKeepalive = 25\n",
|
||||
client.to_base64(),
|
||||
server.public_key()
|
||||
);
|
||||
let report = reconcile(&saved, [("fixture.conf".to_owned(), source.into_bytes())]);
|
||||
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
|
||||
report
|
||||
.encrypt_candidate(&mut bundle, 0)
|
||||
.expect("matching imported file encrypts");
|
||||
bundle
|
||||
.retrieve_client_record(client.public_key(), PASSWORD)
|
||||
.expect("imported record retrieves")
|
||||
}
|
||||
|
||||
fn settings() -> (ServerEndpoint, Vec<IpAddr>) {
|
||||
(
|
||||
ServerEndpoint {
|
||||
host: EndpointHost::Hostname("vpn.example.test".to_owned()),
|
||||
port: 51820,
|
||||
},
|
||||
vec![
|
||||
"10.80.0.1".parse().expect("valid DNS"),
|
||||
"fd80::1".parse().expect("valid DNS"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_client_uses_saved_dual_stack_addresses_and_full_tunnel_template() {
|
||||
let client = key(1);
|
||||
let server = key(9).public_key();
|
||||
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
|
||||
let (endpoint, dns) = settings();
|
||||
let payload = render_new_client(
|
||||
client.public_key(),
|
||||
&client,
|
||||
&saved,
|
||||
&DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
},
|
||||
)
|
||||
.expect("valid synthetic values render");
|
||||
|
||||
let parsed = parse_client_config(payload.as_bytes()).expect("rendered file parses");
|
||||
assert_eq!(parsed.identity, client.public_key());
|
||||
assert_eq!(parsed.server_public_key, server);
|
||||
assert_eq!(parsed.addresses.len(), 2);
|
||||
assert_eq!(parsed.allowed_ips.len(), 2);
|
||||
assert_eq!(parsed.allowed_ips[0].to_string(), "0.0.0.0/0");
|
||||
assert_eq!(parsed.allowed_ips[1].to_string(), "::/0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_client_rejects_wrong_key_and_missing_or_ambiguous_saved_peer() {
|
||||
let client = key(1);
|
||||
let other = key(2);
|
||||
let (endpoint, dns) = settings();
|
||||
let opts = DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
};
|
||||
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
|
||||
assert_eq!(
|
||||
render_new_client(other.public_key(), &client, &saved, &opts).err(),
|
||||
Some(DeliveryError::ClientIdentityMismatch)
|
||||
);
|
||||
let empty = ConfigDocument::parse(format!(
|
||||
"[Interface]\nPrivateKey = {}\n",
|
||||
key(9).to_base64()
|
||||
))
|
||||
.expect("valid document");
|
||||
assert_eq!(
|
||||
render_new_client(client.public_key(), &client, &empty, &opts).err(),
|
||||
Some(DeliveryError::MissingSavedPeer)
|
||||
);
|
||||
let duplicate = ConfigDocument::parse(format!(
|
||||
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
|
||||
key(9).to_base64(),
|
||||
client.public_key(), client.public_key()
|
||||
)).expect("duplicate peer document parses");
|
||||
assert_eq!(
|
||||
render_new_client(client.public_key(), &client, &duplicate, &opts).err(),
|
||||
Some(DeliveryError::AmbiguousSavedPeer)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_client_derives_server_identity_and_rejects_invalid_saved_interface_keys() {
|
||||
let client = key(1);
|
||||
let (endpoint, dns) = settings();
|
||||
let opts = DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
};
|
||||
let valid_peer = format!(
|
||||
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
|
||||
client.public_key()
|
||||
);
|
||||
let invalid_documents = [
|
||||
valid_peer.clone(),
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\n[Interface]\nPrivateKey = {}{}",
|
||||
key(9).to_base64(),
|
||||
key(9).to_base64(),
|
||||
valid_peer
|
||||
),
|
||||
format!("[Interface]\nPrivateKey = invalid{valid_peer}"),
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nPrivateKey = {}{}",
|
||||
key(9).to_base64(),
|
||||
key(8).to_base64(),
|
||||
valid_peer
|
||||
),
|
||||
format!("[Interface]\nPrivateKey\n{valid_peer}"),
|
||||
];
|
||||
for source in invalid_documents {
|
||||
let saved = ConfigDocument::parse(source).expect("synthetic document parses");
|
||||
assert_eq!(
|
||||
render_new_client(client.public_key(), &client, &saved, &opts).err(),
|
||||
Some(DeliveryError::InvalidSavedServerIdentity)
|
||||
);
|
||||
}
|
||||
|
||||
let valid = document(&client, "10.80.0.7/32, fd80::7/128");
|
||||
let rendered = render_new_client(client.public_key(), &client, &valid, &opts)
|
||||
.expect("server identity derives from valid saved private key");
|
||||
let parsed = parse_client_config(rendered.as_bytes()).expect("rendered file parses");
|
||||
assert_eq!(parsed.server_public_key, key(9).public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_ipv4_only_record_preserves_supported_fields() {
|
||||
let client = key(4);
|
||||
let server_private = key(9);
|
||||
let record = imported_record(&client, "10.80.0.9/32", &server_private);
|
||||
let saved = document(&client, "10.80.0.9/32");
|
||||
let (endpoint, dns) = settings();
|
||||
let payload = render_imported_client(
|
||||
client.public_key(),
|
||||
&record,
|
||||
&saved,
|
||||
&DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
},
|
||||
)
|
||||
.expect("supported imported values render");
|
||||
let parsed =
|
||||
parse_client_config(payload.as_bytes()).expect("rendered imported file parses");
|
||||
assert_eq!(
|
||||
parsed.addresses,
|
||||
vec!["10.80.0.9/32".parse().expect("valid address")]
|
||||
);
|
||||
assert_eq!(parsed.allowed_ips.len(), 2);
|
||||
assert_eq!(parsed.mtu.as_deref(), Some("1380"));
|
||||
assert_eq!(parsed.endpoint.as_deref(), Some("vpn.example.test:51821"));
|
||||
assert_eq!(parsed.persistent_keepalive.as_deref(), Some("25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_rendering_rejects_stale_and_mismatched_bindings() {
|
||||
let client = key(6);
|
||||
let server_private = key(9);
|
||||
let (endpoint, dns) = settings();
|
||||
let opts = DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
};
|
||||
let record = imported_record(&client, "10.80.0.9/32", &server_private);
|
||||
let stale_saved = document(&client, "10.80.0.8/32");
|
||||
assert_eq!(
|
||||
render_imported_client(client.public_key(), &record, &stale_saved, &opts).err(),
|
||||
Some(DeliveryError::AddressMismatch)
|
||||
);
|
||||
let changed_server = document_with_server(&client, "10.80.0.9/32", &key(8));
|
||||
assert_eq!(
|
||||
render_imported_client(client.public_key(), &record, &changed_server, &opts).err(),
|
||||
Some(DeliveryError::ServerIdentityMismatch)
|
||||
);
|
||||
assert!(record.server_public_key().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_rendering_requires_saved_server_identity() {
|
||||
let client = key(12);
|
||||
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
|
||||
bundle
|
||||
.encrypt_record(client.public_key(), &client)
|
||||
.expect("client key encrypts");
|
||||
let record = bundle
|
||||
.retrieve_client_record(client.public_key(), PASSWORD)
|
||||
.expect("record retrieves");
|
||||
let saved = ConfigDocument::parse(format!(
|
||||
"[Interface]\nPrivateKey = malformed\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.9/32\n",
|
||||
client.public_key()
|
||||
))
|
||||
.expect("synthetic malformed-key document parses structurally");
|
||||
let (endpoint, dns) = settings();
|
||||
let opts = DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
};
|
||||
let valid_saved = document(&client, "10.80.0.9/32");
|
||||
assert_eq!(
|
||||
render_imported_client(client.public_key(), &record, &valid_saved, &opts).err(),
|
||||
Some(DeliveryError::ServerIdentityMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
render_imported_client(client.public_key(), &record, &saved, &opts).err(),
|
||||
Some(DeliveryError::InvalidSavedServerIdentity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_debug_and_render_errors_do_not_contain_secret_text() {
|
||||
let client = key(10);
|
||||
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
|
||||
let (endpoint, dns) = settings();
|
||||
let payload = render_new_client(
|
||||
client.public_key(),
|
||||
&client,
|
||||
&saved,
|
||||
&DeliverySettings {
|
||||
server_endpoint: &endpoint,
|
||||
dns_addresses: &dns,
|
||||
},
|
||||
)
|
||||
.expect("valid synthetic values render");
|
||||
let debug = format!("{payload:?}");
|
||||
assert!(debug.contains("REDACTED"));
|
||||
assert!(!debug.contains(&client.to_base64()));
|
||||
assert!(Ipv4Addr::LOCALHOST.is_loopback());
|
||||
assert_eq!(
|
||||
"::1".parse::<Ipv6Addr>().expect("valid IPv6"),
|
||||
Ipv6Addr::LOCALHOST
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
//! Secure publication of explicitly selected plaintext client configurations.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::os::fd::AsFd;
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use rustix::fs::{self as rfs, Mode, OFlags, RenameFlags, ResolveFlags};
|
||||
use thiserror::Error;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use super::{ClientConfigPayload, MAX_CONFIG_BYTES};
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
const MAX_OUTPUT_BYTES: usize = MAX_CONFIG_BYTES;
|
||||
|
||||
/// Resolved installation files and protected directories supplied by SavedStore.
|
||||
pub(crate) struct OutputProtection {
|
||||
protected_files: Vec<PathBuf>,
|
||||
protected_directories: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl OutputProtection {
|
||||
pub(crate) fn new(files: Vec<PathBuf>, directories: Vec<PathBuf>) -> Self {
|
||||
Self {
|
||||
protected_files: files,
|
||||
protected_directories: directories,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A destination prepared without creating files or directories.
|
||||
///
|
||||
/// The optional existing-file identity binds overwrite confirmation to the
|
||||
/// precise regular file observed by [`prepare_destination`].
|
||||
pub struct PreparedDestination {
|
||||
path: PathBuf,
|
||||
parent: PathBuf,
|
||||
filename: std::ffi::OsString,
|
||||
existing: Option<FileIdentity>,
|
||||
overwrite_confirmed: bool,
|
||||
}
|
||||
|
||||
impl PreparedDestination {
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub const fn overwrite_confirmation_required(&self) -> bool {
|
||||
self.existing.is_some() && !self.overwrite_confirmed
|
||||
}
|
||||
|
||||
/// Confirm replacement of the exact regular file observed during preparation.
|
||||
pub fn confirm_overwrite(mut self) -> Result<Self, FileOutputError> {
|
||||
if self.existing.is_none() {
|
||||
return Err(FileOutputError::OverwriteNotRequired);
|
||||
}
|
||||
self.overwrite_confirmed = true;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedDestination {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PreparedDestination")
|
||||
.field("path", &self.path)
|
||||
.field("existing_file", &self.existing.is_some())
|
||||
.field("overwrite_confirmed", &self.overwrite_confirmed)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct FileIdentity {
|
||||
device: u64,
|
||||
inode: u64,
|
||||
length: u64,
|
||||
modified_seconds: i64,
|
||||
modified_nanoseconds: i64,
|
||||
}
|
||||
|
||||
impl From<&fs::Metadata> for FileIdentity {
|
||||
fn from(metadata: &fs::Metadata) -> Self {
|
||||
Self {
|
||||
device: metadata.dev(),
|
||||
inode: metadata.ino(),
|
||||
length: metadata.len(),
|
||||
modified_seconds: metadata.mtime(),
|
||||
modified_nanoseconds: metadata.mtime_nsec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure categories let the application preserve retry eligibility while
|
||||
/// distinguishing a failed preparation from a file that may already be visible.
|
||||
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
|
||||
pub enum FileOutputError {
|
||||
#[error("selected output path is unsafe or protected")]
|
||||
UnsafePath,
|
||||
#[error("selected destination already exists; confirm replacement for this file")]
|
||||
OverwriteDeclined,
|
||||
#[error("the selected destination changed after it was prepared")]
|
||||
DestinationChanged,
|
||||
#[error("client configuration exceeds the output size limit")]
|
||||
TooLarge,
|
||||
#[error("client configuration could not be written before publication")]
|
||||
BeforePublication,
|
||||
#[error("a private temporary client configuration may remain and requires manual cleanup")]
|
||||
ManualCleanupRequired,
|
||||
#[error("client configuration may have been published, but directory sync failed")]
|
||||
PublicationUncertain,
|
||||
#[error("file output failed")]
|
||||
Io,
|
||||
#[error("overwrite confirmation is not applicable to a new destination")]
|
||||
OverwriteNotRequired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Checkpoint {
|
||||
Write,
|
||||
Flush,
|
||||
FileSync,
|
||||
Cleanup,
|
||||
Publish,
|
||||
Rollback,
|
||||
ParentSync,
|
||||
}
|
||||
|
||||
/// Validate a caller-selected absolute destination without performing writes.
|
||||
/// Existing targets are accepted only when they are regular files; they require
|
||||
/// an explicit later call to [`PreparedDestination::confirm_overwrite`].
|
||||
pub(crate) fn prepare_destination(
|
||||
path: impl AsRef<Path>,
|
||||
protection: &OutputProtection,
|
||||
) -> Result<PreparedDestination, FileOutputError> {
|
||||
let path = path.as_ref();
|
||||
validate_absolute_path(path)?;
|
||||
reject_protected(path, protection)?;
|
||||
let parent = path.parent().ok_or(FileOutputError::UnsafePath)?;
|
||||
validate_existing_ancestors(parent)?;
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or(FileOutputError::UnsafePath)?
|
||||
.to_os_string();
|
||||
let existing = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_file() => {
|
||||
reject_protected_hardlink(&metadata, protection)?;
|
||||
Some(FileIdentity::from(&metadata))
|
||||
}
|
||||
Ok(_) => return Err(FileOutputError::UnsafePath),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(_) => return Err(FileOutputError::Io),
|
||||
};
|
||||
Ok(PreparedDestination {
|
||||
path: path.to_path_buf(),
|
||||
parent: parent.to_path_buf(),
|
||||
filename,
|
||||
existing,
|
||||
overwrite_confirmed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the bounded payload using a private same-directory temporary file.
|
||||
/// New targets use a no-replace rename. Confirmed overwrite uses an atomic
|
||||
/// exchange and verifies the displaced inode before removing it.
|
||||
pub fn publish(
|
||||
destination: PreparedDestination,
|
||||
payload: &ClientConfigPayload,
|
||||
) -> Result<(), FileOutputError> {
|
||||
publish_with_hook(destination, payload, |_| Ok(()))
|
||||
}
|
||||
|
||||
fn publish_with_hook(
|
||||
destination: PreparedDestination,
|
||||
payload: &ClientConfigPayload,
|
||||
mut hook: impl FnMut(Checkpoint) -> io::Result<()>,
|
||||
) -> Result<(), FileOutputError> {
|
||||
if payload.len() > MAX_OUTPUT_BYTES {
|
||||
return Err(FileOutputError::TooLarge);
|
||||
}
|
||||
if destination.overwrite_confirmation_required() {
|
||||
return Err(FileOutputError::OverwriteDeclined);
|
||||
}
|
||||
validate_existing_ancestors(&destination.parent)?;
|
||||
match (
|
||||
&destination.existing,
|
||||
fs::symlink_metadata(&destination.path),
|
||||
) {
|
||||
(Some(expected), Ok(actual))
|
||||
if actual.file_type().is_file() && FileIdentity::from(&actual) == *expected => {}
|
||||
(None, Err(error)) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
(None, Ok(_)) => return Err(FileOutputError::DestinationChanged),
|
||||
_ => return Err(FileOutputError::DestinationChanged),
|
||||
}
|
||||
|
||||
create_missing_private_directories(&destination.parent)?;
|
||||
validate_existing_ancestors(&destination.parent)?;
|
||||
let parent = open_parent_directory(&destination.parent)?;
|
||||
let mut temp_name = None;
|
||||
let mut temp_file = None;
|
||||
for _ in 0..32 {
|
||||
let mut random = [0_u8; 16];
|
||||
getrandom::fill(&mut random).map_err(|_| FileOutputError::Io)?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let name = format!(".wg-admin-client-{sequence}-{}.tmp", hex(&random));
|
||||
match rfs::openat(
|
||||
&parent,
|
||||
name.as_str(),
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::from_raw_mode(0o600),
|
||||
) {
|
||||
Ok(fd) => {
|
||||
temp_name = Some(name);
|
||||
temp_file = Some(File::from(fd));
|
||||
break;
|
||||
}
|
||||
Err(rustix::io::Errno::EXIST) => continue,
|
||||
Err(_) => return Err(FileOutputError::Io),
|
||||
}
|
||||
}
|
||||
let temp_name = temp_name.ok_or(FileOutputError::Io)?;
|
||||
let mut file = temp_file.ok_or(FileOutputError::Io)?;
|
||||
let mut bytes = Zeroizing::new(payload.as_bytes().to_vec());
|
||||
let write_result = (|| -> io::Result<()> {
|
||||
hook(Checkpoint::Write)?;
|
||||
file.write_all(bytes.as_slice())?;
|
||||
hook(Checkpoint::Flush)?;
|
||||
file.flush()?;
|
||||
hook(Checkpoint::FileSync)?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
bytes.zeroize();
|
||||
if write_result.is_err() {
|
||||
drop(file);
|
||||
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
|
||||
FileOutputError::BeforePublication
|
||||
} else {
|
||||
FileOutputError::ManualCleanupRequired
|
||||
});
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let staged_identity = file_identity_at(&parent, &temp_name);
|
||||
let Some(staged_identity) = staged_identity else {
|
||||
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
|
||||
FileOutputError::Io
|
||||
} else {
|
||||
FileOutputError::ManualCleanupRequired
|
||||
});
|
||||
};
|
||||
if hook(Checkpoint::Publish).is_err() {
|
||||
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
|
||||
FileOutputError::BeforePublication
|
||||
} else {
|
||||
FileOutputError::ManualCleanupRequired
|
||||
});
|
||||
}
|
||||
let publication = if destination.existing.is_some() {
|
||||
rfs::renameat_with(
|
||||
&parent,
|
||||
temp_name.as_str(),
|
||||
&parent,
|
||||
&destination.filename,
|
||||
RenameFlags::EXCHANGE,
|
||||
)
|
||||
} else {
|
||||
rfs::renameat_with(
|
||||
&parent,
|
||||
temp_name.as_str(),
|
||||
&parent,
|
||||
&destination.filename,
|
||||
RenameFlags::NOREPLACE,
|
||||
)
|
||||
};
|
||||
if publication.is_err() {
|
||||
let clean = cleanup_temporary(&parent, &temp_name, &mut hook);
|
||||
return Err(if !clean {
|
||||
FileOutputError::ManualCleanupRequired
|
||||
} else if destination.existing.is_some() {
|
||||
FileOutputError::DestinationChanged
|
||||
} else {
|
||||
FileOutputError::BeforePublication
|
||||
});
|
||||
}
|
||||
if let Some(expected) = destination.existing {
|
||||
let displaced = file_identity_at(&parent, &temp_name);
|
||||
if displaced != Some(expected) {
|
||||
// EXCHANGE never discards either entry. Rollback is another
|
||||
// unconditional exchange, so a concurrent substitution can change
|
||||
// either name before or during it. Never unlink by name here.
|
||||
if hook(Checkpoint::Rollback).is_err() {
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
if rfs::renameat_with(
|
||||
&parent,
|
||||
temp_name.as_str(),
|
||||
&parent,
|
||||
&destination.filename,
|
||||
RenameFlags::EXCHANGE,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
if file_identity_at(&parent, &destination.filename) != displaced
|
||||
|| file_identity_at(&parent, &temp_name) != Some(staged_identity)
|
||||
{
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
// Keep the staged entry under its private temporary name. Removing
|
||||
// it by name would race with another substitution and could destroy
|
||||
// an unconfirmed entry.
|
||||
return Err(FileOutputError::DestinationChanged);
|
||||
}
|
||||
// The exchanged entry is the exact confirmed file. The atomic exchange
|
||||
// closes the check/rename race; remove only after that identity check.
|
||||
if rfs::unlinkat(&parent, temp_name.as_str(), rfs::AtFlags::empty()).is_err() {
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
}
|
||||
if file_identity_at(&parent, &destination.filename) != Some(staged_identity) {
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
|
||||
return Err(FileOutputError::PublicationUncertain);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_temporary(
|
||||
parent: &impl AsFd,
|
||||
name: &str,
|
||||
hook: &mut impl FnMut(Checkpoint) -> io::Result<()>,
|
||||
) -> bool {
|
||||
if hook(Checkpoint::Cleanup).is_err() {
|
||||
return false;
|
||||
}
|
||||
match rfs::unlinkat(parent, name, rfs::AtFlags::empty()) {
|
||||
Ok(()) | Err(rustix::io::Errno::NOENT) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_identity_at(parent: &impl AsFd, name: impl AsRef<std::ffi::OsStr>) -> Option<FileIdentity> {
|
||||
let fd = rfs::openat(
|
||||
parent,
|
||||
name.as_ref(),
|
||||
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
)
|
||||
.ok()?;
|
||||
let metadata = File::from(fd).metadata().ok()?;
|
||||
metadata.is_file().then(|| FileIdentity::from(&metadata))
|
||||
}
|
||||
|
||||
fn validate_absolute_path(path: &Path) -> Result<(), FileOutputError> {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
|
||||
{
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_existing_ancestors(path: &Path) -> Result<(), FileOutputError> {
|
||||
let mut current = PathBuf::from("/");
|
||||
let components: Vec<_> = path.components().skip(1).collect();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
};
|
||||
current.push(name);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_dir() => {
|
||||
validate_directory_metadata(&metadata, index + 1 == components.len())?;
|
||||
}
|
||||
Ok(_) => return Err(FileOutputError::UnsafePath),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
|
||||
Err(_) => return Err(FileOutputError::Io),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_missing_private_directories(path: &Path) -> Result<(), FileOutputError> {
|
||||
let mut current = PathBuf::from("/");
|
||||
for component in path.components().skip(1) {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
};
|
||||
current.push(name);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_dir() => {}
|
||||
Ok(_) => return Err(FileOutputError::UnsafePath),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.mode(0o700);
|
||||
builder.create(¤t).map_err(|_| FileOutputError::Io)?;
|
||||
}
|
||||
Err(_) => return Err(FileOutputError::Io),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_parent_directory(path: &Path) -> Result<File, FileOutputError> {
|
||||
let fd = rfs::openat2(
|
||||
rfs::ABS,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_SYMLINKS,
|
||||
)
|
||||
.map_err(|_| FileOutputError::UnsafePath)?;
|
||||
let directory = File::from(fd);
|
||||
let metadata = directory.metadata().map_err(|_| FileOutputError::Io)?;
|
||||
validate_directory_metadata(&metadata, true)?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn validate_directory_metadata(
|
||||
metadata: &fs::Metadata,
|
||||
is_output_parent: bool,
|
||||
) -> Result<(), FileOutputError> {
|
||||
let uid = rustix::process::geteuid().as_raw();
|
||||
if is_output_parent && metadata.uid() != 0 && metadata.uid() != uid {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
let mode = metadata.mode();
|
||||
let group_or_other_writable = mode & 0o022 != 0;
|
||||
let sticky = mode & 0o1000 != 0;
|
||||
if group_or_other_writable && (is_output_parent || !sticky) {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
|
||||
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn reject_protected(path: &Path, protection: &OutputProtection) -> Result<(), FileOutputError> {
|
||||
for protected in &protection.protected_files {
|
||||
if lexical_normalize(path)? == lexical_normalize(protected)? {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
let target_meta = fs::symlink_metadata(path);
|
||||
let protected_meta = fs::symlink_metadata(protected);
|
||||
if let (Ok(target_meta), Ok(protected_meta)) = (target_meta, protected_meta)
|
||||
&& target_meta.dev() == protected_meta.dev()
|
||||
&& target_meta.ino() == protected_meta.ino()
|
||||
{
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
}
|
||||
for directory in &protection.protected_directories {
|
||||
if lexical_normalize(path)?.starts_with(lexical_normalize(directory)?) {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_protected_hardlink(
|
||||
metadata: &fs::Metadata,
|
||||
protection: &OutputProtection,
|
||||
) -> Result<(), FileOutputError> {
|
||||
for protected in &protection.protected_files {
|
||||
if let Ok(protected_metadata) = fs::symlink_metadata(protected)
|
||||
&& metadata.dev() == protected_metadata.dev()
|
||||
&& metadata.ino() == protected_metadata.ino()
|
||||
{
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lexical_normalize(path: &Path) -> Result<PathBuf, FileOutputError> {
|
||||
validate_absolute_path(path)?;
|
||||
let mut normalized = PathBuf::from("/");
|
||||
for component in path.components().skip(1) {
|
||||
if let Component::Normal(name) = component {
|
||||
normalized.push(name);
|
||||
} else {
|
||||
return Err(FileOutputError::UnsafePath);
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Checkpoint, FileOutputError, OutputProtection, PreparedDestination, prepare_destination,
|
||||
publish, publish_with_hook,
|
||||
};
|
||||
use crate::delivery::ClientConfigPayload;
|
||||
use rustix::fs as rfs;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::path::Path;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn payload() -> ClientConfigPayload {
|
||||
ClientConfigPayload(Zeroizing::new(b"synthetic client configuration\n".to_vec()))
|
||||
}
|
||||
|
||||
fn protection(root: &Path) -> OutputProtection {
|
||||
OutputProtection::new(
|
||||
vec![root.join("settings.toml"), root.join("server.conf")],
|
||||
vec![root.join("Backup/data"), root.join("Backup/archives")],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_new_file_with_private_mode_and_private_new_directories() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("chosen/nested/client.conf");
|
||||
let prepared = prepare_destination(&target, &protection(root.path()))
|
||||
.expect("safe explicit destination");
|
||||
assert!(!target.exists());
|
||||
publish(prepared, &payload()).expect("publication succeeds");
|
||||
assert_eq!(
|
||||
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(root.path().join("chosen"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(root.path().join("chosen/nested"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert!(
|
||||
!fs::read_dir(root.path().join("chosen/nested"))
|
||||
.unwrap()
|
||||
.any(|entry| entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".tmp"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_file_requires_bound_confirmation_and_decline_preserves_bytes() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("existing.conf");
|
||||
fs::write(&target, b"old synthetic value").unwrap();
|
||||
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
|
||||
assert!(prepared.overwrite_confirmation_required());
|
||||
assert_eq!(
|
||||
publish(prepared, &payload()),
|
||||
Err(FileOutputError::OverwriteDeclined)
|
||||
);
|
||||
assert!(fs::read(&target).unwrap() == b"old synthetic value");
|
||||
|
||||
let confirmed = prepare_destination(&target, &protection(root.path()))
|
||||
.unwrap()
|
||||
.confirm_overwrite()
|
||||
.unwrap();
|
||||
publish(confirmed, &payload()).expect("confirmed bounded replacement");
|
||||
assert!(fs::read(&target).unwrap() == b"synthetic client configuration\n");
|
||||
assert_eq!(
|
||||
fs::metadata(target).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_symlink_special_traversal_and_protected_targets() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let outside = root.path().join("outside");
|
||||
fs::write(&outside, b"untouched").unwrap();
|
||||
let link = root.path().join("link");
|
||||
symlink(&outside, &link).unwrap();
|
||||
assert_eq!(
|
||||
prepare_destination(&link, &protection(root.path())).unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_destination(root.path().join("missing/../bad"), &protection(root.path()))
|
||||
.unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_destination(root.path().join("settings.toml"), &protection(root.path()))
|
||||
.unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_destination(
|
||||
root.path().join("Backup/data/vault.toml"),
|
||||
&protection(root.path())
|
||||
)
|
||||
.unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
assert_eq!(fs::read(outside).unwrap(), b"untouched");
|
||||
|
||||
let fifo = root.path().join("fifo");
|
||||
rfs::mkfifoat(rfs::ABS, &fifo, rustix::fs::Mode::from_raw_mode(0o600))
|
||||
.expect("synthetic special file");
|
||||
assert_eq!(
|
||||
prepare_destination(&fifo, &protection(root.path())).unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_ancestors_and_protects_original_source_entries() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let other = tempfile::tempdir().expect("other temp root");
|
||||
let link = root.path().join("linked-dir");
|
||||
symlink(other.path(), &link).unwrap();
|
||||
assert_eq!(
|
||||
prepare_destination(link.join("client.conf"), &protection(root.path())).unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
let source = root.path().join("clients");
|
||||
fs::create_dir(&source).unwrap();
|
||||
let original = source.join("original.conf");
|
||||
fs::write(&original, b"source fixture").unwrap();
|
||||
let policy = OutputProtection::new(vec![original.clone()], vec![]);
|
||||
assert_eq!(
|
||||
prepare_destination(&original, &policy).unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_group_or_world_writable_output_parent_before_staging_plaintext() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let shared = root.path().join("shared");
|
||||
fs::create_dir(&shared).unwrap();
|
||||
fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let target = shared.join("client.conf");
|
||||
|
||||
assert_eq!(
|
||||
prepare_destination(&target, &protection(root.path())).unwrap_err(),
|
||||
FileOutputError::UnsafePath
|
||||
);
|
||||
assert_eq!(fs::read_dir(&shared).unwrap().count(), 0);
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_write_and_sync_failures_report_publication_state() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
for (index, failed_stage) in [Checkpoint::Write, Checkpoint::Flush, Checkpoint::FileSync]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let before_target = root.path().join(format!("pre-publish-{index}.conf"));
|
||||
let before = prepare_destination(&before_target, &protection(root.path())).unwrap();
|
||||
let failure = publish_with_hook(before, &payload(), |stage| {
|
||||
if stage == failed_stage {
|
||||
Err(io::Error::other("synthetic pre-publication fault"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
assert_eq!(failure, Err(FileOutputError::BeforePublication));
|
||||
assert!(!before_target.exists());
|
||||
}
|
||||
|
||||
let uncertain_target = root.path().join("sync-fail.conf");
|
||||
let after = prepare_destination(&uncertain_target, &protection(root.path())).unwrap();
|
||||
let failure = publish_with_hook(after, &payload(), |stage| {
|
||||
if stage == Checkpoint::ParentSync {
|
||||
Err(io::Error::other("synthetic flush fault"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
assert_eq!(failure, Err(FileOutputError::PublicationUncertain));
|
||||
assert!(uncertain_target.exists());
|
||||
assert!(fs::read(uncertain_target).unwrap() == b"synthetic client configuration\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_failure_reports_manual_recovery_and_retains_private_temporary_file() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("cleanup-fail.conf");
|
||||
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
|
||||
let failure = publish_with_hook(prepared, &payload(), |stage| match stage {
|
||||
Checkpoint::Flush | Checkpoint::Cleanup => Err(io::Error::other("synthetic fault")),
|
||||
_ => Ok(()),
|
||||
});
|
||||
|
||||
assert_eq!(failure, Err(FileOutputError::ManualCleanupRequired));
|
||||
assert!(!target.exists());
|
||||
let temporary = fs::read_dir(root.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.find(|path| {
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with(".wg-admin-client-")
|
||||
})
|
||||
.expect("temporary plaintext is retained for manual cleanup");
|
||||
assert_eq!(
|
||||
fs::metadata(&temporary).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_replace_publication_keeps_a_racing_new_file_untouched() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("racing.conf");
|
||||
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
|
||||
let failure = publish_with_hook(prepared, &payload(), |stage| {
|
||||
if stage == Checkpoint::Publish {
|
||||
fs::write(&target, b"racing synthetic content")?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(failure, Err(FileOutputError::BeforePublication));
|
||||
assert!(fs::read(target).unwrap() == b"racing synthetic content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_existing_destination_cannot_reuse_old_confirmation() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("replace.conf");
|
||||
fs::write(&target, b"original").unwrap();
|
||||
let prepared = prepare_destination(&target, &protection(root.path()))
|
||||
.unwrap()
|
||||
.confirm_overwrite()
|
||||
.unwrap();
|
||||
fs::write(&target, b"changed after selection").unwrap();
|
||||
assert_eq!(
|
||||
publish(prepared, &payload()),
|
||||
Err(FileOutputError::DestinationChanged)
|
||||
);
|
||||
assert!(fs::read(target).unwrap() == b"changed after selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitution_at_publish_checkpoint_is_exchanged_back_without_data_loss() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("substituted.conf");
|
||||
fs::write(&target, b"confirmed original").unwrap();
|
||||
let prepared = prepare_destination(&target, &protection(root.path()))
|
||||
.unwrap()
|
||||
.confirm_overwrite()
|
||||
.unwrap();
|
||||
let result = publish_with_hook(prepared, &payload(), |stage| {
|
||||
if stage == Checkpoint::Publish {
|
||||
fs::remove_file(&target)?;
|
||||
fs::write(&target, b"unconfirmed substitute")?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(result, Err(FileOutputError::DestinationChanged));
|
||||
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
|
||||
let temporary = fs::read_dir(root.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.find(|path| {
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.ends_with(".tmp")
|
||||
})
|
||||
.expect("staged output is retained under its private temporary name");
|
||||
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitution_during_rollback_retains_every_entry_and_reports_uncertain() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("substituted-during-rollback.conf");
|
||||
let parked = root.path().join("parked-published.conf");
|
||||
fs::write(&target, b"confirmed original").unwrap();
|
||||
let prepared = prepare_destination(&target, &protection(root.path()))
|
||||
.unwrap()
|
||||
.confirm_overwrite()
|
||||
.unwrap();
|
||||
let result = publish_with_hook(prepared, &payload(), |stage| {
|
||||
if stage == Checkpoint::Publish {
|
||||
fs::remove_file(&target)?;
|
||||
fs::write(&target, b"first unconfirmed substitute")?;
|
||||
} else if stage == Checkpoint::Rollback {
|
||||
fs::rename(&target, &parked)?;
|
||||
fs::write(&target, b"second unconfirmed substitute")?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
|
||||
assert!(fs::read(&target).unwrap() == b"first unconfirmed substitute");
|
||||
assert!(fs::read(parked).unwrap() == b"synthetic client configuration\n");
|
||||
let temporary = fs::read_dir(root.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.find(|path| {
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.ends_with(".tmp")
|
||||
})
|
||||
.expect("second unconfirmed substitute is retained");
|
||||
assert!(fs::read(temporary).unwrap() == b"second unconfirmed substitute");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_parent_sync_failure_is_uncertain_and_keeps_both_entries() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let target = root.path().join("rollback-sync-fail.conf");
|
||||
fs::write(&target, b"confirmed original").unwrap();
|
||||
let prepared = prepare_destination(&target, &protection(root.path()))
|
||||
.unwrap()
|
||||
.confirm_overwrite()
|
||||
.unwrap();
|
||||
let result = publish_with_hook(prepared, &payload(), |stage| {
|
||||
if stage == Checkpoint::Publish {
|
||||
fs::remove_file(&target)?;
|
||||
fs::write(&target, b"unconfirmed substitute")?;
|
||||
} else if stage == Checkpoint::ParentSync {
|
||||
return Err(io::Error::other("synthetic rollback directory sync fault"));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
|
||||
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
|
||||
let temporary = fs::read_dir(root.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.find(|path| {
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.ends_with(".tmp")
|
||||
})
|
||||
.expect("published staged output remains retained");
|
||||
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_is_not_available_for_a_new_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let target = root.path().join("new.conf");
|
||||
let prepared: PreparedDestination =
|
||||
prepare_destination(&target, &protection(root.path())).unwrap();
|
||||
assert_eq!(
|
||||
prepared.confirm_overwrite().unwrap_err(),
|
||||
FileOutputError::OverwriteNotRequired
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Bounded, operation-local QR representation for an authorized client payload.
|
||||
//!
|
||||
//! The encoder necessarily allocates internal copies of the payload and QR
|
||||
//! codewords that this crate cannot zeroize. This module clears its owned matrix
|
||||
//! on drop and never formats or serializes it implicitly.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use qrcode::{QrCode, types::Color};
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::ClientConfigPayload;
|
||||
|
||||
const QUIET_ZONE: usize = 4;
|
||||
const MAX_QR_SIDE: usize = 185; // Version 40 (177 modules) plus quiet zone.
|
||||
|
||||
/// Secret-bearing QR modules including the four-module quiet zone.
|
||||
///
|
||||
/// The bytes are row-major: `0` is light and `1` is dark. Access is explicit
|
||||
/// through [`Self::module`]; there is no `Display` or serialization API.
|
||||
pub struct QrCodeOutput {
|
||||
side: usize,
|
||||
modules: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl QrCodeOutput {
|
||||
pub fn side(&self) -> usize {
|
||||
self.side
|
||||
}
|
||||
|
||||
pub fn module(&self, x: usize, y: usize) -> Option<bool> {
|
||||
if x >= self.side || y >= self.side {
|
||||
return None;
|
||||
}
|
||||
self.modules.get(y * self.side + x).map(|value| *value != 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for QrCodeOutput {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("QrCodeOutput")
|
||||
.field("side", &self.side)
|
||||
.field("modules", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret-free QR encoding failures.
|
||||
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
|
||||
pub enum QrError {
|
||||
#[error("client configuration exceeds QR capacity")]
|
||||
CapacityExceeded,
|
||||
#[error("QR encoding failed")]
|
||||
EncodingFailed,
|
||||
#[error("encoded QR dimensions exceed the supported bound")]
|
||||
DimensionsExceeded,
|
||||
}
|
||||
|
||||
/// Encode exactly the supplied delivery payload with a four-module quiet zone.
|
||||
pub(crate) fn encode_payload(payload: &ClientConfigPayload) -> Result<QrCodeOutput, QrError> {
|
||||
encode_bytes(payload.as_bytes())
|
||||
}
|
||||
|
||||
fn encode_bytes(bytes: &[u8]) -> Result<QrCodeOutput, QrError> {
|
||||
let code = QrCode::new(bytes).map_err(|error| match error {
|
||||
qrcode::types::QrError::DataTooLong => QrError::CapacityExceeded,
|
||||
_ => QrError::EncodingFailed,
|
||||
})?;
|
||||
let inner_side = code.width();
|
||||
let side = inner_side
|
||||
.checked_add(QUIET_ZONE * 2)
|
||||
.filter(|side| *side <= MAX_QR_SIDE)
|
||||
.ok_or(QrError::DimensionsExceeded)?;
|
||||
let mut modules = Zeroizing::new(vec![0; side * side]);
|
||||
for y in 0..inner_side {
|
||||
for x in 0..inner_side {
|
||||
if code[(x, y)] == Color::Dark {
|
||||
modules[(y + QUIET_ZONE) * side + x + QUIET_ZONE] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(QrCodeOutput { side, modules })
|
||||
}
|
||||
|
||||
/// Small, non-secret QR used only to exercise the terminal disclosure renderer.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn synthetic_test_output() -> QrCodeOutput {
|
||||
encode_bytes(b"synthetic QR disclosure fixture").expect("synthetic QR payload encodes")
|
||||
}
|
||||
|
||||
/// Deterministic, non-secret matrix for terminal layout and glyph tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn synthetic_test_matrix(side: usize) -> QrCodeOutput {
|
||||
let mut modules = Zeroizing::new(vec![0; side.saturating_mul(side)]);
|
||||
for y in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) {
|
||||
for x in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) {
|
||||
if (x * 3 + y * 5) % 7 < 3 {
|
||||
modules[y * side + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
QrCodeOutput { side, modules }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encodes_bounded_matrix_with_quiet_zone_and_redacted_debug() {
|
||||
let payload = ClientConfigPayload(Zeroizing::new(
|
||||
b"[Interface]\nPrivateKey = synthetic-secret\n".to_vec(),
|
||||
));
|
||||
let matrix = encode_payload(&payload).expect("small payload encodes");
|
||||
assert!((21 + 8..=MAX_QR_SIDE).contains(&matrix.side()));
|
||||
assert!((0..QUIET_ZONE).all(|offset| {
|
||||
(0..matrix.side()).all(|position| {
|
||||
matrix.module(position, offset) == Some(false)
|
||||
&& matrix.module(offset, position) == Some(false)
|
||||
})
|
||||
}));
|
||||
let debug = format!("{matrix:?}");
|
||||
assert!(debug.contains("REDACTED"));
|
||||
assert!(!debug.contains("synthetic-secret"));
|
||||
assert_eq!(matrix.module(matrix.side(), 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_payload_capacity_without_including_payload_in_error() {
|
||||
let payload = ClientConfigPayload(Zeroizing::new(vec![b'x'; 65_536]));
|
||||
let error = encode_payload(&payload).expect_err("payload exceeds QR capacity");
|
||||
assert_eq!(error, QrError::CapacityExceeded);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"client configuration exceeds QR capacity"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1016
File diff suppressed because it is too large
Load Diff
+17
@@ -0,0 +1,17 @@
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod delivery;
|
||||
pub mod domain;
|
||||
pub mod persistence;
|
||||
pub mod runtime;
|
||||
pub mod service;
|
||||
pub mod startup;
|
||||
pub mod tui;
|
||||
pub mod vault;
|
||||
pub mod wireguard;
|
||||
|
||||
// This driver is compiled only into the library test harness. In particular,
|
||||
// the normal executable has no fixture apply path.
|
||||
#[cfg(test)]
|
||||
mod container_driver;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::process::ExitCode;
|
||||
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\
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments: Vec<OsString> = env::args_os().skip(1).collect();
|
||||
let result = startup::start(&arguments, || {
|
||||
rustix::process::geteuid() == rustix::process::Uid::ROOT
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(StartupAction::Help) => {
|
||||
println!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Ok(StartupAction::Version) => {
|
||||
println!("wireguard-admin {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
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())
|
||||
}
|
||||
+1341
File diff suppressed because it is too large
Load Diff
+674
@@ -0,0 +1,674 @@
|
||||
//! Public, read-only runtime observations and pure saved-peer verification.
|
||||
//!
|
||||
//! The transport performs bounded read-only Linux access; comparison remains
|
||||
//! pure and accepts caller-supplied observations.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use ipnet::IpNet;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::domain::PublicKey;
|
||||
use crate::wireguard::{ConfigDocument, DocumentIssue, PeerIssue};
|
||||
|
||||
#[allow(dead_code)] // Integrated by the M5 orchestration step.
|
||||
pub(crate) mod netlink;
|
||||
#[allow(dead_code)] // Consumed by the M5 application integration step.
|
||||
pub(crate) mod transport;
|
||||
|
||||
/// A value that could not be observed is distinct from a valid zero or empty value.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Observed<T> {
|
||||
Available(T),
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Current statistics for a peer, from the server interface's perspective.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RuntimePeer {
|
||||
pub allowed_ips: Observed<Vec<IpNet>>,
|
||||
/// `Available(None)` means WireGuard has never recorded a handshake.
|
||||
pub last_handshake: Observed<Option<OffsetDateTime>>,
|
||||
/// These are current counters and may reset when the interface restarts.
|
||||
pub received_bytes: Observed<u64>,
|
||||
pub transmitted_bytes: Observed<u64>,
|
||||
}
|
||||
|
||||
/// Whether the peer map is exhaustive enough to establish absence and equality.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PeerSetCompleteness {
|
||||
Unknown,
|
||||
Unavailable,
|
||||
Partial,
|
||||
Complete,
|
||||
}
|
||||
|
||||
/// Runtime peers keyed by their public key, with duplicate identities retained
|
||||
/// as an explicit ambiguity signal rather than silently overwritten.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RuntimePeerSet {
|
||||
pub peers: BTreeMap<PublicKey, RuntimePeer>,
|
||||
pub duplicate_public_keys: BTreeSet<PublicKey>,
|
||||
pub completeness: PeerSetCompleteness,
|
||||
}
|
||||
|
||||
impl RuntimePeerSet {
|
||||
/// Collect records while preserving evidence that the adapter returned a
|
||||
/// duplicate public key. The first record remains available for diagnostics.
|
||||
pub fn from_records(
|
||||
completeness: PeerSetCompleteness,
|
||||
records: impl IntoIterator<Item = (PublicKey, RuntimePeer)>,
|
||||
) -> Self {
|
||||
let mut peers = BTreeMap::new();
|
||||
let mut duplicate_public_keys = BTreeSet::new();
|
||||
for (identity, peer) in records {
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = peers.entry(identity) {
|
||||
entry.insert(peer);
|
||||
} else {
|
||||
duplicate_public_keys.insert(identity);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
peers,
|
||||
duplicate_public_keys,
|
||||
completeness,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface presence and peer observation are independent facts.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum InterfaceObservation {
|
||||
Unknown,
|
||||
Unavailable,
|
||||
Missing,
|
||||
Present {
|
||||
/// Interface returned by the runtime adapter.
|
||||
name: String,
|
||||
peers: RuntimePeerSet,
|
||||
},
|
||||
}
|
||||
|
||||
/// One read-only sample. A missing timestamp means the sample time is unknown.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RuntimeObservation {
|
||||
pub observed_at: Option<OffsetDateTime>,
|
||||
pub interface: InterfaceObservation,
|
||||
}
|
||||
|
||||
impl RuntimeObservation {
|
||||
pub const fn unknown() -> Self {
|
||||
Self {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One saved peer's complete technical routes, independent of alias metadata
|
||||
/// and the narrower `domain::Client` address representation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SavedPeer {
|
||||
pub identity: PublicKey,
|
||||
pub allowed_ips: BTreeSet<IpNet>,
|
||||
}
|
||||
|
||||
/// Technical peer projection from the authoritative byte-preserving document.
|
||||
/// Invalid or ambiguous records remain represented as unverifiable entries.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SavedPeerProjection {
|
||||
pub peers: BTreeMap<PublicKey, SavedPeer>,
|
||||
pub duplicate_public_keys: BTreeSet<PublicKey>,
|
||||
pub unverifiable_peer_indices: BTreeSet<usize>,
|
||||
pub document_unverifiable: bool,
|
||||
}
|
||||
|
||||
impl SavedPeerProjection {
|
||||
/// Project every `[Peer]` section, retaining valid but unsupported route
|
||||
/// shapes that cannot fit the UI's ordinary client address model.
|
||||
pub fn from_document(document: &ConfigDocument) -> Self {
|
||||
let mut peers = BTreeMap::new();
|
||||
let mut duplicate_public_keys = BTreeSet::new();
|
||||
let mut unverifiable_peer_indices = BTreeSet::new();
|
||||
|
||||
for peer in document.peers() {
|
||||
let Some(identity) = peer.identity else {
|
||||
unverifiable_peer_indices.insert(peer.index);
|
||||
continue;
|
||||
};
|
||||
let invalid_route = peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }));
|
||||
let duplicate_key_field = peer.issues.iter().any(|issue| {
|
||||
matches!(
|
||||
issue,
|
||||
PeerIssue::MalformedPublicKey { .. }
|
||||
| PeerIssue::DuplicatePublicKeyField { .. }
|
||||
| PeerIssue::DuplicatePeerIdentity { .. }
|
||||
)
|
||||
});
|
||||
if invalid_route || duplicate_key_field {
|
||||
unverifiable_peer_indices.insert(peer.index);
|
||||
}
|
||||
|
||||
let routes = peer
|
||||
.allowed_ips
|
||||
.iter()
|
||||
.map(|network| network.trunc())
|
||||
.collect();
|
||||
if peers
|
||||
.insert(
|
||||
identity,
|
||||
SavedPeer {
|
||||
identity,
|
||||
allowed_ips: routes,
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
duplicate_public_keys.insert(identity);
|
||||
unverifiable_peer_indices.insert(peer.index);
|
||||
}
|
||||
}
|
||||
|
||||
let document_unverifiable = document
|
||||
.issues()
|
||||
.iter()
|
||||
.any(|issue| !matches!(issue, DocumentIssue::ManagedMetadata(_)));
|
||||
Self {
|
||||
peers,
|
||||
duplicate_public_keys,
|
||||
unverifiable_peer_indices,
|
||||
document_unverifiable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A technical discrepancy or a reason verification could not safely decide.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ComparisonFinding {
|
||||
InterfaceUnknown,
|
||||
InterfaceUnavailable,
|
||||
InterfaceMissing,
|
||||
InterfaceMismatch { expected: String, observed: String },
|
||||
PeerSetUnknown,
|
||||
PeerSetUnavailable,
|
||||
PeerSetPartial,
|
||||
SavedDocumentUnverifiable,
|
||||
SavedPeerUnverifiable { peer_index: usize },
|
||||
DuplicateSavedPublicKey { identity: PublicKey },
|
||||
DuplicateRuntimePublicKey { identity: PublicKey },
|
||||
MissingRuntimePeer { identity: PublicKey },
|
||||
ExtraRuntimePeer { identity: PublicKey },
|
||||
RuntimeRoutesUnavailable { identity: PublicKey },
|
||||
AllowedIpsMismatch { identity: PublicKey },
|
||||
}
|
||||
|
||||
/// Outcome does not depend on handshake, counters, aliases, or record ordering.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TechnicalMatch {
|
||||
Match,
|
||||
Mismatch(Vec<ComparisonFinding>),
|
||||
Unverifiable(Vec<ComparisonFinding>),
|
||||
}
|
||||
|
||||
/// Compare the complete saved peer set with a runtime sample, requiring the
|
||||
/// observed interface to be the interface selected by current configuration.
|
||||
pub fn compare_peers(
|
||||
saved: &SavedPeerProjection,
|
||||
expected_interface: &str,
|
||||
runtime: &RuntimeObservation,
|
||||
) -> TechnicalMatch {
|
||||
let mut findings = Vec::new();
|
||||
let runtime_peers = match &runtime.interface {
|
||||
InterfaceObservation::Unknown => {
|
||||
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnknown]);
|
||||
}
|
||||
InterfaceObservation::Unavailable => {
|
||||
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnavailable]);
|
||||
}
|
||||
InterfaceObservation::Missing => {
|
||||
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMissing]);
|
||||
}
|
||||
InterfaceObservation::Present { name, peers } => {
|
||||
if name != expected_interface {
|
||||
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
|
||||
expected: expected_interface.to_owned(),
|
||||
observed: name.clone(),
|
||||
}]);
|
||||
}
|
||||
peers
|
||||
}
|
||||
};
|
||||
|
||||
if saved.document_unverifiable {
|
||||
findings.push(ComparisonFinding::SavedDocumentUnverifiable);
|
||||
}
|
||||
findings.extend(saved.unverifiable_peer_indices.iter().map(|peer_index| {
|
||||
ComparisonFinding::SavedPeerUnverifiable {
|
||||
peer_index: *peer_index,
|
||||
}
|
||||
}));
|
||||
findings.extend(saved.duplicate_public_keys.iter().map(|identity| {
|
||||
ComparisonFinding::DuplicateSavedPublicKey {
|
||||
identity: *identity,
|
||||
}
|
||||
}));
|
||||
findings.extend(runtime_peers.duplicate_public_keys.iter().map(|identity| {
|
||||
ComparisonFinding::DuplicateRuntimePublicKey {
|
||||
identity: *identity,
|
||||
}
|
||||
}));
|
||||
|
||||
match runtime_peers.completeness {
|
||||
PeerSetCompleteness::Unknown => findings.push(ComparisonFinding::PeerSetUnknown),
|
||||
PeerSetCompleteness::Unavailable => findings.push(ComparisonFinding::PeerSetUnavailable),
|
||||
PeerSetCompleteness::Partial => findings.push(ComparisonFinding::PeerSetPartial),
|
||||
PeerSetCompleteness::Complete => {}
|
||||
}
|
||||
|
||||
for (identity, expected) in &saved.peers {
|
||||
match runtime_peers.peers.get(identity) {
|
||||
None if runtime_peers.completeness == PeerSetCompleteness::Complete => {
|
||||
findings.push(ComparisonFinding::MissingRuntimePeer {
|
||||
identity: *identity,
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
Some(actual) => match &actual.allowed_ips {
|
||||
Observed::Unavailable => {
|
||||
findings.push(ComparisonFinding::RuntimeRoutesUnavailable {
|
||||
identity: *identity,
|
||||
})
|
||||
}
|
||||
Observed::Available(routes) => {
|
||||
let normalized: BTreeSet<IpNet> =
|
||||
routes.iter().map(|network| network.trunc()).collect();
|
||||
if normalized != expected.allowed_ips {
|
||||
findings.push(ComparisonFinding::AllowedIpsMismatch {
|
||||
identity: *identity,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if runtime_peers.completeness == PeerSetCompleteness::Complete {
|
||||
for identity in runtime_peers.peers.keys() {
|
||||
if !saved.peers.contains_key(identity) {
|
||||
findings.push(ComparisonFinding::ExtraRuntimePeer {
|
||||
identity: *identity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if findings.is_empty() {
|
||||
TechnicalMatch::Match
|
||||
} else if findings.iter().any(is_unverifiable_finding) {
|
||||
TechnicalMatch::Unverifiable(findings)
|
||||
} else {
|
||||
TechnicalMatch::Mismatch(findings)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unverifiable_finding(finding: &ComparisonFinding) -> bool {
|
||||
matches!(
|
||||
finding,
|
||||
ComparisonFinding::InterfaceUnknown
|
||||
| ComparisonFinding::InterfaceUnavailable
|
||||
| ComparisonFinding::PeerSetUnknown
|
||||
| ComparisonFinding::PeerSetUnavailable
|
||||
| ComparisonFinding::PeerSetPartial
|
||||
| ComparisonFinding::SavedDocumentUnverifiable
|
||||
| ComparisonFinding::SavedPeerUnverifiable { .. }
|
||||
| ComparisonFinding::DuplicateSavedPublicKey { .. }
|
||||
| ComparisonFinding::DuplicateRuntimePublicKey { .. }
|
||||
| ComparisonFinding::RuntimeRoutesUnavailable { .. }
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::*;
|
||||
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
|
||||
fn key(value: &str) -> PublicKey {
|
||||
PublicKey::from_str(value).unwrap()
|
||||
}
|
||||
|
||||
fn runtime_peer(routes: Observed<Vec<IpNet>>) -> RuntimePeer {
|
||||
RuntimePeer {
|
||||
allowed_ips: routes,
|
||||
last_handshake: Observed::Available(None),
|
||||
received_bytes: Observed::Available(0),
|
||||
transmitted_bytes: Observed::Available(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(records: Vec<(PublicKey, RuntimePeer)>) -> RuntimeObservation {
|
||||
RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Present {
|
||||
name: "wg0".to_owned(),
|
||||
peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn compare(saved: &SavedPeerProjection, runtime: &RuntimeObservation) -> TechnicalMatch {
|
||||
compare_peers(saved, "wg0", runtime)
|
||||
}
|
||||
|
||||
fn projection(source: &str) -> SavedPeerProjection {
|
||||
SavedPeerProjection::from_document(&ConfigDocument::parse(source).unwrap())
|
||||
}
|
||||
|
||||
fn route(value: &str) -> IpNet {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_distinguishes_unknown_missing_unavailable_zero_and_no_handshake() {
|
||||
assert_eq!(
|
||||
RuntimeObservation::unknown().interface,
|
||||
InterfaceObservation::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Unavailable,
|
||||
}
|
||||
.interface,
|
||||
InterfaceObservation::Unavailable
|
||||
);
|
||||
assert!(matches!(
|
||||
runtime_peer(Observed::Available(Vec::new())).last_handshake,
|
||||
Observed::Available(None)
|
||||
));
|
||||
assert_eq!(
|
||||
runtime_peer(Observed::Available(Vec::new())).received_bytes,
|
||||
Observed::Available(0)
|
||||
);
|
||||
assert_eq!(
|
||||
runtime_peer(Observed::Unavailable).allowed_ips,
|
||||
Observed::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_accepts_empty_set_and_normalized_reordered_dual_stack_routes() {
|
||||
let empty = projection("");
|
||||
assert_eq!(
|
||||
compare(&empty, &observation(Vec::new())),
|
||||
TechnicalMatch::Match
|
||||
);
|
||||
|
||||
let source =
|
||||
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::2/128, 10.0.0.2/32\n");
|
||||
let expected = projection(&source);
|
||||
let actual = observation(vec![(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![
|
||||
route("10.0.0.2/32"),
|
||||
route("fd00::2/128"),
|
||||
route("10.0.0.2/32"),
|
||||
])),
|
||||
)]);
|
||||
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_requires_the_configured_interface_identity() {
|
||||
let expected = projection(&format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
));
|
||||
let wrong_interface = RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Present {
|
||||
name: "wg1".to_owned(),
|
||||
peers: RuntimePeerSet::from_records(
|
||||
PeerSetCompleteness::Complete,
|
||||
[(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
)],
|
||||
),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
compare_peers(&expected, "wg0", &wrong_interface),
|
||||
TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
|
||||
expected: "wg0".to_owned(),
|
||||
observed: "wg1".to_owned(),
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counters_and_handshake_changes_do_not_change_technical_match() {
|
||||
let expected = projection(&format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
));
|
||||
let mut before_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
|
||||
before_restart_peer.last_handshake = Observed::Available(Some(
|
||||
OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
));
|
||||
before_restart_peer.received_bytes = Observed::Available(1_000);
|
||||
before_restart_peer.transmitted_bytes = Observed::Available(300);
|
||||
let before_restart = observation(vec![(key(KEY_A), before_restart_peer)]);
|
||||
let mut after_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
|
||||
after_restart_peer.last_handshake = Observed::Available(None);
|
||||
after_restart_peer.received_bytes = Observed::Available(0);
|
||||
after_restart_peer.transmitted_bytes = Observed::Available(7);
|
||||
let after_restart = observation(vec![(key(KEY_A), after_restart_peer)]);
|
||||
|
||||
assert_eq!(compare(&expected, &before_restart), TechnicalMatch::Match);
|
||||
assert_eq!(compare(&expected, &after_restart), TechnicalMatch::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_keeps_valid_unsupported_routes_and_ipv4_only_peers() {
|
||||
let source = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 10.20.0.0/16\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.3/32\n"
|
||||
);
|
||||
let expected = projection(&source);
|
||||
assert_eq!(expected.peers.len(), 2);
|
||||
assert!(expected.unverifiable_peer_indices.is_empty());
|
||||
let actual = observation(vec![
|
||||
(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![
|
||||
route("10.20.0.0/16"),
|
||||
route("10.0.0.2/32"),
|
||||
])),
|
||||
),
|
||||
(
|
||||
key(KEY_B),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
|
||||
),
|
||||
]);
|
||||
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_reports_missing_extra_and_route_mismatch() {
|
||||
let expected = projection(&format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
));
|
||||
let actual = observation(vec![(
|
||||
key(KEY_B),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
|
||||
)]);
|
||||
let TechnicalMatch::Mismatch(findings) = compare(&expected, &actual) else {
|
||||
panic!("expected concrete set differences");
|
||||
};
|
||||
assert!(findings.contains(&ComparisonFinding::MissingRuntimePeer {
|
||||
identity: key(KEY_A)
|
||||
}));
|
||||
assert!(findings.contains(&ComparisonFinding::ExtraRuntimePeer {
|
||||
identity: key(KEY_B)
|
||||
}));
|
||||
|
||||
let actual = observation(vec![(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.9/32")])),
|
||||
)]);
|
||||
assert!(matches!(
|
||||
compare(&expected, &actual),
|
||||
TechnicalMatch::Mismatch(findings)
|
||||
if findings.contains(&ComparisonFinding::AllowedIpsMismatch { identity: key(KEY_A) })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_duplicate_and_unavailable_records_never_pass() {
|
||||
let expected = projection(&format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
));
|
||||
let duplicate = RuntimePeerSet::from_records(
|
||||
PeerSetCompleteness::Complete,
|
||||
[
|
||||
(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
),
|
||||
(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert!(matches!(
|
||||
compare(
|
||||
&expected,
|
||||
&RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Present {
|
||||
name: "wg0".to_owned(),
|
||||
peers: duplicate,
|
||||
},
|
||||
}
|
||||
),
|
||||
TechnicalMatch::Unverifiable(findings)
|
||||
if findings.contains(&ComparisonFinding::DuplicateRuntimePublicKey { identity: key(KEY_A) })
|
||||
));
|
||||
|
||||
for completeness in [
|
||||
PeerSetCompleteness::Unknown,
|
||||
PeerSetCompleteness::Unavailable,
|
||||
PeerSetCompleteness::Partial,
|
||||
] {
|
||||
let partial = RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Present {
|
||||
name: "wg0".to_owned(),
|
||||
peers: RuntimePeerSet {
|
||||
peers: BTreeMap::from([(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
)]),
|
||||
duplicate_public_keys: BTreeSet::new(),
|
||||
completeness,
|
||||
},
|
||||
},
|
||||
};
|
||||
let TechnicalMatch::Unverifiable(findings) = compare(&expected, &partial) else {
|
||||
panic!("an incomplete peer set cannot verify");
|
||||
};
|
||||
if completeness != PeerSetCompleteness::Complete {
|
||||
assert!(!findings.iter().any(|finding| matches!(
|
||||
finding,
|
||||
ComparisonFinding::MissingRuntimePeer { .. }
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let no_routes = observation(vec![(key(KEY_A), runtime_peer(Observed::Unavailable))]);
|
||||
assert!(matches!(
|
||||
compare(&expected, &no_routes),
|
||||
TechnicalMatch::Unverifiable(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_saved_identities_are_unverifiable_even_if_the_map_would_match() {
|
||||
let source = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
|
||||
);
|
||||
let expected = projection(&source);
|
||||
let actual = observation(vec![(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
)]);
|
||||
assert!(matches!(
|
||||
compare(&expected, &actual),
|
||||
TechnicalMatch::Unverifiable(findings)
|
||||
if findings.contains(&ComparisonFinding::DuplicateSavedPublicKey { identity: key(KEY_A) })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_peer_and_interface_states_fail_closed_but_metadata_does_not() {
|
||||
let invalid = projection("[Peer]\nAllowedIPs = 10.0.0.2/32\n");
|
||||
assert!(matches!(
|
||||
compare(&invalid, &observation(Vec::new())),
|
||||
TechnicalMatch::Unverifiable(_)
|
||||
));
|
||||
let valid_with_bad_alias = projection(&format!(
|
||||
"# wireguard-admin:v1 alias=bad%0A date=unknown\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
));
|
||||
assert!(!valid_with_bad_alias.document_unverifiable);
|
||||
|
||||
for interface in [
|
||||
InterfaceObservation::Unknown,
|
||||
InterfaceObservation::Unavailable,
|
||||
InterfaceObservation::Missing,
|
||||
] {
|
||||
let result = compare(
|
||||
&projection(""),
|
||||
&RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface,
|
||||
},
|
||||
);
|
||||
assert!(!matches!(result, TechnicalMatch::Match));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_routes_and_structural_saved_issues_cannot_be_omitted_for_a_match() {
|
||||
let malformed_route = projection(&format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, not-a-network\n"
|
||||
));
|
||||
assert!(!malformed_route.unverifiable_peer_indices.is_empty());
|
||||
let apparent_subset_match = observation(vec![(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
)]);
|
||||
assert!(matches!(
|
||||
compare(&malformed_route, &apparent_subset_match),
|
||||
TechnicalMatch::Unverifiable(findings)
|
||||
if findings.iter().any(|finding| matches!(finding, ComparisonFinding::SavedPeerUnverifiable { .. }))
|
||||
));
|
||||
|
||||
let structural = projection(&format!(
|
||||
"[Interface]\nMalformed Directive\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
));
|
||||
assert!(structural.document_unverifiable);
|
||||
let exact_runtime = observation(vec![(
|
||||
key(KEY_A),
|
||||
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
|
||||
)]);
|
||||
assert!(matches!(
|
||||
compare(&structural, &exact_runtime),
|
||||
TechnicalMatch::Unverifiable(findings)
|
||||
if findings.contains(&ComparisonFinding::SavedDocumentUnverifiable)
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
||||
//! Bounded, read-only Linux Generic Netlink transport for WireGuard.
|
||||
//!
|
||||
//! Replies are received into zeroizing buffers. The datagram sender address
|
||||
//! must be kernel port 0; `nlmsg_pid` in request replies is the requester's
|
||||
//! port ID, as Linux Generic Netlink constructs replies using that destination.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||||
use rustix::fd::OwnedFd;
|
||||
use rustix::io::Errno;
|
||||
use rustix::net::{
|
||||
AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, bind, getsockname,
|
||||
netlink::SocketAddrNetlink, recvfrom, sendto, socket_with,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::netlink::{DecodeContext, DecodeError, DecodeLimits, GetDeviceDecoder};
|
||||
use super::{InterfaceObservation, PeerSetCompleteness, RuntimeObservation, RuntimePeerSet};
|
||||
|
||||
const NETLINK_GENERIC: u32 = 16;
|
||||
const CTRL_FAMILY_ID: u16 = 0x10;
|
||||
const CTRL_CMD_GETFAMILY: u8 = 3;
|
||||
const CTRL_CMD_NEWFAMILY: u8 = 1;
|
||||
const CTRL_ATTR_FAMILY_ID: u16 = 1;
|
||||
const CTRL_ATTR_FAMILY_NAME: u16 = 2;
|
||||
const WG_CMD_GET_DEVICE: u8 = 0;
|
||||
const WG_GENL_VERSION: u8 = 1;
|
||||
const WG_ATTR_IFNAME: u16 = 2;
|
||||
const NLMSG_ERROR: u16 = 2;
|
||||
const NLMSG_DONE: u16 = 3;
|
||||
const NLMSG_REQUEST: u16 = 1;
|
||||
const NLMSG_DUMP: u16 = 0x300;
|
||||
const NLMSG_MULTI: u16 = 2;
|
||||
const MAX_DATAGRAM: usize = 1024 * 1024;
|
||||
const MAX_INTERFACE_NAME: usize = 15;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum TransportError {
|
||||
#[error("configured WireGuard interface was not found")]
|
||||
NoDevice,
|
||||
#[error("permission denied while reading WireGuard state")]
|
||||
PermissionDenied,
|
||||
#[error("WireGuard netlink response was malformed or incomplete")]
|
||||
Malformed,
|
||||
#[error("WireGuard netlink transport failed")]
|
||||
Transport,
|
||||
#[error("WireGuard netlink request timed out")]
|
||||
Timeout,
|
||||
#[error("WireGuard netlink response exceeded configured limits")]
|
||||
LimitExceeded,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Datagram {
|
||||
bytes: Zeroizing<Vec<u8>>,
|
||||
sender: u32,
|
||||
}
|
||||
|
||||
trait Io {
|
||||
fn now(&self) -> Duration;
|
||||
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError>;
|
||||
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError>;
|
||||
fn port_id(&self) -> u32;
|
||||
}
|
||||
|
||||
struct LinuxIo {
|
||||
socket: OwnedFd,
|
||||
port_id: u32,
|
||||
origin: Instant,
|
||||
}
|
||||
|
||||
impl LinuxIo {
|
||||
fn new() -> Result<Self, TransportError> {
|
||||
let socket = socket_with(
|
||||
AddressFamily::NETLINK,
|
||||
SocketType::DGRAM,
|
||||
SocketFlags::CLOEXEC.union(SocketFlags::NONBLOCK),
|
||||
Some(rustix::net::netlink::GENERIC),
|
||||
)
|
||||
.map_err(map_errno)?;
|
||||
bind(&socket, &SocketAddrNetlink::new(0, 0)).map_err(map_errno)?;
|
||||
let addr = SocketAddrNetlink::try_from(getsockname(&socket).map_err(map_errno)?)
|
||||
.map_err(|_| TransportError::Transport)?;
|
||||
Ok(Self {
|
||||
socket,
|
||||
port_id: addr.pid(),
|
||||
origin: Instant::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Io for LinuxIo {
|
||||
fn now(&self) -> Duration {
|
||||
self.origin.elapsed()
|
||||
}
|
||||
|
||||
fn port_id(&self) -> u32 {
|
||||
self.port_id
|
||||
}
|
||||
|
||||
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
|
||||
let sent = sendto(
|
||||
&self.socket,
|
||||
bytes,
|
||||
SendFlags::empty(),
|
||||
&SocketAddrNetlink::new(destination, 0),
|
||||
)
|
||||
.map_err(map_errno)?;
|
||||
if sent == bytes.len() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TransportError::Transport)
|
||||
}
|
||||
}
|
||||
|
||||
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
|
||||
let timeout = Timespec {
|
||||
tv_sec: wait.as_secs().min(i64::MAX as u64) as i64,
|
||||
tv_nsec: wait.subsec_nanos() as i64,
|
||||
};
|
||||
let mut fds = [PollFd::new(&self.socket, PollFlags::IN)];
|
||||
if poll(&mut fds, Some(&timeout)).map_err(map_errno)? == 0 {
|
||||
return Err(TransportError::Timeout);
|
||||
}
|
||||
let mut bytes = Zeroizing::new(vec![0; MAX_DATAGRAM]);
|
||||
let (_, count, address) = recvfrom(
|
||||
&self.socket,
|
||||
&mut bytes[..],
|
||||
RecvFlags::TRUNC.union(RecvFlags::DONTWAIT),
|
||||
)
|
||||
.map_err(map_errno)?;
|
||||
if count > MAX_DATAGRAM {
|
||||
return Err(TransportError::LimitExceeded);
|
||||
}
|
||||
let sender = address
|
||||
.and_then(|a| SocketAddrNetlink::try_from(a).ok())
|
||||
.ok_or(TransportError::Malformed)?
|
||||
.pid();
|
||||
bytes.truncate(count);
|
||||
Ok(Datagram { bytes, sender })
|
||||
}
|
||||
}
|
||||
|
||||
fn map_errno(error: Errno) -> TransportError {
|
||||
if error == Errno::ACCESS || error == Errno::PERM {
|
||||
TransportError::PermissionDenied
|
||||
} else if error == Errno::NOENT || error == Errno::NODEV {
|
||||
TransportError::NoDevice
|
||||
} else if error == Errno::TIMEDOUT {
|
||||
TransportError::Timeout
|
||||
} else {
|
||||
TransportError::Transport
|
||||
}
|
||||
}
|
||||
|
||||
fn map_kernel_error(error: i32) -> TransportError {
|
||||
if error == Errno::ACCESS.raw_os_error() || error == Errno::PERM.raw_os_error() {
|
||||
TransportError::PermissionDenied
|
||||
} else if error == Errno::NOENT.raw_os_error() || error == Errno::NODEV.raw_os_error() {
|
||||
TransportError::NoDevice
|
||||
} else {
|
||||
TransportError::Transport
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observe_interface(
|
||||
interface: &str,
|
||||
timeout: Duration,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<RuntimeObservation, TransportError> {
|
||||
let mut io = LinuxIo::new()?;
|
||||
observe_with_io(&mut io, interface, timeout, limits)
|
||||
}
|
||||
|
||||
fn observe_with_io(
|
||||
io: &mut impl Io,
|
||||
interface: &str,
|
||||
timeout: Duration,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<RuntimeObservation, TransportError> {
|
||||
if interface.is_empty()
|
||||
|| interface.len() > MAX_INTERFACE_NAME
|
||||
|| interface.as_bytes().contains(&0)
|
||||
|| timeout.is_zero()
|
||||
{
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
let start = io.now();
|
||||
let deadline = start.checked_add(timeout).ok_or(TransportError::Timeout)?;
|
||||
let family = resolve_family(io, deadline)?;
|
||||
let seq = 2;
|
||||
let request = get_device_request(family, seq, io.port_id(), interface);
|
||||
io.send(0, &request)?;
|
||||
let context = DecodeContext {
|
||||
sequence: seq,
|
||||
port_id: io.port_id(),
|
||||
family_id: family,
|
||||
};
|
||||
let mut decoder = GetDeviceDecoder::new(context, interface, limits);
|
||||
loop {
|
||||
let datagram = receive_before(io, deadline)?;
|
||||
if datagram.sender != 0 {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
decoder.push_datagram(&datagram.bytes).map_err(map_decode)?;
|
||||
// Decoder only returns a device after its explicit terminal DONE.
|
||||
if decoder.is_done() {
|
||||
let device = decoder.finish().map_err(map_decode)?;
|
||||
return Ok(RuntimeObservation {
|
||||
observed_at: None,
|
||||
interface: InterfaceObservation::Present {
|
||||
name: device.interface_name,
|
||||
peers: RuntimePeerSet::from_records(
|
||||
PeerSetCompleteness::Complete,
|
||||
device.peers,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_before(io: &mut impl Io, deadline: Duration) -> Result<Datagram, TransportError> {
|
||||
let remaining = deadline
|
||||
.checked_sub(io.now())
|
||||
.ok_or(TransportError::Timeout)?;
|
||||
if remaining.is_zero() {
|
||||
return Err(TransportError::Timeout);
|
||||
}
|
||||
io.receive(remaining)
|
||||
}
|
||||
|
||||
fn resolve_family(io: &mut impl Io, deadline: Duration) -> Result<u16, TransportError> {
|
||||
let seq = 1;
|
||||
let mut request = message(CTRL_FAMILY_ID, NLMSG_REQUEST, seq, io.port_id());
|
||||
request.extend_from_slice(&[CTRL_CMD_GETFAMILY, 1, 0, 0]);
|
||||
push_attr(&mut request, CTRL_ATTR_FAMILY_NAME, b"wireguard\0");
|
||||
set_len(&mut request);
|
||||
io.send(0, &request)?;
|
||||
let d = receive_before(io, deadline)?;
|
||||
if d.sender != 0 {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
parse_family(&d.bytes, seq, io.port_id())
|
||||
}
|
||||
|
||||
fn parse_family(bytes: &[u8], seq: u32, port: u32) -> Result<u16, TransportError> {
|
||||
if bytes.len() < 20 {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
if read_u32(bytes, 0)? as usize != bytes.len()
|
||||
|| read_u32(bytes, 8)? != seq
|
||||
|| read_u32(bytes, 12)? != port
|
||||
{
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
let message_type = read_u16(bytes, 4)?;
|
||||
if message_type == NLMSG_ERROR {
|
||||
let status = read_u32(bytes, 16)? as i32;
|
||||
if status == 0 {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
return Err(status
|
||||
.checked_neg()
|
||||
.map(map_kernel_error)
|
||||
.ok_or(TransportError::Malformed)?);
|
||||
}
|
||||
if message_type != CTRL_FAMILY_ID {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
if bytes[16] != CTRL_CMD_NEWFAMILY || read_u16(bytes, 18)? != 0 {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
let mut off = 20;
|
||||
while off < bytes.len() {
|
||||
if bytes.len() - off < 4 {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
let len = read_u16(bytes, off)? as usize;
|
||||
let kind = read_u16(bytes, off + 2)? & 0x3fff;
|
||||
if len < 4 || off + len > bytes.len() {
|
||||
return Err(TransportError::Malformed);
|
||||
}
|
||||
if kind == CTRL_ATTR_FAMILY_ID && len == 6 {
|
||||
return read_u16(bytes, off + 4);
|
||||
}
|
||||
let aligned_len = len.checked_add(3).ok_or(TransportError::Malformed)? & !3;
|
||||
off = off
|
||||
.checked_add(aligned_len)
|
||||
.filter(|&end| end <= bytes.len())
|
||||
.ok_or(TransportError::Malformed)?;
|
||||
}
|
||||
Err(TransportError::NoDevice)
|
||||
}
|
||||
|
||||
fn get_device_request(family: u16, seq: u32, port: u32, interface: &str) -> Vec<u8> {
|
||||
let mut out = message(family, NLMSG_REQUEST | NLMSG_DUMP, seq, port);
|
||||
out.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
|
||||
let mut interface_name = interface.as_bytes().to_vec();
|
||||
interface_name.push(0);
|
||||
push_attr(&mut out, WG_ATTR_IFNAME, &interface_name);
|
||||
set_len(&mut out);
|
||||
out
|
||||
}
|
||||
fn message(kind: u16, flags: u16, seq: u32, port: u32) -> Vec<u8> {
|
||||
let mut out = vec![0; 16];
|
||||
out[4..6].copy_from_slice(&kind.to_ne_bytes());
|
||||
out[6..8].copy_from_slice(&flags.to_ne_bytes());
|
||||
out[8..12].copy_from_slice(&seq.to_ne_bytes());
|
||||
out[12..16].copy_from_slice(&port.to_ne_bytes());
|
||||
out
|
||||
}
|
||||
fn push_attr(out: &mut Vec<u8>, kind: u16, value: &[u8]) {
|
||||
let len = (4 + value.len()) as u16;
|
||||
out.extend_from_slice(&len.to_ne_bytes());
|
||||
out.extend_from_slice(&kind.to_ne_bytes());
|
||||
out.extend_from_slice(value);
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
fn set_len(out: &mut [u8]) {
|
||||
let len = out.len() as u32;
|
||||
out[..4].copy_from_slice(&len.to_ne_bytes());
|
||||
}
|
||||
fn read_u16(bytes: &[u8], at: usize) -> Result<u16, TransportError> {
|
||||
bytes
|
||||
.get(at..at + 2)
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.map(u16::from_ne_bytes)
|
||||
.ok_or(TransportError::Malformed)
|
||||
}
|
||||
fn read_u32(bytes: &[u8], at: usize) -> Result<u32, TransportError> {
|
||||
bytes
|
||||
.get(at..at + 4)
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.map(u32::from_ne_bytes)
|
||||
.ok_or(TransportError::Malformed)
|
||||
}
|
||||
fn map_decode(error: DecodeError) -> TransportError {
|
||||
match error {
|
||||
DecodeError::KernelError(n) if n == Errno::NODEV.raw_os_error() => TransportError::NoDevice,
|
||||
DecodeError::KernelError(n)
|
||||
if n == Errno::ACCESS.raw_os_error() || n == Errno::PERM.raw_os_error() =>
|
||||
{
|
||||
TransportError::PermissionDenied
|
||||
}
|
||||
DecodeError::LimitExceeded => TransportError::LimitExceeded,
|
||||
DecodeError::KernelError(_) | DecodeError::DumpFailed => TransportError::Transport,
|
||||
_ => TransportError::Malformed,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctrl_family_message(sequence: u32, port: u32, family: u16) -> Vec<u8> {
|
||||
let mut out = message(CTRL_FAMILY_ID, 0, sequence, port);
|
||||
out.extend_from_slice(&[CTRL_CMD_NEWFAMILY, 2, 0, 0]);
|
||||
push_attr(&mut out, CTRL_ATTR_FAMILY_ID, &family.to_ne_bytes());
|
||||
set_len(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_family_only_from_matching_kernel_reply() {
|
||||
assert_eq!(parse_family(&ctrl_family_message(1, 55, 42), 1, 55), Ok(42));
|
||||
assert_eq!(
|
||||
parse_family(&ctrl_family_message(2, 55, 42), 1, 55),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_family(&ctrl_family_message(1, 56, 42), 1, 55),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
}
|
||||
|
||||
fn netlink_error(sequence: u32, port: u32, status: i32) -> Vec<u8> {
|
||||
let mut out = message(NLMSG_ERROR, 0, sequence, port);
|
||||
out.extend_from_slice(&status.to_ne_bytes());
|
||||
set_len(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_errors_map_kernel_errno_after_validating_reply_identity() {
|
||||
for errno in [Errno::NOENT, Errno::NODEV] {
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
|
||||
Err(TransportError::NoDevice)
|
||||
);
|
||||
}
|
||||
for errno in [Errno::ACCESS, Errno::PERM] {
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
|
||||
Err(TransportError::PermissionDenied)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(1, 55, -Errno::IO.raw_os_error()), 1, 55),
|
||||
Err(TransportError::Transport)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(1, 55, i32::MIN), 1, 55),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(1, 55, 0), 1, 55),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(2, 55, -Errno::NOENT.raw_os_error()), 1, 55),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_family(&netlink_error(1, 56, -Errno::NOENT.raw_os_error()), 1, 55),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_family(
|
||||
&netlink_error(1, 55, -Errno::NOENT.raw_os_error())[..19],
|
||||
1,
|
||||
55
|
||||
),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_resolution_propagates_kernel_errors_from_injected_transport() {
|
||||
for (errno, expected) in [
|
||||
(Errno::NODEV, TransportError::NoDevice),
|
||||
(Errno::ACCESS, TransportError::PermissionDenied),
|
||||
(Errno::IO, TransportError::Transport),
|
||||
] {
|
||||
let mut fake = FakeIo {
|
||||
port: 55,
|
||||
..FakeIo::default()
|
||||
};
|
||||
fake.incoming
|
||||
.push_back(datagram(netlink_error(1, 55, -errno.raw_os_error()), 0));
|
||||
assert_eq!(
|
||||
resolve_family(&mut fake, Duration::from_secs(1)),
|
||||
Err(expected)
|
||||
);
|
||||
assert_eq!(fake.sent.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_request_targets_only_named_interface_and_dump_flags() {
|
||||
let request = get_device_request(42, 7, 55, "wg-test");
|
||||
assert_eq!(read_u16(&request, 4), Ok(42));
|
||||
assert_eq!(read_u16(&request, 6), Ok(NLMSG_REQUEST | NLMSG_DUMP));
|
||||
assert_eq!(
|
||||
&request[16..20],
|
||||
&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]
|
||||
);
|
||||
assert_eq!(read_u16(&request, 20), Ok(12));
|
||||
assert_eq!(&request[22..24], &2u16.to_ne_bytes());
|
||||
assert_eq!(&request[24..32], b"wg-test\0");
|
||||
assert_eq!(request.len(), 32);
|
||||
assert_eq!(read_u32(&request, 0), Ok(request.len() as u32));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeIo {
|
||||
clock: Duration,
|
||||
port: u32,
|
||||
sent: Vec<(u32, Vec<u8>)>,
|
||||
incoming: std::collections::VecDeque<Datagram>,
|
||||
}
|
||||
impl Io for FakeIo {
|
||||
fn now(&self) -> Duration {
|
||||
self.clock
|
||||
}
|
||||
fn port_id(&self) -> u32 {
|
||||
self.port
|
||||
}
|
||||
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
|
||||
self.sent.push((destination, bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
|
||||
if let Some(datagram) = self.incoming.pop_front() {
|
||||
Ok(datagram)
|
||||
} else {
|
||||
self.clock += wait;
|
||||
Err(TransportError::Timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn datagram(bytes: Vec<u8>, sender: u32) -> Datagram {
|
||||
Datagram {
|
||||
bytes: Zeroizing::new(bytes),
|
||||
sender,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_transport_rejects_non_kernel_sender_and_bounds_resolution_wait() {
|
||||
let mut fake = FakeIo {
|
||||
port: 55,
|
||||
..FakeIo::default()
|
||||
};
|
||||
fake.incoming
|
||||
.push_back(datagram(ctrl_family_message(1, 55, 42), 22));
|
||||
assert_eq!(
|
||||
resolve_family(&mut fake, Duration::from_secs(1)),
|
||||
Err(TransportError::Malformed)
|
||||
);
|
||||
assert_eq!(fake.sent.len(), 1);
|
||||
assert_eq!(fake.sent[0].0, 0);
|
||||
|
||||
let mut silent = FakeIo {
|
||||
port: 55,
|
||||
..FakeIo::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_family(&mut silent, Duration::from_millis(5)),
|
||||
Err(TransportError::Timeout)
|
||||
);
|
||||
assert_eq!(silent.clock, Duration::from_millis(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_transport_returns_only_a_complete_public_empty_observation() {
|
||||
let mut fake = FakeIo {
|
||||
port: 55,
|
||||
..FakeIo::default()
|
||||
};
|
||||
fake.incoming
|
||||
.push_back(datagram(ctrl_family_message(1, 55, 42), 0));
|
||||
let mut response = message(42, NLMSG_MULTI, 2, 55);
|
||||
response.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
|
||||
push_attr(&mut response, 2, b"wg0\0");
|
||||
push_attr(&mut response, 8 | 0x8000, &[]);
|
||||
set_len(&mut response);
|
||||
let mut complete = response;
|
||||
let done_start = complete.len();
|
||||
complete.extend_from_slice(&message(NLMSG_DONE, 0, 2, 55));
|
||||
complete.extend_from_slice(&0i32.to_ne_bytes());
|
||||
set_len(&mut complete[done_start..]);
|
||||
fake.incoming.push_back(datagram(complete, 0));
|
||||
let observation = observe_with_io(
|
||||
&mut fake,
|
||||
"wg0",
|
||||
Duration::from_secs(2),
|
||||
DecodeLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(observation.interface, InterfaceObservation::Present { ref name, ref peers } if name == "wg0" && peers.completeness == PeerSetCompleteness::Complete && peers.peers.is_empty())
|
||||
);
|
||||
assert_eq!(fake.sent.len(), 2);
|
||||
assert!(fake.sent.iter().all(|(destination, _)| *destination == 0));
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
//! Systemd restart job tracking and bounded D-Bus adapter.
|
||||
//!
|
||||
//! The event tracker is pure. The real D-Bus restart adapter is crate-private;
|
||||
//! callers outside the crate must use the application restart boundary.
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! use wireguard_admin::service::dbus::restart_systemd_unit_report;
|
||||
//! ```
|
||||
|
||||
mod dbus;
|
||||
|
||||
pub(crate) use dbus::restart_systemd_unit_report;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One operation-wide monotonic deadline, created before bus setup begins.
|
||||
///
|
||||
/// The same value should be used for connection, subscription, request,
|
||||
/// waiting, and subsequent runtime verification. A timed-out wait does not
|
||||
/// imply that systemd canceled its job.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RestartDeadline {
|
||||
started_at: Instant,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl RestartDeadline {
|
||||
pub const fn new(started_at: Instant, timeout: Duration) -> Self {
|
||||
Self {
|
||||
started_at,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn started_at(self) -> Instant {
|
||||
self.started_at
|
||||
}
|
||||
|
||||
pub const fn timeout(self) -> Duration {
|
||||
self.timeout
|
||||
}
|
||||
|
||||
/// Remaining time at a monotonic sample. Values sampled before the start
|
||||
/// are treated as the start, which also keeps deterministic callers safe.
|
||||
pub fn remaining_at(self, now: Instant) -> Duration {
|
||||
self.timeout
|
||||
.saturating_sub(now.saturating_duration_since(self.started_at))
|
||||
}
|
||||
|
||||
pub fn is_expired_at(self, now: Instant) -> bool {
|
||||
self.remaining_at(now).is_zero()
|
||||
}
|
||||
}
|
||||
|
||||
/// The only event the tracker needs from systemd's `JobRemoved` signal.
|
||||
/// Unknown systemd result strings should map to [`JobResult::Other`], without
|
||||
/// retaining or surfacing the raw bus value.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum JobResult {
|
||||
Done,
|
||||
Failed,
|
||||
Dependency,
|
||||
Timeout,
|
||||
Canceled,
|
||||
Skipped,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// An event emitted by the D-Bus adapter and consumed by the job tracker.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ServiceEvent {
|
||||
JobRemoved {
|
||||
job_path: String,
|
||||
unit: String,
|
||||
result: JobResult,
|
||||
},
|
||||
Disconnected,
|
||||
ManagerOwnerChanged,
|
||||
SubscriptionFailed,
|
||||
PermissionDenied,
|
||||
RequestFailed,
|
||||
}
|
||||
|
||||
/// Redacted terminal reason. No D-Bus error string or signal payload is kept.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartFailure {
|
||||
JobFailed,
|
||||
JobDependency,
|
||||
JobTimedOut,
|
||||
JobCanceled,
|
||||
JobSkipped,
|
||||
JobResultUnknown,
|
||||
BusDisconnected,
|
||||
ManagerOwnerChanged,
|
||||
SubscriptionFailed,
|
||||
UnitResolutionFailed,
|
||||
PermissionDenied,
|
||||
RequestFailed,
|
||||
WaitTimedOutMayStillComplete,
|
||||
EarlyEventBufferFull,
|
||||
InvalidJobPath,
|
||||
}
|
||||
|
||||
/// Secret-free operation boundary where a restart attempt failed.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartPhase {
|
||||
Connect,
|
||||
Subscribe,
|
||||
ResolveUnit,
|
||||
RequestRestart,
|
||||
WaitForJob,
|
||||
}
|
||||
|
||||
/// Current state of a single restart attempt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartOutcome {
|
||||
Waiting,
|
||||
Succeeded,
|
||||
Failed(RestartFailure),
|
||||
}
|
||||
|
||||
/// Terminal job outcome plus the request-submission boundary. A timeout after
|
||||
/// submission explicitly leaves open the possibility that the job may finish.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RestartExecution {
|
||||
pub outcome: RestartOutcome,
|
||||
pub submitted: bool,
|
||||
pub phase: RestartPhase,
|
||||
}
|
||||
|
||||
/// Pure event tracker for the primary systemd unit resolved from the configured
|
||||
/// target.
|
||||
///
|
||||
/// Signals can arrive before the method reply containing the job path. To
|
||||
/// close that race, up to [`MAX_EARLY_JOB_EVENTS`] events for the configured
|
||||
/// unit are retained until `job_submitted` provides the actual path. Overflow
|
||||
/// fails closed instead of silently dropping a potentially relevant event.
|
||||
pub struct RestartJobTracker {
|
||||
configured_unit: String,
|
||||
deadline: RestartDeadline,
|
||||
submitted_job_path: Option<String>,
|
||||
early_events: Vec<(String, JobResult)>,
|
||||
outcome: RestartOutcome,
|
||||
}
|
||||
|
||||
/// Bound for `JobRemoved` events received before the systemd method reply.
|
||||
pub const MAX_EARLY_JOB_EVENTS: usize = 64;
|
||||
|
||||
impl RestartJobTracker {
|
||||
pub fn new(configured_unit: impl Into<String>, deadline: RestartDeadline) -> Self {
|
||||
Self {
|
||||
configured_unit: configured_unit.into(),
|
||||
deadline,
|
||||
submitted_job_path: None,
|
||||
early_events: Vec::new(),
|
||||
outcome: RestartOutcome::Waiting,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configured_unit(&self) -> &str {
|
||||
&self.configured_unit
|
||||
}
|
||||
|
||||
pub const fn deadline(&self) -> RestartDeadline {
|
||||
self.deadline
|
||||
}
|
||||
|
||||
pub fn submitted_job_path(&self) -> Option<&str> {
|
||||
self.submitted_job_path.as_deref()
|
||||
}
|
||||
|
||||
pub const fn outcome(&self) -> RestartOutcome {
|
||||
self.outcome
|
||||
}
|
||||
|
||||
/// Record the actual object path returned by systemd's restart request.
|
||||
/// Any earlier matching-unit event is reconciled against that path.
|
||||
pub fn job_submitted(&mut self, job_path: impl Into<String>, now: Instant) -> RestartOutcome {
|
||||
if self.outcome != RestartOutcome::Waiting {
|
||||
return self.outcome;
|
||||
}
|
||||
if self.expire_if_needed(now) {
|
||||
return self.outcome;
|
||||
}
|
||||
|
||||
let job_path = job_path.into();
|
||||
if job_path.is_empty() {
|
||||
self.fail(RestartFailure::InvalidJobPath);
|
||||
return self.outcome;
|
||||
}
|
||||
if self.submitted_job_path.is_some() {
|
||||
self.fail(RestartFailure::RequestFailed);
|
||||
return self.outcome;
|
||||
}
|
||||
self.submitted_job_path = Some(job_path.clone());
|
||||
|
||||
for (event_path, result) in std::mem::take(&mut self.early_events) {
|
||||
if event_path == job_path {
|
||||
self.finish_job(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.outcome
|
||||
}
|
||||
|
||||
/// Apply one event using a caller-supplied monotonic time sample.
|
||||
pub fn observe(&mut self, event: ServiceEvent, now: Instant) -> RestartOutcome {
|
||||
if self.outcome != RestartOutcome::Waiting {
|
||||
return self.outcome;
|
||||
}
|
||||
if self.expire_if_needed(now) {
|
||||
return self.outcome;
|
||||
}
|
||||
|
||||
match event {
|
||||
ServiceEvent::JobRemoved {
|
||||
job_path,
|
||||
unit,
|
||||
result,
|
||||
} => {
|
||||
if unit != self.configured_unit || job_path.is_empty() {
|
||||
return self.outcome;
|
||||
}
|
||||
if let Some(submitted_path) = &self.submitted_job_path {
|
||||
if &job_path == submitted_path {
|
||||
self.finish_job(result);
|
||||
}
|
||||
} else if self.early_events.len() == MAX_EARLY_JOB_EVENTS {
|
||||
self.fail(RestartFailure::EarlyEventBufferFull);
|
||||
} else {
|
||||
self.early_events.push((job_path, result));
|
||||
}
|
||||
}
|
||||
ServiceEvent::Disconnected => self.fail(RestartFailure::BusDisconnected),
|
||||
ServiceEvent::ManagerOwnerChanged => self.fail(RestartFailure::ManagerOwnerChanged),
|
||||
ServiceEvent::SubscriptionFailed => self.fail(RestartFailure::SubscriptionFailed),
|
||||
ServiceEvent::PermissionDenied => self.fail(RestartFailure::PermissionDenied),
|
||||
ServiceEvent::RequestFailed => self.fail(RestartFailure::RequestFailed),
|
||||
}
|
||||
self.outcome
|
||||
}
|
||||
|
||||
/// Check the operation deadline even when no event arrives.
|
||||
pub fn poll(&mut self, now: Instant) -> RestartOutcome {
|
||||
self.expire_if_needed(now);
|
||||
self.outcome
|
||||
}
|
||||
|
||||
fn expire_if_needed(&mut self, now: Instant) -> bool {
|
||||
if self.deadline.is_expired_at(now) {
|
||||
self.fail(RestartFailure::WaitTimedOutMayStillComplete);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_job(&mut self, result: JobResult) {
|
||||
self.outcome = match result {
|
||||
JobResult::Done => RestartOutcome::Succeeded,
|
||||
JobResult::Failed => RestartOutcome::Failed(RestartFailure::JobFailed),
|
||||
JobResult::Dependency => RestartOutcome::Failed(RestartFailure::JobDependency),
|
||||
JobResult::Timeout => RestartOutcome::Failed(RestartFailure::JobTimedOut),
|
||||
JobResult::Canceled => RestartOutcome::Failed(RestartFailure::JobCanceled),
|
||||
JobResult::Skipped => RestartOutcome::Failed(RestartFailure::JobSkipped),
|
||||
JobResult::Other => RestartOutcome::Failed(RestartFailure::JobResultUnknown),
|
||||
};
|
||||
}
|
||||
|
||||
fn fail(&mut self, failure: RestartFailure) {
|
||||
self.outcome = RestartOutcome::Failed(failure);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (Instant, RestartJobTracker) {
|
||||
let start = Instant::now();
|
||||
let deadline = RestartDeadline::new(start, Duration::from_secs(5));
|
||||
(
|
||||
start,
|
||||
RestartJobTracker::new("wg-quick@wg0.service", deadline),
|
||||
)
|
||||
}
|
||||
|
||||
fn removed(path: &str, unit: &str, result: JobResult) -> ServiceEvent {
|
||||
ServiceEvent::JobRemoved {
|
||||
job_path: path.to_owned(),
|
||||
unit: unit.to_owned(),
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffers_fast_completion_until_actual_job_path_is_returned() {
|
||||
let (start, mut tracker) = setup();
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed(
|
||||
"/org/freedesktop/systemd1/job/41",
|
||||
"wg-quick@wg0.service",
|
||||
JobResult::Done
|
||||
),
|
||||
start + Duration::from_millis(1),
|
||||
),
|
||||
RestartOutcome::Waiting
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.job_submitted(
|
||||
"/org/freedesktop/systemd1/job/41",
|
||||
start + Duration::from_millis(2),
|
||||
),
|
||||
RestartOutcome::Succeeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_jobs_and_units_do_not_finish_the_attempt() {
|
||||
let (start, mut tracker) = setup();
|
||||
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
|
||||
for event in [
|
||||
removed("/job/other", "wg-quick@wg0.service", JobResult::Done),
|
||||
removed("/job/actual", "wg-quick@wg1.service", JobResult::Done),
|
||||
] {
|
||||
assert_eq!(
|
||||
tracker.observe(event, start + Duration::from_millis(2)),
|
||||
RestartOutcome::Waiting
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_alias_matches_job_removed_primary_unit_name() {
|
||||
let start = Instant::now();
|
||||
let deadline = RestartDeadline::new(start, Duration::from_secs(5));
|
||||
// The adapter resolves this primary ID from configured alias
|
||||
// "wireguard-vpn.service" before constructing the tracker.
|
||||
let mut tracker = RestartJobTracker::new("wg-quick@wg0.service", deadline);
|
||||
tracker.job_submitted("/job/alias-target", start + Duration::from_millis(1));
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed("/job/alias-target", "wg-quick@wg0.service", JobResult::Done,),
|
||||
start + Duration::from_millis(2),
|
||||
),
|
||||
RestartOutcome::Succeeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_done_job_result_is_success() {
|
||||
for (job_result, failure) in [
|
||||
(JobResult::Failed, RestartFailure::JobFailed),
|
||||
(JobResult::Dependency, RestartFailure::JobDependency),
|
||||
(JobResult::Timeout, RestartFailure::JobTimedOut),
|
||||
(JobResult::Canceled, RestartFailure::JobCanceled),
|
||||
(JobResult::Skipped, RestartFailure::JobSkipped),
|
||||
(JobResult::Other, RestartFailure::JobResultUnknown),
|
||||
] {
|
||||
let (start, mut tracker) = setup();
|
||||
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed("/job/actual", "wg-quick@wg0.service", job_result),
|
||||
start + Duration::from_millis(2),
|
||||
),
|
||||
RestartOutcome::Failed(failure)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_for_same_unit_are_still_correlated_by_returned_path() {
|
||||
let (start, mut tracker) = setup();
|
||||
tracker.observe(
|
||||
removed("/job/unrelated", "wg-quick@wg0.service", JobResult::Failed),
|
||||
start + Duration::from_millis(1),
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.job_submitted("/job/actual", start + Duration::from_millis(2)),
|
||||
RestartOutcome::Waiting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemd_oneshot_success_needs_no_pid_or_active_state() {
|
||||
let (start, mut tracker) = setup();
|
||||
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed("/job/actual", "wg-quick@wg0.service", JobResult::Done),
|
||||
start + Duration::from_millis(2),
|
||||
),
|
||||
RestartOutcome::Succeeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_deadline_is_shared_and_timeout_does_not_claim_cancellation() {
|
||||
let (start, mut tracker) = setup();
|
||||
let deadline = tracker.deadline();
|
||||
assert_eq!(
|
||||
deadline.remaining_at(start + Duration::from_secs(2)),
|
||||
Duration::from_secs(3)
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.poll(start + Duration::from_secs(5)),
|
||||
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_and_request_failures_are_redacted_terminal_outcomes() {
|
||||
for (event, failure) in [
|
||||
(ServiceEvent::Disconnected, RestartFailure::BusDisconnected),
|
||||
(
|
||||
ServiceEvent::ManagerOwnerChanged,
|
||||
RestartFailure::ManagerOwnerChanged,
|
||||
),
|
||||
(
|
||||
ServiceEvent::SubscriptionFailed,
|
||||
RestartFailure::SubscriptionFailed,
|
||||
),
|
||||
(
|
||||
ServiceEvent::PermissionDenied,
|
||||
RestartFailure::PermissionDenied,
|
||||
),
|
||||
(ServiceEvent::RequestFailed, RestartFailure::RequestFailed),
|
||||
] {
|
||||
let (start, mut tracker) = setup();
|
||||
assert_eq!(
|
||||
tracker.observe(event, start + Duration::from_millis(1)),
|
||||
RestartOutcome::Failed(failure)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_event_buffer_overflow_fails_closed() {
|
||||
let (start, mut tracker) = setup();
|
||||
for index in 0..MAX_EARLY_JOB_EVENTS {
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed(
|
||||
&format!("/job/{index}"),
|
||||
"wg-quick@wg0.service",
|
||||
JobResult::Done,
|
||||
),
|
||||
start + Duration::from_millis(1),
|
||||
),
|
||||
RestartOutcome::Waiting
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed("/job/overflow", "wg-quick@wg0.service", JobResult::Done),
|
||||
start + Duration::from_millis(2),
|
||||
),
|
||||
RestartOutcome::Failed(RestartFailure::EarlyEventBufferFull)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_outcome_cannot_be_replaced_by_later_events() {
|
||||
let (start, mut tracker) = setup();
|
||||
tracker.job_submitted("/job/actual", start + Duration::from_millis(1));
|
||||
tracker.observe(
|
||||
removed("/job/actual", "wg-quick@wg0.service", JobResult::Failed),
|
||||
start + Duration::from_millis(2),
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.observe(
|
||||
removed("/job/actual", "wg-quick@wg0.service", JobResult::Done),
|
||||
start + Duration::from_millis(3),
|
||||
),
|
||||
RestartOutcome::Failed(RestartFailure::JobFailed)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1282
File diff suppressed because it is too large
Load Diff
+226
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
Management,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum StartupError {
|
||||
UnknownArguments,
|
||||
RootRequired,
|
||||
}
|
||||
|
||||
impl fmt::Display for StartupError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnknownArguments => write!(formatter, "unknown or unsupported arguments"),
|
||||
Self::RootRequired => write!(formatter, "management operations require root"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// root. Help, version, and invalid arguments do not query process privileges.
|
||||
pub fn start(
|
||||
arguments: &[OsString],
|
||||
effective_uid_is_root: impl FnOnce() -> bool,
|
||||
) -> Result<StartupAction, StartupError> {
|
||||
match arguments {
|
||||
[] => {
|
||||
if effective_uid_is_root() {
|
||||
Ok(StartupAction::Management)
|
||||
} else {
|
||||
Err(StartupError::RootRequired)
|
||||
}
|
||||
}
|
||||
[argument] if argument == "--help" => Ok(StartupAction::Help),
|
||||
[argument] if argument == "--version" => Ok(StartupAction::Version),
|
||||
_ => Err(StartupError::UnknownArguments),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StartupAction, StartupError, start};
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[test]
|
||||
fn help_and_version_do_not_query_root() {
|
||||
for (argument, expected) in [
|
||||
("--help", StartupAction::Help),
|
||||
("--version", StartupAction::Version),
|
||||
] {
|
||||
let result = start(&[OsString::from(argument)], || {
|
||||
panic!("root check must not run for {argument}")
|
||||
});
|
||||
assert_eq!(result, Ok(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_root_is_rejected_before_management_startup() {
|
||||
let result = start(&[], || false);
|
||||
assert_eq!(result, Err(StartupError::RootRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_management_startup_is_authorized_for_terminal_boundary() {
|
||||
let result = start(&[], || true);
|
||||
assert_eq!(result, Ok(StartupAction::Management));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_or_extra_arguments_fail_without_root_check() {
|
||||
for arguments in [
|
||||
vec![OsString::from("--settings")],
|
||||
vec![OsString::from("--help"), OsString::from("unexpected")],
|
||||
] {
|
||||
let result = start(&arguments, || {
|
||||
panic!("root check must not run for invalid arguments")
|
||||
});
|
||||
assert_eq!(result, Err(StartupError::UnknownArguments));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_session_checks_root_before_reading_settings() {
|
||||
let result = super::open_management_session(
|
||||
std::path::Path::new("/path/that/does/not/exist"),
|
||||
|| false,
|
||||
);
|
||||
assert_eq!(result.err(), Some(super::SessionLoadError::RootRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_session_loads_supported_state_and_keeps_unsupported_peers_visible() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"wireguard-admin-startup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
std::fs::write(
|
||||
directory.join(crate::config::SETTINGS_FILE_NAME),
|
||||
r#"interface_name = "wg0"
|
||||
server_config_path = "wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.org:51820"
|
||||
ipv4_allocation_range = "10.80.0.0/24"
|
||||
ipv6_allocation_range = "fd80::/64"
|
||||
dns_addresses = ["10.80.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 10
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let server_path = directory.join("wg0.conf");
|
||||
std::fs::write(
|
||||
&server_path,
|
||||
b"[Interface]\nAddress = 10.80.0.1/24\n# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xlYW4\n# wg-admin:end\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.80.0.2/32, fd80::2/128\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.80.0.0/24\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&server_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
let app = super::open_management_session(&directory, || true)
|
||||
.expect("valid synthetic installation should open");
|
||||
let projection = app.display_projection();
|
||||
assert_eq!(projection.clients.len(), 2);
|
||||
assert_eq!(app.states().saved().clients().count(), 1);
|
||||
assert!(projection.clients[0].mutable_identity.is_some());
|
||||
assert!(projection.clients[1].mutable_identity.is_none());
|
||||
assert_eq!(projection.clients[1].allowed_ips, ["10.80.0.0/24"]);
|
||||
assert!(!format!("{projection:?}").contains("PrivateKey"));
|
||||
let internal_state = directory.join("Backup/data");
|
||||
std::fs::create_dir_all(&internal_state).unwrap();
|
||||
std::fs::write(
|
||||
internal_state.join("internal-state.toml"),
|
||||
b"not valid state",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
super::open_management_session(&directory, || true).err(),
|
||||
Some(super::SessionLoadError::SavedStateInvalid),
|
||||
"corrupt persisted state must stop session loading rather than start empty"
|
||||
);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//! Native terminal workflow state and the serialized application worker.
|
||||
//!
|
||||
//! Terminal input/rendering adapters are added in the subsequent M7 tasks.
|
||||
|
||||
pub mod events;
|
||||
pub mod forms;
|
||||
pub mod render;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
|
||||
pub use events::{ApplicationWorker, UiCommand, WorkerConfig, WorkerEvent};
|
||||
pub use state::{SessionView, UiState};
|
||||
+1145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
use super::{
|
||||
CommandResult, DeliveryDestinationSummary, FirstIssueAttempt, FirstIssueStatus,
|
||||
FirstIssueSummary, OperationError, OperationExecutor, QrStatus, ReissueSummary,
|
||||
RestartPreviewSummary, RestorePreviewSummary, SensitiveCommand, UiCommand,
|
||||
};
|
||||
use crate::app::{Application, FirstIssueDeliveryOutcome, RestorePreview};
|
||||
use crate::delivery::files::PreparedDestination;
|
||||
use crate::delivery::{QrCodeOutput, QrError};
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
enum DeliveryMode {
|
||||
FirstIssue,
|
||||
Reissue,
|
||||
}
|
||||
|
||||
struct PreparedDestinationContext {
|
||||
identity: PublicKey,
|
||||
include_qr: bool,
|
||||
mode: DeliveryMode,
|
||||
attempt: FirstIssueAttempt,
|
||||
generation: u64,
|
||||
destination: PreparedDestination,
|
||||
}
|
||||
|
||||
struct RestorePreviewContext {
|
||||
_archive_name: String,
|
||||
generation: u64,
|
||||
preview: RestorePreview,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ApplicationExecutor {
|
||||
prepared_destination: Option<PreparedDestinationContext>,
|
||||
restore_preview: Option<RestorePreviewContext>,
|
||||
}
|
||||
|
||||
impl OperationExecutor for ApplicationExecutor {
|
||||
fn execute(
|
||||
&mut self,
|
||||
app: &mut Application,
|
||||
command: UiCommand,
|
||||
) -> Result<CommandResult, OperationError> {
|
||||
self.execute_at(app, command, 0)
|
||||
}
|
||||
|
||||
fn execute_at(
|
||||
&mut self,
|
||||
app: &mut Application,
|
||||
command: UiCommand,
|
||||
generation: u64,
|
||||
) -> Result<CommandResult, OperationError> {
|
||||
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
|
||||
let restore_continuation = matches!(
|
||||
command,
|
||||
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
|
||||
);
|
||||
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
|
||||
self.prepared_destination = None;
|
||||
}
|
||||
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
|
||||
self.restore_preview = None;
|
||||
}
|
||||
let result = match command {
|
||||
UiCommand::CreateClient {
|
||||
alias,
|
||||
manual_addresses,
|
||||
created_utc,
|
||||
} => app
|
||||
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
|
||||
.map(CommandResult::ClientCreated),
|
||||
UiCommand::RenameClient { identity, alias } => app
|
||||
.rename_client(identity, &alias)
|
||||
.map(|()| CommandResult::ClientRenamed),
|
||||
UiCommand::DeleteClient { identity } => app
|
||||
.delete_client(identity)
|
||||
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
|
||||
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
|
||||
UiCommand::Commit => app.commit().map(CommandResult::Committed),
|
||||
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
|
||||
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
|
||||
UiCommand::ImportSelectedFile { file_name } => app
|
||||
.import_selected_file(&file_name)
|
||||
.map(CommandResult::SelectedFileImported),
|
||||
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
|
||||
CommandResult::RestartPreview(RestartPreviewSummary {
|
||||
has_draft: preview.has_draft(),
|
||||
interface: preview.interface().to_owned(),
|
||||
systemd_unit: preview.systemd_unit().to_owned(),
|
||||
})
|
||||
}),
|
||||
UiCommand::Restart(choice) => {
|
||||
let result = app
|
||||
.preview_restart()
|
||||
.map(|preview| app.restart(preview, choice));
|
||||
result.map(CommandResult::Restarted)
|
||||
}
|
||||
UiCommand::PrepareDelivery {
|
||||
identity,
|
||||
path,
|
||||
include_qr,
|
||||
reissue,
|
||||
attempt,
|
||||
} => app
|
||||
.prepare_first_issue_destination(path)
|
||||
.map_err(crate::app::AppError::FileOutput)
|
||||
.map(|destination| {
|
||||
let summary = DeliveryDestinationSummary {
|
||||
identity,
|
||||
path: destination.path().to_path_buf(),
|
||||
overwrite_required: destination.overwrite_confirmation_required(),
|
||||
include_qr,
|
||||
reissue,
|
||||
attempt,
|
||||
prepared_generation: generation,
|
||||
};
|
||||
self.prepared_destination = Some(PreparedDestinationContext {
|
||||
identity,
|
||||
include_qr,
|
||||
mode: if reissue {
|
||||
DeliveryMode::Reissue
|
||||
} else {
|
||||
DeliveryMode::FirstIssue
|
||||
},
|
||||
attempt,
|
||||
generation,
|
||||
destination,
|
||||
});
|
||||
CommandResult::DeliveryDestination(summary)
|
||||
}),
|
||||
UiCommand::DeliverFirstIssue {
|
||||
attempt,
|
||||
overwrite_confirmed,
|
||||
prepared_generation,
|
||||
} => {
|
||||
let prepared = self
|
||||
.prepared_destination
|
||||
.take()
|
||||
.ok_or_else(|| OperationError {
|
||||
message: "No prepared output destination is available.".to_owned(),
|
||||
})?;
|
||||
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|
||||
|| prepared.attempt != attempt
|
||||
|| !destination_generation_matches(
|
||||
prepared.generation,
|
||||
prepared_generation,
|
||||
generation,
|
||||
)
|
||||
{
|
||||
return Err(OperationError {
|
||||
message: "Prepared output destination is stale; select it again."
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
let identity = prepared.identity;
|
||||
let include_qr = prepared.include_qr;
|
||||
let mut destination = prepared.destination;
|
||||
if destination.overwrite_confirmation_required() {
|
||||
if !overwrite_confirmed {
|
||||
return Err(OperationError {
|
||||
message: "Overwrite confirmation was not supplied.".to_owned(),
|
||||
});
|
||||
}
|
||||
destination = destination.confirm_overwrite().map_err(operation_error)?;
|
||||
}
|
||||
if include_qr {
|
||||
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
|
||||
.map(|outcome| match outcome {
|
||||
FirstIssueDeliveryOutcome::Delivered(delivery) => {
|
||||
let (qr, qr_output) = qr_result(delivery.qr);
|
||||
CommandResult::FirstIssue(FirstIssueSummary {
|
||||
identity,
|
||||
status: FirstIssueStatus::Delivered,
|
||||
retry: None,
|
||||
activation_warning: delivery.activation_warning,
|
||||
qr,
|
||||
qr_output,
|
||||
})
|
||||
}
|
||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||
value,
|
||||
..
|
||||
} => {
|
||||
let (qr, qr_output) = qr_result(value.qr);
|
||||
CommandResult::FirstIssue(FirstIssueSummary {
|
||||
identity,
|
||||
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
|
||||
retry: None,
|
||||
activation_warning: value.activation_warning,
|
||||
qr,
|
||||
qr_output,
|
||||
})
|
||||
}
|
||||
FirstIssueDeliveryOutcome::Failed {
|
||||
error,
|
||||
retry,
|
||||
activation_warning,
|
||||
} => CommandResult::FirstIssue(FirstIssueSummary {
|
||||
identity,
|
||||
status: FirstIssueStatus::Failed(error),
|
||||
retry: Some(retry),
|
||||
activation_warning,
|
||||
qr: QrStatus::NotRequested,
|
||||
qr_output: None,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
app.deliver_first_issue_file(identity, attempt, destination)
|
||||
.map(|outcome| match outcome {
|
||||
FirstIssueDeliveryOutcome::Delivered(delivery) => {
|
||||
CommandResult::FirstIssue(FirstIssueSummary {
|
||||
identity,
|
||||
status: FirstIssueStatus::Delivered,
|
||||
retry: None,
|
||||
activation_warning: delivery.activation_warning,
|
||||
qr: QrStatus::NotRequested,
|
||||
qr_output: None,
|
||||
})
|
||||
}
|
||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||
value,
|
||||
..
|
||||
} => CommandResult::FirstIssue(FirstIssueSummary {
|
||||
identity,
|
||||
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
|
||||
retry: None,
|
||||
activation_warning: value.activation_warning,
|
||||
qr: QrStatus::NotRequested,
|
||||
qr_output: None,
|
||||
}),
|
||||
FirstIssueDeliveryOutcome::Failed {
|
||||
error,
|
||||
retry,
|
||||
activation_warning,
|
||||
} => CommandResult::FirstIssue(FirstIssueSummary {
|
||||
identity,
|
||||
status: FirstIssueStatus::Failed(error),
|
||||
retry: Some(retry),
|
||||
activation_warning,
|
||||
qr: QrStatus::NotRequested,
|
||||
qr_output: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
UiCommand::PreviewRestore { file_name } => {
|
||||
app.preview_restore(&file_name).map(|preview| {
|
||||
let changes = preview.change_summary(app.saved_bytes());
|
||||
let summary = RestorePreviewSummary {
|
||||
file_name: file_name.clone(),
|
||||
created_at_utc: preview.created_at_utc().to_owned(),
|
||||
reason: preview.reason(),
|
||||
historical_apply: preview.historical_apply().clone(),
|
||||
changes,
|
||||
preview_generation: generation,
|
||||
};
|
||||
self.restore_preview = Some(RestorePreviewContext {
|
||||
_archive_name: file_name,
|
||||
generation,
|
||||
preview,
|
||||
});
|
||||
CommandResult::RestorePreview(summary)
|
||||
})
|
||||
}
|
||||
UiCommand::CancelPreparedDelivery => {
|
||||
self.prepared_destination = None;
|
||||
Ok(CommandResult::Cancelled)
|
||||
}
|
||||
UiCommand::CancelRestorePreview => {
|
||||
self.restore_preview = None;
|
||||
Ok(CommandResult::Cancelled)
|
||||
}
|
||||
UiCommand::ConfirmRestore { preview_generation } => {
|
||||
let context = self.restore_preview.take().ok_or_else(|| OperationError {
|
||||
message: "Restore preview is no longer available; select the archive again."
|
||||
.to_owned(),
|
||||
})?;
|
||||
if !restore_preview_generation_matches(
|
||||
context.generation,
|
||||
preview_generation,
|
||||
generation,
|
||||
) {
|
||||
return Err(OperationError {
|
||||
message: "Restore preview is stale; select the archive again.".to_owned(),
|
||||
});
|
||||
}
|
||||
app.confirm_restore(context.preview)
|
||||
.map(CommandResult::Restored)
|
||||
}
|
||||
};
|
||||
result.map_err(|error| OperationError {
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_sensitive(
|
||||
&mut self,
|
||||
app: &mut Application,
|
||||
command: SensitiveCommand,
|
||||
) -> Result<CommandResult, OperationError> {
|
||||
match command {
|
||||
SensitiveCommand::PrepareInitialSetup(password) => app
|
||||
.prepare_initial_setup_from_sources(password.expose())
|
||||
.map(CommandResult::InitialSetupPrepared)
|
||||
.map_err(|error| OperationError {
|
||||
message: error.to_string(),
|
||||
}),
|
||||
SensitiveCommand::ReissueClient {
|
||||
identity,
|
||||
password,
|
||||
overwrite_confirmed,
|
||||
prepared_generation,
|
||||
} => {
|
||||
let prepared = self
|
||||
.prepared_destination
|
||||
.take()
|
||||
.ok_or_else(|| OperationError {
|
||||
message: "No prepared output destination is available.".to_owned(),
|
||||
})?;
|
||||
if prepared.identity != identity
|
||||
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|
||||
|| prepared.generation != prepared_generation
|
||||
{
|
||||
return Err(OperationError {
|
||||
message: "Prepared output destination is stale; select it again."
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
let include_qr = prepared.include_qr;
|
||||
let mut destination = prepared.destination;
|
||||
if destination.overwrite_confirmation_required() {
|
||||
if !overwrite_confirmed {
|
||||
return Err(OperationError {
|
||||
message: "Overwrite confirmation was not supplied.".to_owned(),
|
||||
});
|
||||
}
|
||||
destination = destination.confirm_overwrite().map_err(operation_error)?;
|
||||
}
|
||||
let delivery = app
|
||||
.reissue_client_file(identity, password.expose(), destination, include_qr)
|
||||
.map_err(|error| OperationError {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let (qr, qr_output) = match delivery.qr {
|
||||
None => (QrStatus::NotRequested, None),
|
||||
Some(result) => qr_result(result),
|
||||
};
|
||||
Ok(CommandResult::Reissued(ReissueSummary {
|
||||
activation_warning: delivery.activation_warning,
|
||||
qr,
|
||||
qr_output,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_sensitive_at(
|
||||
&mut self,
|
||||
app: &mut Application,
|
||||
command: SensitiveCommand,
|
||||
generation: u64,
|
||||
) -> Result<CommandResult, OperationError> {
|
||||
match &command {
|
||||
SensitiveCommand::ReissueClient {
|
||||
prepared_generation,
|
||||
..
|
||||
} if *prepared_generation != generation => {
|
||||
return Err(OperationError {
|
||||
message: "Prepared output destination is stale; select it again.".to_owned(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
|
||||
self.prepared_destination = None;
|
||||
self.restore_preview = None;
|
||||
}
|
||||
self.execute_sensitive(app, command)
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_error(error: impl std::fmt::Display) -> OperationError {
|
||||
OperationError {
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
|
||||
stored == requested && stored == current
|
||||
}
|
||||
|
||||
pub(super) fn restore_preview_generation_matches(
|
||||
stored: u64,
|
||||
requested: u64,
|
||||
current: u64,
|
||||
) -> bool {
|
||||
stored == requested && current == requested.saturating_add(1)
|
||||
}
|
||||
|
||||
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
|
||||
match result {
|
||||
Ok(output) => (QrStatus::Ready, Some(output)),
|
||||
Err(error) => (QrStatus::Failed(error), None),
|
||||
}
|
||||
}
|
||||
+1206
File diff suppressed because it is too large
Load Diff
+4514
File diff suppressed because it is too large
Load Diff
+1104
File diff suppressed because it is too large
Load Diff
+1738
File diff suppressed because it is too large
Load Diff
+858
@@ -0,0 +1,858 @@
|
||||
//! Secret-safe primitives used by the encrypted client vault.
|
||||
|
||||
pub mod import;
|
||||
pub mod source;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::str::FromStr;
|
||||
|
||||
use age::Encryptor;
|
||||
use age::secrecy::{ExposeSecret, SecretString};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
/// A WireGuard private key held in memory and erased when dropped.
|
||||
///
|
||||
/// Private key bytes are neither formatted nor serialized implicitly. Use
|
||||
/// [`Self::to_base64`] only at an explicit WireGuard configuration boundary.
|
||||
pub struct WireGuardPrivateKey(StaticSecret);
|
||||
|
||||
impl WireGuardPrivateKey {
|
||||
/// Generate key material from the operating system's random source.
|
||||
pub fn generate() -> Result<Self, KeyError> {
|
||||
let mut bytes = Zeroizing::new([0_u8; 32]);
|
||||
getrandom::fill(bytes.as_mut()).map_err(|_| KeyError::RandomUnavailable)?;
|
||||
Ok(Self(StaticSecret::from(*bytes)))
|
||||
}
|
||||
|
||||
/// Parse canonical standard Base64 encoding of exactly 32 private key bytes.
|
||||
pub fn parse_base64(value: &str) -> Result<Self, KeyError> {
|
||||
if value.len() != 44 {
|
||||
return Err(KeyError::InvalidPrivateKey);
|
||||
}
|
||||
let mut decoded = Zeroizing::new(Vec::with_capacity(32));
|
||||
STANDARD
|
||||
.decode_vec(value, &mut decoded)
|
||||
.map_err(|_| KeyError::InvalidPrivateKey)?;
|
||||
let bytes: Zeroizing<[u8; 32]> = Zeroizing::new(
|
||||
decoded
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| KeyError::InvalidPrivateKey)?,
|
||||
);
|
||||
let canonical = Zeroizing::new(STANDARD.encode(*bytes));
|
||||
if canonical.as_str() != value {
|
||||
return Err(KeyError::InvalidPrivateKey);
|
||||
}
|
||||
Ok(Self(StaticSecret::from(*bytes)))
|
||||
}
|
||||
|
||||
/// Explicitly encode this private key for a WireGuard configuration.
|
||||
///
|
||||
/// The returned `String` contains plaintext key material and must be kept
|
||||
/// short-lived by the caller.
|
||||
pub fn to_base64(&self) -> String {
|
||||
STANDARD.encode(self.0.as_bytes())
|
||||
}
|
||||
|
||||
/// Derive the corresponding WireGuard public key using X25519.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
let public = X25519PublicKey::from(&self.0);
|
||||
PublicKey::from_bytes(public.to_bytes())
|
||||
}
|
||||
|
||||
/// Check correspondence with an expected public identity.
|
||||
pub fn matches_public_key(&self, expected: &PublicKey) -> bool {
|
||||
self.public_key() == *expected
|
||||
}
|
||||
}
|
||||
|
||||
const BUNDLE_VERSION: u32 = 1;
|
||||
const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_RECORDS: usize = 4096;
|
||||
const MAX_RECORD_CIPHERTEXT_BYTES: usize = 64 * 1024;
|
||||
const MAX_IDENTITY_CIPHERTEXT_BYTES: usize = 16 * 1024;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordPayload {
|
||||
version: u32,
|
||||
label: String,
|
||||
private_key: String,
|
||||
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>,
|
||||
reissue_blocked: bool,
|
||||
}
|
||||
|
||||
impl Drop for RecordPayload {
|
||||
fn drop(&mut self) {
|
||||
self.private_key.zeroize();
|
||||
if let Some(value) = &mut self.preshared_key {
|
||||
value.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Password-authorized, short-lived client configuration data for later delivery.
|
||||
pub struct RetrievedClientRecord {
|
||||
private_key: Option<WireGuardPrivateKey>,
|
||||
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()
|
||||
}
|
||||
|
||||
/// Explicitly expose the optional preshared key for a delivery boundary.
|
||||
pub fn preshared_key_base64(&self) -> Option<&str> {
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RetrievedClientRecord")
|
||||
.field("imported_fields", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RetrievedClientRecord {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &mut self.preshared_key {
|
||||
value.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VaultBundle {
|
||||
version: u32,
|
||||
recipient: String,
|
||||
identity_ciphertext: String,
|
||||
records: BTreeMap<String, VaultRecord>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct VaultRecord {
|
||||
ciphertext: String,
|
||||
reissue_blocked: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for VaultBundle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("VaultBundle")
|
||||
.field("version", &self.version)
|
||||
.field("record_count", &self.records.len())
|
||||
.field("encrypted_material", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl VaultBundle {
|
||||
/// Create a separate age x25519 identity protected by age's standard passphrase format.
|
||||
pub fn create(password: &str) -> Result<Self, VaultError> {
|
||||
if password.is_empty() {
|
||||
return Err(VaultError::InvalidInput);
|
||||
}
|
||||
let identity = age::x25519::Identity::generate();
|
||||
let recipient = identity.to_public().to_string();
|
||||
let identity_text = identity.to_string();
|
||||
let encryptor =
|
||||
Encryptor::with_user_passphrase(SecretString::new(password.to_owned().into()));
|
||||
let mut ciphertext = Vec::new();
|
||||
let mut writer = encryptor
|
||||
.wrap_output(&mut ciphertext)
|
||||
.map_err(|_| VaultError::Crypto)?;
|
||||
writer
|
||||
.write_all(identity_text.expose_secret().as_bytes())
|
||||
.map_err(|_| VaultError::Crypto)?;
|
||||
writer.finish().map_err(|_| VaultError::Crypto)?;
|
||||
if ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES {
|
||||
return Err(VaultError::TooLarge);
|
||||
}
|
||||
Ok(Self {
|
||||
version: BUNDLE_VERSION,
|
||||
recipient,
|
||||
identity_ciphertext: STANDARD.encode(ciphertext),
|
||||
records: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt a client private key with the public recipient only; no password is required.
|
||||
pub fn encrypt_record(
|
||||
&mut self,
|
||||
label: PublicKey,
|
||||
secret: &WireGuardPrivateKey,
|
||||
) -> Result<(), VaultError> {
|
||||
self.encrypt_record_with_status(label, secret, false)
|
||||
}
|
||||
|
||||
/// Encrypt a key and bind its conflict status inside the authenticated payload.
|
||||
pub(crate) fn encrypt_record_with_status(
|
||||
&mut self,
|
||||
label: PublicKey,
|
||||
secret: &WireGuardPrivateKey,
|
||||
reissue_blocked: bool,
|
||||
) -> Result<(), VaultError> {
|
||||
self.validate_shape()?;
|
||||
if secret.public_key() != label {
|
||||
return Err(VaultError::RecordMismatch);
|
||||
}
|
||||
let payload = RecordPayload {
|
||||
version: 1,
|
||||
label: label.to_string(),
|
||||
private_key: secret.to_base64(),
|
||||
client_addresses: Vec::new(),
|
||||
interface_dns: None,
|
||||
interface_mtu: None,
|
||||
server_public_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: Vec::new(),
|
||||
persistent_keepalive: None,
|
||||
preshared_key: None,
|
||||
reissue_blocked,
|
||||
};
|
||||
self.encrypt_payload(label, payload, true)
|
||||
}
|
||||
|
||||
/// Return the unauthenticated display snapshot; retrieval checks encrypted status instead.
|
||||
pub fn reissue_blocked(&self, label: PublicKey) -> Result<bool, VaultError> {
|
||||
self.records
|
||||
.get(&label.to_string())
|
||||
.map(|record| record.reissue_blocked)
|
||||
.ok_or(VaultError::MissingRecord)
|
||||
}
|
||||
|
||||
pub fn contains_record(&self, label: PublicKey) -> bool {
|
||||
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> {
|
||||
self.validate_shape()?;
|
||||
Ok(self.records.remove(&label.to_string()).is_some())
|
||||
}
|
||||
|
||||
/// Retrieve a single record, freshly unlocking and verifying the identity on every call.
|
||||
pub fn retrieve_record(
|
||||
&self,
|
||||
label: PublicKey,
|
||||
password: &str,
|
||||
) -> Result<WireGuardPrivateKey, VaultError> {
|
||||
self.retrieve_client_record(label, password)?
|
||||
.into_private_key()
|
||||
}
|
||||
|
||||
/// Retrieve all retained connection parameters with fresh password authorization.
|
||||
pub fn retrieve_client_record(
|
||||
&self,
|
||||
label: PublicKey,
|
||||
password: &str,
|
||||
) -> Result<RetrievedClientRecord, VaultError> {
|
||||
self.validate_shape()?;
|
||||
if password.is_empty() {
|
||||
return Err(VaultError::InvalidInput);
|
||||
}
|
||||
let encoded_identity = STANDARD
|
||||
.decode(&self.identity_ciphertext)
|
||||
.map_err(|_| VaultError::CorruptVault)?;
|
||||
let password_identity =
|
||||
age::scrypt::Identity::new(SecretString::new(password.to_owned().into()));
|
||||
let identity_bytes = Zeroizing::new(
|
||||
age::decrypt(&password_identity, &encoded_identity)
|
||||
.map_err(|_| VaultError::UnlockFailed)?,
|
||||
);
|
||||
let identity_text =
|
||||
std::str::from_utf8(&identity_bytes).map_err(|_| VaultError::CorruptVault)?;
|
||||
let identity =
|
||||
age::x25519::Identity::from_str(identity_text).map_err(|_| VaultError::CorruptVault)?;
|
||||
if identity.to_public().to_string() != self.recipient {
|
||||
return Err(VaultError::RecipientMismatch);
|
||||
}
|
||||
let record = self
|
||||
.records
|
||||
.get(&label.to_string())
|
||||
.ok_or(VaultError::MissingRecord)?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&record.ciphertext)
|
||||
.map_err(|_| VaultError::CorruptRecord)?;
|
||||
let plaintext =
|
||||
age::decrypt(&identity, &ciphertext).map_err(|_| VaultError::CorruptRecord)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let text = std::str::from_utf8(&plaintext).map_err(|_| VaultError::CorruptRecord)?;
|
||||
let payload: RecordPayload = toml::from_str(text).map_err(|_| VaultError::CorruptRecord)?;
|
||||
if payload.version != 1 {
|
||||
return Err(VaultError::UnsupportedVersion);
|
||||
}
|
||||
if payload.label != label.to_string() {
|
||||
return Err(VaultError::RecordMismatch);
|
||||
}
|
||||
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 {
|
||||
return Err(VaultError::RecordMismatch);
|
||||
}
|
||||
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(),
|
||||
server_public_key: payload.server_public_key.clone(),
|
||||
endpoint: payload.endpoint.clone(),
|
||||
allowed_ips: payload.allowed_ips.clone(),
|
||||
persistent_keepalive: payload.persistent_keepalive.clone(),
|
||||
preshared_key: payload.preshared_key.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize encrypted bundle contents for persistence or backup without unlocking.
|
||||
pub fn to_toml(&self) -> Result<String, VaultError> {
|
||||
self.validate_shape()?;
|
||||
let encoded = toml::to_string(self).map_err(|_| VaultError::Serialization)?;
|
||||
if encoded.len() > MAX_BUNDLE_BYTES {
|
||||
return Err(VaultError::TooLarge);
|
||||
}
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
/// Load bounded serialized contents without requesting the password.
|
||||
pub fn from_toml(encoded: &str) -> Result<Self, VaultError> {
|
||||
if encoded.len() > MAX_BUNDLE_BYTES {
|
||||
return Err(VaultError::TooLarge);
|
||||
}
|
||||
let bundle: Self = toml::from_str(encoded).map_err(|_| VaultError::CorruptVault)?;
|
||||
if bundle.version != BUNDLE_VERSION {
|
||||
return Err(VaultError::UnsupportedVersion);
|
||||
}
|
||||
bundle.validate_shape()?;
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
pub fn recipient(&self) -> &str {
|
||||
&self.recipient
|
||||
}
|
||||
|
||||
pub fn record_count(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
fn encrypt_payload(
|
||||
&mut self,
|
||||
label: PublicKey,
|
||||
payload: RecordPayload,
|
||||
reject_duplicate: bool,
|
||||
) -> Result<(), VaultError> {
|
||||
self.validate_shape()?;
|
||||
let key = label.to_string();
|
||||
if reject_duplicate && self.records.contains_key(&key) {
|
||||
return Err(VaultError::DuplicateRecord);
|
||||
}
|
||||
if !self.records.contains_key(&key) && self.records.len() >= MAX_RECORDS {
|
||||
return Err(VaultError::TooManyRecords);
|
||||
}
|
||||
let plaintext =
|
||||
Zeroizing::new(toml::to_string(&payload).map_err(|_| VaultError::Serialization)?);
|
||||
let recipient = age::x25519::Recipient::from_str(&self.recipient)
|
||||
.map_err(|_| VaultError::CorruptVault)?;
|
||||
let ciphertext =
|
||||
age::encrypt(&recipient, plaintext.as_bytes()).map_err(|_| VaultError::Crypto)?;
|
||||
if ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES {
|
||||
return Err(VaultError::TooLarge);
|
||||
}
|
||||
self.records.insert(
|
||||
key,
|
||||
VaultRecord {
|
||||
ciphertext: STANDARD.encode(ciphertext),
|
||||
// Advisory only. Retrieval trusts the authenticated encrypted payload.
|
||||
reissue_blocked: payload.reissue_blocked,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_shape(&self) -> Result<(), VaultError> {
|
||||
if self.version != BUNDLE_VERSION {
|
||||
return Err(VaultError::UnsupportedVersion);
|
||||
}
|
||||
if self.records.len() > MAX_RECORDS
|
||||
|| age::x25519::Recipient::from_str(&self.recipient).is_err()
|
||||
{
|
||||
return Err(VaultError::CorruptVault);
|
||||
}
|
||||
let identity_ciphertext = STANDARD
|
||||
.decode(&self.identity_ciphertext)
|
||||
.map_err(|_| VaultError::CorruptVault)?;
|
||||
if identity_ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES
|
||||
|| STANDARD.encode(&identity_ciphertext) != self.identity_ciphertext
|
||||
|| age::Decryptor::new(identity_ciphertext.as_slice()).is_err()
|
||||
{
|
||||
return Err(VaultError::CorruptVault);
|
||||
}
|
||||
for (label, record) in &self.records {
|
||||
let parsed = label
|
||||
.parse::<PublicKey>()
|
||||
.map_err(|_| VaultError::CorruptVault)?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&record.ciphertext)
|
||||
.map_err(|_| VaultError::CorruptRecord)?;
|
||||
if parsed.to_string() != *label
|
||||
|| ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES
|
||||
|| STANDARD.encode(&ciphertext) != record.ciphertext
|
||||
|| age::Decryptor::new(ciphertext.as_slice()).is_err()
|
||||
{
|
||||
return Err(VaultError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret-safe errors for vault operations.
|
||||
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
|
||||
pub enum VaultError {
|
||||
#[error("vault input is invalid")]
|
||||
InvalidInput,
|
||||
#[error("cryptographic operation failed")]
|
||||
Crypto,
|
||||
#[error("vault serialization failed")]
|
||||
Serialization,
|
||||
#[error("vault data is corrupt or malformed")]
|
||||
CorruptVault,
|
||||
#[error("vault record is corrupt or malformed")]
|
||||
CorruptRecord,
|
||||
#[error("vault version is not supported")]
|
||||
UnsupportedVersion,
|
||||
#[error("vault data exceeds a size limit")]
|
||||
TooLarge,
|
||||
#[error("vault contains too many records")]
|
||||
TooManyRecords,
|
||||
#[error("vault identity could not be unlocked")]
|
||||
UnlockFailed,
|
||||
#[error("vault recipient does not match its protected identity")]
|
||||
RecipientMismatch,
|
||||
#[error("vault record does not match its public-key label")]
|
||||
RecordMismatch,
|
||||
#[error("vault record is missing")]
|
||||
MissingRecord,
|
||||
#[error("vault already contains a record for this public key")]
|
||||
DuplicateRecord,
|
||||
#[error("client reissue is blocked pending reconciliation")]
|
||||
ReissueBlocked,
|
||||
}
|
||||
|
||||
/// Recoverable errors from private-key generation and parsing.
|
||||
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
|
||||
pub enum KeyError {
|
||||
#[error("operating system random source is unavailable")]
|
||||
RandomUnavailable,
|
||||
#[error("private key must be canonical standard Base64 encoding of exactly 32 bytes")]
|
||||
InvalidPrivateKey,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
|
||||
use super::{KeyError, RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey};
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
const PASSWORD: &str = "synthetic vault passphrase for tests";
|
||||
|
||||
fn prepared() -> (VaultBundle, WireGuardPrivateKey) {
|
||||
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
|
||||
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
|
||||
bundle
|
||||
.encrypt_record(key.public_key(), &key)
|
||||
.expect("recipient encryption must not need a password");
|
||||
(bundle, key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_keys_derive_matching_public_identities() {
|
||||
let first = WireGuardPrivateKey::generate().expect("OS randomness should be available");
|
||||
let second = WireGuardPrivateKey::generate().expect("OS randomness should be available");
|
||||
|
||||
let first_public = first.public_key();
|
||||
let second_public = second.public_key();
|
||||
assert!(first.matches_public_key(&first_public));
|
||||
assert!(!first.matches_public_key(&second_public));
|
||||
assert_ne!(first_public, second_public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_base64_round_trip_preserves_key_identity() {
|
||||
let original = WireGuardPrivateKey::generate().expect("OS randomness should be available");
|
||||
let public = original.public_key();
|
||||
let encoded = original.to_base64();
|
||||
assert_eq!(encoded.len(), 44);
|
||||
|
||||
let imported = WireGuardPrivateKey::parse_base64(&encoded)
|
||||
.expect("generated key encoding should parse canonically");
|
||||
assert!(imported.matches_public_key(&public));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_private_key_encodings_are_rejected() {
|
||||
let short = STANDARD.encode([0_u8; 31]);
|
||||
let full = STANDARD.encode([0_u8; 32]);
|
||||
let without_padding = full.trim_end_matches('=').to_owned();
|
||||
let invalid_after_decoded_prefix = format!("{}!", &full[..43]);
|
||||
|
||||
assert_eq!(
|
||||
WireGuardPrivateKey::parse_base64(&short).err(),
|
||||
Some(KeyError::InvalidPrivateKey)
|
||||
);
|
||||
assert_eq!(
|
||||
WireGuardPrivateKey::parse_base64(&without_padding).err(),
|
||||
Some(KeyError::InvalidPrivateKey)
|
||||
);
|
||||
assert_eq!(
|
||||
WireGuardPrivateKey::parse_base64("not base64").err(),
|
||||
Some(KeyError::InvalidPrivateKey)
|
||||
);
|
||||
assert_eq!(
|
||||
WireGuardPrivateKey::parse_base64(&invalid_after_decoded_prefix).err(),
|
||||
Some(KeyError::InvalidPrivateKey)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password() {
|
||||
let (bundle, key) = prepared();
|
||||
let serialized = bundle.to_toml().expect("bundle should serialize");
|
||||
assert!(!serialized.contains(&key.to_base64()));
|
||||
assert!(!serialized.contains(PASSWORD));
|
||||
|
||||
let loaded = VaultBundle::from_toml(&serialized).expect("bundle loads without password");
|
||||
assert_eq!(loaded.record_count(), 1);
|
||||
assert_eq!(loaded.recipient(), bundle.recipient());
|
||||
let retrieved = loaded
|
||||
.retrieve_record(key.public_key(), PASSWORD)
|
||||
.expect("correct password should retrieve matching secret");
|
||||
assert!(retrieved.matches_public_key(&key.public_key()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_retrieval_requires_the_correct_password() {
|
||||
let (bundle, key) = prepared();
|
||||
assert_eq!(
|
||||
bundle
|
||||
.retrieve_record(key.public_key(), "wrong password")
|
||||
.err(),
|
||||
Some(VaultError::UnlockFailed)
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(key.public_key(), "").err(),
|
||||
Some(VaultError::InvalidInput)
|
||||
);
|
||||
assert!(
|
||||
bundle
|
||||
.retrieve_record(key.public_key(), PASSWORD)
|
||||
.expect("fresh correct authorization should work")
|
||||
.matches_public_key(&key.public_key())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_truncated_and_unknown_version_data_are_rejected() {
|
||||
let (bundle, key) = prepared();
|
||||
let serialized = bundle.to_toml().expect("bundle should serialize");
|
||||
assert_eq!(
|
||||
VaultBundle::from_toml(&serialized[..serialized.len() / 2]).err(),
|
||||
Some(VaultError::CorruptVault)
|
||||
);
|
||||
assert_eq!(
|
||||
VaultBundle::from_toml(&serialized.replace("version = 1", "version = 9")).err(),
|
||||
Some(VaultError::UnsupportedVersion)
|
||||
);
|
||||
|
||||
let mut corrupt_record = bundle;
|
||||
let record = corrupt_record
|
||||
.records
|
||||
.get_mut(&key.public_key().to_string())
|
||||
.expect("record exists");
|
||||
let mut bytes = STANDARD
|
||||
.decode(&record.ciphertext)
|
||||
.expect("ciphertext is base64");
|
||||
bytes.truncate(bytes.len() / 2);
|
||||
record.ciphertext = STANDARD.encode(bytes);
|
||||
assert_eq!(
|
||||
corrupt_record
|
||||
.retrieve_record(key.public_key(), PASSWORD)
|
||||
.err(),
|
||||
Some(VaultError::CorruptRecord)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_identity_detects_corruption_and_recipient_replacement() {
|
||||
let (mut bundle, key) = prepared();
|
||||
let mut identity_ciphertext = STANDARD
|
||||
.decode(&bundle.identity_ciphertext)
|
||||
.expect("identity ciphertext is base64");
|
||||
identity_ciphertext.truncate(identity_ciphertext.len() / 2);
|
||||
bundle.identity_ciphertext = STANDARD.encode(identity_ciphertext);
|
||||
assert!(matches!(
|
||||
bundle.retrieve_record(key.public_key(), PASSWORD),
|
||||
Err(VaultError::UnlockFailed | VaultError::CorruptVault)
|
||||
));
|
||||
|
||||
let (mut bundle, key) = prepared();
|
||||
let different_identity = age::x25519::Identity::generate();
|
||||
bundle.recipient = different_identity.to_public().to_string();
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
|
||||
Some(VaultError::RecipientMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relabelled_record_is_rejected_and_authenticated_conflict_blocks_reissue() {
|
||||
let (mut bundle, key) = prepared();
|
||||
let other_label = WireGuardPrivateKey::generate()
|
||||
.expect("OS randomness should be available")
|
||||
.public_key();
|
||||
let record = bundle
|
||||
.records
|
||||
.remove(&key.public_key().to_string())
|
||||
.expect("record exists");
|
||||
bundle.records.insert(other_label.to_string(), record);
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(other_label, PASSWORD).err(),
|
||||
Some(VaultError::RecordMismatch)
|
||||
);
|
||||
|
||||
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
|
||||
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
|
||||
bundle
|
||||
.encrypt_record_with_status(key.public_key(), &key, true)
|
||||
.expect("blocked record should encrypt");
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
|
||||
Some(VaultError::ReissueBlocked)
|
||||
);
|
||||
let serialized = bundle
|
||||
.to_toml()
|
||||
.expect("blocked record should serialize")
|
||||
.replace("reissue_blocked = true", "reissue_blocked = false");
|
||||
let changed = VaultBundle::from_toml(&serialized).expect("public status is advisory");
|
||||
assert_eq!(changed.reissue_blocked(key.public_key()), Ok(false));
|
||||
assert_eq!(
|
||||
changed.retrieve_record(key.public_key(), PASSWORD).err(),
|
||||
Some(VaultError::ReissueBlocked)
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
|
||||
let label = key.public_key();
|
||||
bundle
|
||||
.encrypt_payload(
|
||||
label,
|
||||
RecordPayload {
|
||||
version: 2,
|
||||
label: label.to_string(),
|
||||
private_key: key.to_base64(),
|
||||
client_addresses: Vec::new(),
|
||||
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("test record should be encrypted with the normal age path");
|
||||
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(label, PASSWORD).err(),
|
||||
Some(VaultError::UnsupportedVersion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_debug_and_errors_do_not_include_secret_material() {
|
||||
let (bundle, key) = prepared();
|
||||
let debug = format!("{bundle:?}");
|
||||
assert!(!debug.contains(PASSWORD));
|
||||
assert!(!debug.contains(&key.to_base64()));
|
||||
let error = VaultError::UnlockFailed.to_string();
|
||||
assert!(!error.contains(PASSWORD));
|
||||
assert!(!error.contains(&key.to_base64()));
|
||||
|
||||
let unknown = PublicKey::from_bytes([0xA5; 32]);
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(unknown, PASSWORD).err(),
|
||||
Some(VaultError::MissingRecord)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
//! Caller-buffer parsing and reconciliation for existing WireGuard client files.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use ipnet::IpNet;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::domain::PublicKey;
|
||||
use crate::wireguard::{ConfigDocument, MetadataAttachment, PeerIssue, SectionKind};
|
||||
|
||||
use super::{RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey};
|
||||
|
||||
/// Secret-free outcome of parsing a supplied client configuration.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ClientFileIssue {
|
||||
InvalidEncoding,
|
||||
InvalidStructure,
|
||||
MissingPrivateKey,
|
||||
AmbiguousPrivateKey,
|
||||
InvalidPrivateKey,
|
||||
MissingAddress,
|
||||
InvalidAddress,
|
||||
AmbiguousAddress,
|
||||
MissingServerKey,
|
||||
AmbiguousServerKey,
|
||||
InvalidServerKey,
|
||||
AmbiguousPresharedKey,
|
||||
InvalidPresharedKey,
|
||||
}
|
||||
|
||||
/// Parsed client parameters. Secret-bearing fields are intentionally not formatted.
|
||||
pub struct ParsedClientConfig {
|
||||
private_key: WireGuardPrivateKey,
|
||||
preshared_key: Option<Zeroizing<String>>,
|
||||
pub identity: PublicKey,
|
||||
pub addresses: Vec<IpNet>,
|
||||
address_text: Vec<String>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<String>,
|
||||
pub server_public_key: PublicKey,
|
||||
pub endpoint: Option<String>,
|
||||
pub allowed_ips: Vec<IpNet>,
|
||||
allowed_ips_text: Vec<String>,
|
||||
pub persistent_keepalive: Option<String>,
|
||||
pub unsupported_address_shape: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ParsedClientConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ParsedClientConfig")
|
||||
.field("identity", &self.identity)
|
||||
.field("addresses", &self.addresses)
|
||||
.field("server_public_key", &self.server_public_key)
|
||||
.field("unsupported_address_shape", &self.unsupported_address_shape)
|
||||
.field("secret_parameters", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ClientSection {
|
||||
Interface,
|
||||
Peer,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Parse a caller-supplied client configuration without reading files or exposing its source.
|
||||
pub fn parse_client_config(input: &[u8]) -> Result<ParsedClientConfig, ClientFileIssue> {
|
||||
let text = std::str::from_utf8(input).map_err(|_| ClientFileIssue::InvalidEncoding)?;
|
||||
let mut section: Option<ClientSection> = None;
|
||||
let mut interface_count = 0;
|
||||
let mut peer_count = 0;
|
||||
let mut private_keys = Vec::<Zeroizing<String>>::new();
|
||||
let mut psk = Vec::<Zeroizing<String>>::new();
|
||||
let (mut addresses, mut dns, mut mtu) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut server_keys, mut endpoints, mut allowed, mut keepalive) =
|
||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('[') || line.ends_with(']') {
|
||||
let Some(name) = line.strip_prefix('[').and_then(|n| n.strip_suffix(']')) else {
|
||||
return Err(ClientFileIssue::InvalidStructure);
|
||||
};
|
||||
let name = name.trim();
|
||||
section = if name.eq_ignore_ascii_case("interface") {
|
||||
interface_count += 1;
|
||||
Some(ClientSection::Interface)
|
||||
} else if name.eq_ignore_ascii_case("peer") {
|
||||
peer_count += 1;
|
||||
Some(ClientSection::Peer)
|
||||
} else {
|
||||
Some(ClientSection::Other)
|
||||
};
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
return Err(ClientFileIssue::InvalidStructure);
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match section {
|
||||
Some(ClientSection::Interface) => {
|
||||
if key.eq_ignore_ascii_case("privatekey") {
|
||||
private_keys.push(Zeroizing::new(value.to_owned()));
|
||||
} else if key.eq_ignore_ascii_case("address") {
|
||||
addresses.push(value.to_owned());
|
||||
} else if key.eq_ignore_ascii_case("dns") {
|
||||
dns.push(value.to_owned());
|
||||
} else if key.eq_ignore_ascii_case("mtu") {
|
||||
mtu.push(value.to_owned());
|
||||
} // Hooks and unknown fields are never interpreted or executed.
|
||||
}
|
||||
Some(ClientSection::Peer) => {
|
||||
if key.eq_ignore_ascii_case("publickey") {
|
||||
server_keys.push(value.to_owned());
|
||||
} else if key.eq_ignore_ascii_case("presharedkey") {
|
||||
psk.push(Zeroizing::new(value.to_owned()));
|
||||
} else if key.eq_ignore_ascii_case("endpoint") {
|
||||
endpoints.push(value.to_owned());
|
||||
} else if key.eq_ignore_ascii_case("allowedips") {
|
||||
allowed.push(value.to_owned());
|
||||
} else if key.eq_ignore_ascii_case("persistentkeepalive") {
|
||||
keepalive.push(value.to_owned());
|
||||
}
|
||||
}
|
||||
_ => return Err(ClientFileIssue::InvalidStructure),
|
||||
}
|
||||
}
|
||||
if interface_count != 1 || peer_count != 1 {
|
||||
return Err(ClientFileIssue::InvalidStructure);
|
||||
}
|
||||
let private_text = match private_keys.as_slice() {
|
||||
[] => return Err(ClientFileIssue::MissingPrivateKey),
|
||||
[value] => value,
|
||||
_ => return Err(ClientFileIssue::AmbiguousPrivateKey),
|
||||
};
|
||||
let private_key = WireGuardPrivateKey::parse_base64(private_text)
|
||||
.map_err(|_| ClientFileIssue::InvalidPrivateKey)?;
|
||||
let address_text = match addresses.as_slice() {
|
||||
[] => return Err(ClientFileIssue::MissingAddress),
|
||||
[value] => value,
|
||||
_ => return Err(ClientFileIssue::AmbiguousAddress),
|
||||
};
|
||||
let address_items = split_network_list(address_text).ok_or(ClientFileIssue::InvalidAddress)?;
|
||||
let parsed_addresses = address_items
|
||||
.iter()
|
||||
.map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let dns = single_optional(dns).ok_or(ClientFileIssue::InvalidStructure)?;
|
||||
let mtu = single_optional(mtu).ok_or(ClientFileIssue::InvalidStructure)?;
|
||||
let server_key_text = match server_keys.as_slice() {
|
||||
[] => return Err(ClientFileIssue::MissingServerKey),
|
||||
[value] => value,
|
||||
_ => return Err(ClientFileIssue::AmbiguousServerKey),
|
||||
};
|
||||
let server_public_key =
|
||||
PublicKey::from_str(server_key_text).map_err(|_| ClientFileIssue::InvalidServerKey)?;
|
||||
let preshared_key = match psk.as_slice() {
|
||||
[] => None,
|
||||
[value] => {
|
||||
WireGuardPrivateKey::parse_base64(value)
|
||||
.map_err(|_| ClientFileIssue::InvalidPresharedKey)?;
|
||||
Some(value.clone())
|
||||
}
|
||||
_ => return Err(ClientFileIssue::AmbiguousPresharedKey),
|
||||
};
|
||||
let endpoint = single_optional(endpoints).ok_or(ClientFileIssue::InvalidStructure)?;
|
||||
let mut allowed_ips_text = Vec::new();
|
||||
for value in &allowed {
|
||||
allowed_ips_text.extend(split_network_list(value).ok_or(ClientFileIssue::InvalidAddress)?);
|
||||
}
|
||||
let allowed_ips = allowed_ips_text
|
||||
.iter()
|
||||
.map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let persistent_keepalive =
|
||||
single_optional(keepalive).ok_or(ClientFileIssue::InvalidStructure)?;
|
||||
let unsupported_address_shape = parsed_addresses.iter().any(|net| match net {
|
||||
IpNet::V4(v4) => v4.prefix_len() != 32,
|
||||
IpNet::V6(v6) => v6.prefix_len() != 128,
|
||||
});
|
||||
|
||||
Ok(ParsedClientConfig {
|
||||
identity: private_key.public_key(),
|
||||
private_key,
|
||||
preshared_key,
|
||||
addresses: parsed_addresses,
|
||||
address_text: address_items,
|
||||
dns,
|
||||
mtu,
|
||||
server_public_key,
|
||||
endpoint,
|
||||
allowed_ips,
|
||||
allowed_ips_text,
|
||||
persistent_keepalive,
|
||||
unsupported_address_shape,
|
||||
})
|
||||
}
|
||||
|
||||
fn single_optional(values: Vec<String>) -> Option<Option<String>> {
|
||||
match values.as_slice() {
|
||||
[] => Some(None),
|
||||
[value] => Some(Some(value.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn split_network_list(value: &str) -> Option<Vec<String>> {
|
||||
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
|
||||
if items.is_empty() || items.iter().any(|item| item.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
IpNet::from_str(item).ok()?;
|
||||
Some(item.to_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reconciliation status for one supplied candidate; it contains no source text or secrets.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CandidateStatus {
|
||||
Invalid(ClientFileIssue),
|
||||
DuplicateFileIdentity,
|
||||
AmbiguousServerPeer,
|
||||
FileOnly,
|
||||
Matched {
|
||||
address_discrepancy: bool,
|
||||
server_identity: ServerIdentityComparison,
|
||||
missing_metadata: bool,
|
||||
invalid_metadata: bool,
|
||||
unsupported_projection: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether the client file's peer key could be checked against the authoritative server identity.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ServerIdentityComparison {
|
||||
Matches,
|
||||
Mismatch,
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
display_name: String,
|
||||
parsed: Result<ParsedClientConfig, ClientFileIssue>,
|
||||
status: CandidateStatus,
|
||||
}
|
||||
|
||||
/// Secret-free reconciliation summary. The display name is never used as identity.
|
||||
pub struct ImportReport {
|
||||
candidates: Vec<Candidate>,
|
||||
server_only: Vec<PublicKey>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ImportReport {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ImportReport")
|
||||
.field("candidate_count", &self.candidates.len())
|
||||
.field("server_only", &self.server_only)
|
||||
.field("details", &"[REDACTED SOURCE DETAILS]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportReport {
|
||||
pub fn candidate_summaries(&self) -> Vec<(&str, CandidateStatus)> {
|
||||
self.candidates
|
||||
.iter()
|
||||
.map(|item| (item.display_name.as_str(), item.status.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn server_only(&self) -> &[PublicKey] {
|
||||
&self.server_only
|
||||
}
|
||||
|
||||
/// Encrypt one valid candidate. Duplicate vault records are rejected without replacement.
|
||||
pub fn encrypt_candidate(
|
||||
&self,
|
||||
bundle: &mut VaultBundle,
|
||||
candidate_index: usize,
|
||||
) -> Result<(), VaultError> {
|
||||
let candidate = self
|
||||
.candidates
|
||||
.get(candidate_index)
|
||||
.ok_or(VaultError::InvalidInput)?;
|
||||
let parsed = candidate
|
||||
.parsed
|
||||
.as_ref()
|
||||
.map_err(|_| VaultError::InvalidInput)?;
|
||||
if matches!(
|
||||
candidate.status,
|
||||
CandidateStatus::DuplicateFileIdentity | CandidateStatus::Invalid(_)
|
||||
) {
|
||||
return Err(VaultError::InvalidInput);
|
||||
}
|
||||
let blocked = match candidate.status {
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy,
|
||||
server_identity,
|
||||
unsupported_projection,
|
||||
..
|
||||
} => {
|
||||
address_discrepancy
|
||||
|| server_identity != ServerIdentityComparison::Matches
|
||||
|| unsupported_projection
|
||||
}
|
||||
CandidateStatus::AmbiguousServerPeer | CandidateStatus::FileOnly => true,
|
||||
CandidateStatus::Invalid(_) | CandidateStatus::DuplicateFileIdentity => true,
|
||||
};
|
||||
let payload = RecordPayload {
|
||||
version: 1,
|
||||
label: parsed.identity.to_string(),
|
||||
private_key: parsed.private_key.to_base64(),
|
||||
client_addresses: parsed.address_text.clone(),
|
||||
interface_dns: parsed.dns.clone(),
|
||||
interface_mtu: parsed.mtu.clone(),
|
||||
server_public_key: Some(parsed.server_public_key.to_string()),
|
||||
endpoint: parsed.endpoint.clone(),
|
||||
allowed_ips: parsed.allowed_ips_text.clone(),
|
||||
persistent_keepalive: parsed.persistent_keepalive.clone(),
|
||||
preshared_key: parsed.preshared_key.as_ref().map(|value| value.to_string()),
|
||||
reissue_blocked: blocked,
|
||||
};
|
||||
bundle.encrypt_payload(parsed.identity, payload, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile supplied caller-buffer files with the authoritative server document.
|
||||
pub fn reconcile(
|
||||
server: &ConfigDocument,
|
||||
files: impl IntoIterator<Item = (String, Vec<u8>)>,
|
||||
) -> ImportReport {
|
||||
reconcile_zeroizing(
|
||||
server,
|
||||
files
|
||||
.into_iter()
|
||||
.map(|(display_name, bytes)| (display_name, Zeroizing::new(bytes))),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_zeroizing(
|
||||
server: &ConfigDocument,
|
||||
files: impl IntoIterator<Item = (String, Zeroizing<Vec<u8>>)>,
|
||||
) -> ImportReport {
|
||||
let mut candidates = files
|
||||
.into_iter()
|
||||
.map(|(display_name, bytes)| Candidate {
|
||||
display_name,
|
||||
parsed: parse_client_config(&bytes),
|
||||
status: CandidateStatus::FileOnly,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut identity_counts = BTreeMap::<PublicKey, usize>::new();
|
||||
for candidate in &candidates {
|
||||
if let Ok(parsed) = &candidate.parsed {
|
||||
*identity_counts.entry(parsed.identity).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let server_key = interface_public_key(server);
|
||||
let mut represented = BTreeSet::new();
|
||||
for candidate in &mut candidates {
|
||||
let parsed = match &candidate.parsed {
|
||||
Ok(parsed) => parsed,
|
||||
Err(issue) => {
|
||||
candidate.status = CandidateStatus::Invalid(*issue);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
represented.insert(parsed.identity);
|
||||
if identity_counts
|
||||
.get(&parsed.identity)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
> 1
|
||||
{
|
||||
candidate.status = CandidateStatus::DuplicateFileIdentity;
|
||||
continue;
|
||||
}
|
||||
let peers = server
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| peer.public_key_candidates.contains(&parsed.identity))
|
||||
.collect::<Vec<_>>();
|
||||
if peers.is_empty() {
|
||||
candidate.status = CandidateStatus::FileOnly;
|
||||
continue;
|
||||
}
|
||||
if peers.len() != 1
|
||||
|| peers[0].identity != Some(parsed.identity)
|
||||
|| peers[0].issues.iter().any(|issue| {
|
||||
matches!(
|
||||
issue,
|
||||
PeerIssue::DuplicatePeerIdentity { .. }
|
||||
| PeerIssue::DuplicatePublicKeyField { .. }
|
||||
| PeerIssue::MalformedPublicKey { .. }
|
||||
)
|
||||
})
|
||||
{
|
||||
candidate.status = CandidateStatus::AmbiguousServerPeer;
|
||||
continue;
|
||||
}
|
||||
let peer = peers[0];
|
||||
let file_addresses = parsed
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|net| net.addr())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let server_addresses = peer
|
||||
.allowed_ips
|
||||
.iter()
|
||||
.map(|net| net.addr())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let address_discrepancy = file_addresses != server_addresses;
|
||||
let server_identity = match server_key {
|
||||
Some(server_key) if server_key == parsed.server_public_key => {
|
||||
ServerIdentityComparison::Matches
|
||||
}
|
||||
Some(_) => ServerIdentityComparison::Mismatch,
|
||||
None => ServerIdentityComparison::Unresolved,
|
||||
};
|
||||
let missing_metadata = matches!(peer.metadata, MetadataAttachment::Absent);
|
||||
let invalid_metadata = matches!(peer.metadata, MetadataAttachment::Invalid(_));
|
||||
let server_unsupported = peer.issues.iter().any(|issue| {
|
||||
matches!(
|
||||
issue,
|
||||
PeerIssue::MalformedAllowedIp { .. }
|
||||
| PeerIssue::UnsupportedAllowedIpShape { .. }
|
||||
| PeerIssue::MissingIpv4HostRoute
|
||||
)
|
||||
});
|
||||
candidate.status = CandidateStatus::Matched {
|
||||
address_discrepancy,
|
||||
server_identity,
|
||||
missing_metadata,
|
||||
invalid_metadata,
|
||||
unsupported_projection: parsed.unsupported_address_shape || server_unsupported,
|
||||
};
|
||||
}
|
||||
let server_only = server
|
||||
.peers()
|
||||
.iter()
|
||||
.filter_map(|peer| peer.identity)
|
||||
.filter(|identity| !represented.contains(identity))
|
||||
.collect();
|
||||
ImportReport {
|
||||
candidates,
|
||||
server_only,
|
||||
}
|
||||
}
|
||||
|
||||
fn interface_public_key(document: &ConfigDocument) -> Option<PublicKey> {
|
||||
let interfaces = document
|
||||
.sections()
|
||||
.iter()
|
||||
.filter(|section| section.kind == SectionKind::Interface)
|
||||
.collect::<Vec<_>>();
|
||||
if interfaces.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let text = document.source_slice(interfaces[0].span)?;
|
||||
let keys = text
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
key.trim()
|
||||
.eq_ignore_ascii_case("PrivateKey")
|
||||
.then(|| value.trim())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let [private] = keys.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
WireGuardPrivateKey::parse_base64(private)
|
||||
.ok()
|
||||
.map(|key| key.public_key())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::{VaultBundle, VaultError};
|
||||
|
||||
const PASSWORD: &str = "synthetic passphrase";
|
||||
|
||||
fn generated() -> (String, String, String) {
|
||||
let client = WireGuardPrivateKey::generate().unwrap();
|
||||
let server = WireGuardPrivateKey::generate().unwrap();
|
||||
let psk = WireGuardPrivateKey::generate().unwrap();
|
||||
(client.to_base64(), server.to_base64(), psk.to_base64())
|
||||
}
|
||||
|
||||
fn client_config(client: &str, server: &str, psk: &str, address: &str) -> String {
|
||||
let server_public = WireGuardPrivateKey::parse_base64(server)
|
||||
.unwrap()
|
||||
.public_key();
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {client}\nAddress = {address}\nDNS = 1.1.1.1\nMTU = 1380\n[Peer]\nPublicKey = {server_public}\nPresharedKey = {psk}\nEndpoint = example.invalid:51820\nAllowedIPs = 0.0.0.0/0, ::/0\nPersistentKeepalive = 25\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn server_config(server: &str, client_public: &PublicKey, address: &str) -> String {
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {server}\n[Peer]\nPublicKey = {client_public}\nAllowedIPs = {address}\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_interface_private_key_is_identity_and_peer_public_key_is_server() {
|
||||
let (client, server, psk) = generated();
|
||||
let parsed =
|
||||
parse_client_config(client_config(&client, &server, &psk, "10.0.0.2/32").as_bytes())
|
||||
.unwrap();
|
||||
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let server_key = WireGuardPrivateKey::parse_base64(&server).unwrap();
|
||||
assert_eq!(parsed.identity, client_key.public_key());
|
||||
assert_ne!(parsed.identity, server_key.public_key());
|
||||
assert_eq!(parsed.server_public_key, server_key.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preserves_secrets_and_parameters_only_inside_encrypted_record() {
|
||||
let (client, server, psk) = generated();
|
||||
let private = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap();
|
||||
let source = client_config(&client, &server, &psk, "10.0.0.2/32, fd00::2/128");
|
||||
let parsed = parse_client_config(source.as_bytes()).unwrap();
|
||||
assert!(parsed.preshared_key.as_ref().map(|value| value.as_str()) == Some(psk.as_str()));
|
||||
let server_doc = ConfigDocument::parse(server_config(
|
||||
&server,
|
||||
&private.public_key(),
|
||||
"10.0.0.2/32, fd00::2/128",
|
||||
))
|
||||
.unwrap();
|
||||
let report = reconcile(
|
||||
&server_doc,
|
||||
[("alias-looking-name.conf".into(), source.as_bytes().to_vec())],
|
||||
);
|
||||
let summaries = report.candidate_summaries();
|
||||
assert_eq!(
|
||||
summaries[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: ServerIdentityComparison::Matches,
|
||||
missing_metadata: true,
|
||||
invalid_metadata: false,
|
||||
unsupported_projection: false,
|
||||
}
|
||||
);
|
||||
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
|
||||
report.encrypt_candidate(&mut bundle, 0).unwrap();
|
||||
let serialized = bundle.to_toml().unwrap();
|
||||
assert!(!serialized.contains(&client));
|
||||
assert!(!serialized.contains(&psk));
|
||||
assert!(!serialized.contains("example.invalid"));
|
||||
let retrieved = bundle
|
||||
.retrieve_client_record(parsed.identity, PASSWORD)
|
||||
.unwrap();
|
||||
assert!(retrieved.preshared_key_base64() == Some(psk.as_str()));
|
||||
assert_eq!(retrieved.client_addresses, ["10.0.0.2/32", "fd00::2/128"]);
|
||||
assert_eq!(retrieved.endpoint.as_deref(), Some("example.invalid:51820"));
|
||||
assert_eq!(retrieved.interface_dns.as_deref(), Some("1.1.1.1"));
|
||||
assert_eq!(retrieved.interface_mtu.as_deref(), Some("1380"));
|
||||
assert_eq!(retrieved.allowed_ips, ["0.0.0.0/0", "::/0"]);
|
||||
let debug = format!("{retrieved:?}");
|
||||
for raw_value in [
|
||||
psk.as_str(),
|
||||
"10.0.0.2/32",
|
||||
"1.1.1.1",
|
||||
"1380",
|
||||
"example.invalid:51820",
|
||||
"0.0.0.0/0",
|
||||
"25",
|
||||
] {
|
||||
assert!(!debug.contains(raw_value));
|
||||
}
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
let _ = server_private;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_invalid_duplicates_and_server_only_are_reported_and_blocked() {
|
||||
let (client, server, psk) = generated();
|
||||
let key = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap();
|
||||
let file = client_config(&client, &server, &psk, "10.0.0.9/32");
|
||||
let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32"))
|
||||
.unwrap();
|
||||
let report = reconcile(
|
||||
&doc,
|
||||
[
|
||||
("one.conf".into(), file.as_bytes().to_vec()),
|
||||
("two.conf".into(), file.as_bytes().to_vec()),
|
||||
(
|
||||
"bad.conf".into(),
|
||||
b"[Interface]\nPrivateKey = nope\n".to_vec(),
|
||||
),
|
||||
],
|
||||
);
|
||||
let statuses = report.candidate_summaries();
|
||||
assert_eq!(statuses[0].1, CandidateStatus::DuplicateFileIdentity);
|
||||
assert_eq!(statuses[1].1, CandidateStatus::DuplicateFileIdentity);
|
||||
assert!(matches!(statuses[2].1, CandidateStatus::Invalid(_)));
|
||||
assert!(report.server_only().is_empty());
|
||||
|
||||
let single = reconcile(&doc, [("file.conf".into(), file.as_bytes().to_vec())]);
|
||||
assert_eq!(
|
||||
single.candidate_summaries()[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: true,
|
||||
server_identity: ServerIdentityComparison::Matches,
|
||||
missing_metadata: true,
|
||||
invalid_metadata: false,
|
||||
unsupported_projection: false,
|
||||
}
|
||||
);
|
||||
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
|
||||
single.encrypt_candidate(&mut bundle, 0).unwrap();
|
||||
assert_eq!(bundle.reissue_blocked(key.public_key()), Ok(true));
|
||||
assert_eq!(
|
||||
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
|
||||
Some(VaultError::ReissueBlocked)
|
||||
);
|
||||
|
||||
let empty_doc =
|
||||
ConfigDocument::parse(format!("[Interface]\nPrivateKey = {server}\n")).unwrap();
|
||||
let file_only = reconcile(&empty_doc, [("file.conf".into(), file.as_bytes().to_vec())]);
|
||||
assert_eq!(
|
||||
file_only.candidate_summaries()[0].1,
|
||||
CandidateStatus::FileOnly
|
||||
);
|
||||
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
|
||||
file_only.encrypt_candidate(&mut bundle, 0).unwrap();
|
||||
assert!(bundle.reissue_blocked(key.public_key()).unwrap());
|
||||
|
||||
let unused_key = server_private.public_key();
|
||||
assert!(!report.server_only().contains(&unused_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_public_gate_cannot_authorize_reissue_and_duplicates_never_overwrite() {
|
||||
let (client, server, psk) = generated();
|
||||
let key = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32"))
|
||||
.unwrap();
|
||||
let report = reconcile(
|
||||
&doc,
|
||||
[(
|
||||
"file.conf".into(),
|
||||
client_config(&client, &server, &psk, "10.0.0.3/32").into_bytes(),
|
||||
)],
|
||||
);
|
||||
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
|
||||
report.encrypt_candidate(&mut bundle, 0).unwrap();
|
||||
let before = bundle.to_toml().unwrap();
|
||||
assert_eq!(
|
||||
bundle.encrypt_record(key.public_key(), &key),
|
||||
Err(VaultError::DuplicateRecord)
|
||||
);
|
||||
let mut changed = before.replace("reissue_blocked = true", "reissue_blocked = false");
|
||||
let loaded = VaultBundle::from_toml(&changed).unwrap();
|
||||
assert_eq!(loaded.reissue_blocked(key.public_key()), Ok(false));
|
||||
assert_eq!(
|
||||
loaded.retrieve_record(key.public_key(), PASSWORD).err(),
|
||||
Some(VaultError::ReissueBlocked)
|
||||
);
|
||||
changed.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_report_debug_are_redacted() {
|
||||
let (client, server, psk) = generated();
|
||||
let source = format!(
|
||||
"{}PrivateKey = SYNTHETIC-MISPLACED-PRIVATE-VALUE\n",
|
||||
client_config(&client, &server, &psk, "10.0.0.2/32")
|
||||
);
|
||||
let parsed = parse_client_config(source.as_bytes()).unwrap();
|
||||
let debug = format!("{parsed:?}");
|
||||
assert!(!debug.contains(&client));
|
||||
assert!(!debug.contains(&psk));
|
||||
assert!(!debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE"));
|
||||
|
||||
let malformed =
|
||||
format!("{source}[Unknown]\nCredential = SYNTHETIC-REJECTED-SECRET-VALUE\n");
|
||||
let error = parse_client_config(malformed.as_bytes()).unwrap_err();
|
||||
assert_eq!(error, ClientFileIssue::InvalidStructure);
|
||||
assert!(!format!("{error:?}").contains("SYNTHETIC-REJECTED-SECRET-VALUE"));
|
||||
|
||||
let invalid_psk = client_config(
|
||||
&client,
|
||||
&server,
|
||||
"SYNTHETIC-INVALID-PSK-VALUE",
|
||||
"10.0.0.2/32",
|
||||
);
|
||||
let error = parse_client_config(invalid_psk.as_bytes()).unwrap_err();
|
||||
assert_eq!(error, ClientFileIssue::InvalidPresharedKey);
|
||||
assert!(!format!("{error:?}").contains("SYNTHETIC-INVALID-PSK-VALUE"));
|
||||
|
||||
let report_debug = format!(
|
||||
"{:?}",
|
||||
reconcile(
|
||||
&ConfigDocument::parse("[Interface]\n").unwrap(),
|
||||
[("file".into(), malformed.into_bytes())]
|
||||
)
|
||||
);
|
||||
assert!(!report_debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE"));
|
||||
assert!(!report_debug.contains("SYNTHETIC-REJECTED-SECRET-VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_separates_server_only_binding_and_projection_anomalies() {
|
||||
let (client, server, psk) = generated();
|
||||
let (_, other_server, _) = generated();
|
||||
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let unmatched = WireGuardPrivateKey::generate().unwrap().public_key();
|
||||
let config = format!(
|
||||
"[Interface]\nPrivateKey = {server}\n# wg-admin:begin\ninvalid metadata\n# wg-admin:end\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {unmatched}\nAllowedIPs = 10.9.0.1/24\n",
|
||||
client_key.public_key()
|
||||
);
|
||||
let client_file = client_config(&client, &other_server, &psk, "10.0.0.2/24");
|
||||
let report = reconcile(
|
||||
&ConfigDocument::parse(config).unwrap(),
|
||||
[("candidate.conf".into(), client_file.into_bytes())],
|
||||
);
|
||||
assert_eq!(report.server_only(), &[unmatched]);
|
||||
assert_eq!(
|
||||
report.candidate_summaries()[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: ServerIdentityComparison::Mismatch,
|
||||
missing_metadata: false,
|
||||
invalid_metadata: true,
|
||||
unsupported_projection: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_server_identity_is_distinct_and_blocks_reissue() {
|
||||
let (client, server, psk) = generated();
|
||||
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let server_doc = ConfigDocument::parse(format!(
|
||||
"[Interface]\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n",
|
||||
client_key.public_key()
|
||||
))
|
||||
.unwrap();
|
||||
let file = client_config(&client, &server, &psk, "10.0.0.2/32");
|
||||
let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]);
|
||||
assert_eq!(
|
||||
report.candidate_summaries()[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: ServerIdentityComparison::Unresolved,
|
||||
missing_metadata: true,
|
||||
invalid_metadata: false,
|
||||
unsupported_projection: false,
|
||||
}
|
||||
);
|
||||
|
||||
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
|
||||
report.encrypt_candidate(&mut bundle, 0).unwrap();
|
||||
assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true));
|
||||
assert_eq!(
|
||||
bundle
|
||||
.retrieve_record(client_key.public_key(), PASSWORD)
|
||||
.err(),
|
||||
Some(VaultError::ReissueBlocked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_key_mismatch_alone_allows_encryption_but_blocks_reissue() {
|
||||
let (client, server, psk) = generated();
|
||||
let (_, other_server, _) = generated();
|
||||
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
|
||||
let server_doc = ConfigDocument::parse(server_config(
|
||||
&server,
|
||||
&client_key.public_key(),
|
||||
"10.0.0.2/32",
|
||||
))
|
||||
.unwrap();
|
||||
let file = client_config(&client, &other_server, &psk, "10.0.0.2/32");
|
||||
let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]);
|
||||
assert_eq!(
|
||||
report.candidate_summaries()[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: ServerIdentityComparison::Mismatch,
|
||||
missing_metadata: true,
|
||||
invalid_metadata: false,
|
||||
unsupported_projection: false,
|
||||
}
|
||||
);
|
||||
|
||||
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
|
||||
assert!(report.encrypt_candidate(&mut bundle, 0).is_ok());
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true));
|
||||
assert_eq!(
|
||||
bundle
|
||||
.retrieve_record(client_key.public_key(), PASSWORD)
|
||||
.err(),
|
||||
Some(VaultError::ReissueBlocked)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! Bounded, read-only access to configured client source files.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Take};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rustix::fd::OwnedFd;
|
||||
use rustix::fs::{self as rfs, FileType, Mode, OFlags, ResolveFlags};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::ResolvedSettings;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::import::{CandidateStatus, ImportReport, reconcile, reconcile_zeroizing};
|
||||
use super::{VaultBundle, VaultError};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Only immediate `*.conf` entries are candidates. Matching is ASCII-case-insensitive.
|
||||
pub const MAX_CANDIDATE_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_SCAN_BYTES: usize = 32 * 1024 * 1024;
|
||||
pub const MAX_CANDIDATES: usize = 1024;
|
||||
pub const MAX_DIRECTORY_ENTRIES: usize = 4096;
|
||||
|
||||
/// A source failure safe to show in a UI or log. It never contains I/O error text.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SourceFailureKind {
|
||||
DirectoryUnavailable,
|
||||
EnumerationFailed,
|
||||
TooManyEntries,
|
||||
FileUnavailable,
|
||||
NotRegularFile,
|
||||
FileTooLarge,
|
||||
ScanTooLarge,
|
||||
ReadFailed,
|
||||
EncryptionFailed,
|
||||
DuplicateRecordEqualityUnverified,
|
||||
}
|
||||
|
||||
/// Per-file issue with a display-only filename and a redacted failure category.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SourceIssue {
|
||||
pub file_name: Option<String>,
|
||||
pub kind: SourceFailureKind,
|
||||
}
|
||||
|
||||
/// Directory scan and reconciliation result. `scan_complete` is false whenever
|
||||
/// enumeration or a configured limit prevented a complete inventory.
|
||||
pub struct SourceImportReport {
|
||||
pub scan_complete: bool,
|
||||
pub issues: Vec<SourceIssue>,
|
||||
pub reconciliation: ImportReport,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SourceImportReport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SourceImportReport")
|
||||
.field("scan_complete", &self.scan_complete)
|
||||
.field("issues", &self.issues)
|
||||
.field("reconciliation", &self.reconciliation)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle prepared for a later explicit persistence/Commit operation.
|
||||
pub struct PreparedImportSetup {
|
||||
pub bundle: VaultBundle,
|
||||
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
|
||||
/// configured source directory. This only returns prepared in-memory data.
|
||||
pub fn prepare_initial_setup(
|
||||
settings: &ResolvedSettings,
|
||||
server: &ConfigDocument,
|
||||
vault_password: &str,
|
||||
) -> Result<PreparedImportSetup, SourceImportError> {
|
||||
let mut bundle = VaultBundle::create(vault_password)?;
|
||||
let report = import_directory(&mut bundle, settings, server)?;
|
||||
Ok(PreparedImportSetup { bundle, report })
|
||||
}
|
||||
|
||||
/// Explicitly import configured candidates into an existing prepared bundle.
|
||||
/// It uses the bundle's public recipient and never unlocks or replaces its identity.
|
||||
pub fn import_configured_directory(
|
||||
bundle: &mut VaultBundle,
|
||||
settings: &ResolvedSettings,
|
||||
server: &ConfigDocument,
|
||||
) -> Result<SourceImportReport, SourceImportError> {
|
||||
import_directory(bundle, settings, server)
|
||||
}
|
||||
|
||||
fn import_directory(
|
||||
bundle: &mut VaultBundle,
|
||||
settings: &ResolvedSettings,
|
||||
server: &ConfigDocument,
|
||||
) -> Result<SourceImportReport, SourceImportError> {
|
||||
let source = &settings.client_source_directory;
|
||||
if !source.is_absolute() {
|
||||
return Err(SourceImportError::InvalidDirectory);
|
||||
}
|
||||
|
||||
let directory = match open_source_directory(source) {
|
||||
Ok(fd) => fd,
|
||||
Err(_) => {
|
||||
return Ok(SourceImportReport {
|
||||
scan_complete: false,
|
||||
issues: vec![SourceIssue {
|
||||
file_name: None,
|
||||
kind: SourceFailureKind::DirectoryUnavailable,
|
||||
}],
|
||||
reconciliation: reconcile(server, []),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let entries = match rfs::Dir::read_from(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => {
|
||||
return Ok(SourceImportReport {
|
||||
scan_complete: false,
|
||||
issues: vec![SourceIssue {
|
||||
file_name: None,
|
||||
kind: SourceFailureKind::EnumerationFailed,
|
||||
}],
|
||||
reconciliation: reconcile(server, []),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(import_open_directory(bundle, server, &directory, entries))
|
||||
}
|
||||
|
||||
fn import_open_directory(
|
||||
bundle: &mut VaultBundle,
|
||||
server: &ConfigDocument,
|
||||
directory: &OwnedFd,
|
||||
mut entries: rfs::Dir,
|
||||
) -> SourceImportReport {
|
||||
let entries = std::iter::from_fn(move || {
|
||||
entries.read().map(|entry| {
|
||||
entry.map_err(std::io::Error::from).map(|entry| {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
OsString::from(std::ffi::OsStr::from_bytes(entry.file_name().to_bytes()))
|
||||
})
|
||||
})
|
||||
});
|
||||
import_entries(bundle, server, directory, entries)
|
||||
}
|
||||
|
||||
fn import_entries(
|
||||
bundle: &mut VaultBundle,
|
||||
server: &ConfigDocument,
|
||||
directory: &OwnedFd,
|
||||
entries: impl IntoIterator<Item = std::io::Result<OsString>>,
|
||||
) -> SourceImportReport {
|
||||
import_entries_with_limits(
|
||||
bundle,
|
||||
server,
|
||||
directory,
|
||||
entries,
|
||||
MAX_SCAN_BYTES,
|
||||
MAX_DIRECTORY_ENTRIES,
|
||||
)
|
||||
}
|
||||
|
||||
fn import_entries_with_limits(
|
||||
bundle: &mut VaultBundle,
|
||||
server: &ConfigDocument,
|
||||
directory: &OwnedFd,
|
||||
entries: impl IntoIterator<Item = std::io::Result<OsString>>,
|
||||
max_scan_bytes: usize,
|
||||
max_directory_entries: usize,
|
||||
) -> SourceImportReport {
|
||||
let mut complete = true;
|
||||
let mut issues = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
let mut aggregate_bytes = 0usize;
|
||||
let mut candidate_count = 0usize;
|
||||
for (examined_entries, entry) in entries.into_iter().enumerate() {
|
||||
if examined_entries >= max_directory_entries {
|
||||
complete = false;
|
||||
issues.push(SourceIssue {
|
||||
file_name: None,
|
||||
kind: SourceFailureKind::TooManyEntries,
|
||||
});
|
||||
break;
|
||||
}
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => {
|
||||
complete = false;
|
||||
issues.push(SourceIssue {
|
||||
file_name: None,
|
||||
kind: SourceFailureKind::EnumerationFailed,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let name = entry;
|
||||
if !is_candidate_name(&name) {
|
||||
continue;
|
||||
}
|
||||
if candidate_count >= MAX_CANDIDATES {
|
||||
complete = false;
|
||||
issues.push(SourceIssue {
|
||||
file_name: None,
|
||||
kind: SourceFailureKind::TooManyEntries,
|
||||
});
|
||||
break;
|
||||
}
|
||||
candidate_count += 1;
|
||||
let display_name = safe_file_name(&name);
|
||||
match read_candidate(directory, &name, aggregate_bytes, max_scan_bytes) {
|
||||
Ok(bytes) => {
|
||||
aggregate_bytes += bytes.len();
|
||||
candidates.push((display_name, bytes));
|
||||
}
|
||||
Err(kind) => {
|
||||
complete = false;
|
||||
issues.push(SourceIssue {
|
||||
file_name: Some(display_name),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reconciliation = reconcile_zeroizing(server, candidates);
|
||||
let summaries = reconciliation.candidate_summaries();
|
||||
for (index, (name, status)) in summaries.iter().enumerate() {
|
||||
if matches!(
|
||||
status,
|
||||
super::import::CandidateStatus::Invalid(_)
|
||||
| super::import::CandidateStatus::DuplicateFileIdentity
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = reconciliation.encrypt_candidate(bundle, index) {
|
||||
complete = false;
|
||||
issues.push(SourceIssue {
|
||||
file_name: Some((*name).to_owned()),
|
||||
kind: match error {
|
||||
VaultError::DuplicateRecord => {
|
||||
SourceFailureKind::DuplicateRecordEqualityUnverified
|
||||
}
|
||||
_ => SourceFailureKind::EncryptionFailed,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SourceImportReport {
|
||||
scan_complete: complete,
|
||||
issues,
|
||||
reconciliation,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_source_directory(path: &Path) -> std::io::Result<OwnedFd> {
|
||||
rfs::openat2(
|
||||
rfs::CWD,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
|
||||
)
|
||||
.map_err(std::io::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn open_source_directory(_path: &Path) -> std::io::Result<OwnedFd> {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"secure directory opening is unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
fn read_candidate(
|
||||
directory: &OwnedFd,
|
||||
name: &std::ffi::OsStr,
|
||||
aggregate_bytes: usize,
|
||||
max_scan_bytes: usize,
|
||||
) -> Result<Zeroizing<Vec<u8>>, SourceFailureKind> {
|
||||
let file = open_source_file(directory, name).map_err(|_| SourceFailureKind::FileUnavailable)?;
|
||||
let stat = rfs::fstat(&file).map_err(|_| SourceFailureKind::FileUnavailable)?;
|
||||
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
|
||||
return Err(SourceFailureKind::NotRegularFile);
|
||||
}
|
||||
let size = usize::try_from(stat.st_size).map_err(|_| SourceFailureKind::FileTooLarge)?;
|
||||
if size > MAX_CANDIDATE_BYTES {
|
||||
return Err(SourceFailureKind::FileTooLarge);
|
||||
}
|
||||
let remaining_scan_bytes = max_scan_bytes.saturating_sub(aggregate_bytes);
|
||||
if size > remaining_scan_bytes {
|
||||
return Err(SourceFailureKind::ScanTooLarge);
|
||||
}
|
||||
let mut file = File::from(file);
|
||||
let mut bytes = Zeroizing::new(Vec::with_capacity(size.min(MAX_CANDIDATE_BYTES)));
|
||||
let read_limit = MAX_CANDIDATE_BYTES.min(remaining_scan_bytes);
|
||||
let mut reader: Take<&mut File> = (&mut file).take((read_limit + 1) as u64);
|
||||
reader
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| SourceFailureKind::ReadFailed)?;
|
||||
if bytes.len() > MAX_CANDIDATE_BYTES {
|
||||
return Err(SourceFailureKind::FileTooLarge);
|
||||
}
|
||||
if bytes.len() > remaining_scan_bytes {
|
||||
return Err(SourceFailureKind::ScanTooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_source_file(directory: &OwnedFd, name: &std::ffi::OsStr) -> std::io::Result<OwnedFd> {
|
||||
rfs::openat2(
|
||||
directory,
|
||||
PathBuf::from(name),
|
||||
OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
|
||||
)
|
||||
.map_err(std::io::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn open_source_file(_directory: &OwnedFd, _name: &std::ffi::OsStr) -> std::io::Result<OwnedFd> {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"secure file opening is unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
fn is_candidate_name(name: &std::ffi::OsStr) -> bool {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("conf"))
|
||||
}
|
||||
|
||||
fn safe_file_name(name: &std::ffi::OsStr) -> String {
|
||||
let raw = name.to_string_lossy();
|
||||
raw.chars()
|
||||
.map(|character| {
|
||||
if character.is_control() {
|
||||
'\u{fffd}'
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.take(255)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::WireGuardPrivateKey;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn make_private(path: &Path) {
|
||||
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();
|
||||
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 client_bytes = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n",
|
||||
client_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
let source_file = directory.path().join("valid.conf");
|
||||
fs::write(&source_file, &client_bytes).unwrap();
|
||||
make_private(&source_file);
|
||||
let dirfd = open_source_directory(directory.path()).unwrap();
|
||||
let entries = [
|
||||
Ok(OsString::from("valid.conf")),
|
||||
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
|
||||
];
|
||||
let mut bundle = VaultBundle::create("password").unwrap();
|
||||
let report = import_entries(&mut bundle, &server, &dirfd, entries);
|
||||
assert!(!report.scan_complete);
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert!(
|
||||
report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.kind == SourceFailureKind::EnumerationFailed)
|
||||
);
|
||||
assert!(fs::read(directory.path().join("valid.conf")).unwrap() == client_bytes.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumeration_and_reads_stay_pinned_after_source_path_is_replaced() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let source = parent.path().join("source");
|
||||
let pinned_path = parent.path().join("pinned");
|
||||
fs::create_dir(&source).unwrap();
|
||||
let server_key = WireGuardPrivateKey::generate().unwrap();
|
||||
let client_key = WireGuardPrivateKey::generate().unwrap();
|
||||
let decoy_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()
|
||||
))
|
||||
.unwrap();
|
||||
let bytes = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n",
|
||||
client_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
let client_file = source.join("client.conf");
|
||||
fs::write(&client_file, &bytes).unwrap();
|
||||
make_private(&client_file);
|
||||
|
||||
let directory = open_source_directory(&source).unwrap();
|
||||
fs::rename(&source, &pinned_path).unwrap();
|
||||
fs::create_dir(&source).unwrap();
|
||||
let replacement = source.join("replacement.conf");
|
||||
fs::write(
|
||||
&replacement,
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n",
|
||||
decoy_key.to_base64(),
|
||||
server_key.public_key()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
make_private(&replacement);
|
||||
|
||||
let entries = rfs::Dir::read_from(&directory).unwrap();
|
||||
let mut bundle = VaultBundle::create("password").unwrap();
|
||||
let report = import_open_directory(&mut bundle, &server, &directory, entries);
|
||||
|
||||
assert!(report.scan_complete);
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert!(report.reconciliation.server_only().is_empty());
|
||||
assert_eq!(
|
||||
report.reconciliation.candidate_summaries()[0].1,
|
||||
super::super::import::CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: super::super::import::ServerIdentityComparison::Matches,
|
||||
missing_metadata: true,
|
||||
invalid_metadata: false,
|
||||
unsupported_projection: false,
|
||||
}
|
||||
);
|
||||
assert!(fs::read(pinned_path.join("client.conf")).unwrap() == bytes.as_bytes());
|
||||
assert!(fs::read_dir(&source).unwrap().next().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_scan_byte_limit_is_enforced_after_successful_reads() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(directory.path().join("one.conf"), b"one").unwrap();
|
||||
fs::write(directory.path().join("two.conf"), b"two").unwrap();
|
||||
let server = ConfigDocument::parse("[Interface]\n").unwrap();
|
||||
let dirfd = open_source_directory(directory.path()).unwrap();
|
||||
let entries = [
|
||||
Ok(OsString::from("one.conf")),
|
||||
Ok(OsString::from("two.conf")),
|
||||
];
|
||||
let mut bundle = VaultBundle::create("password").unwrap();
|
||||
|
||||
let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 10);
|
||||
|
||||
assert!(!report.scan_complete);
|
||||
assert_eq!(
|
||||
report.issues,
|
||||
[SourceIssue {
|
||||
file_name: Some("two.conf".to_owned()),
|
||||
kind: SourceFailureKind::ScanTooLarge,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_entry_limit_marks_inventory_incomplete() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let dirfd = open_source_directory(directory.path()).unwrap();
|
||||
let server = ConfigDocument::parse("[Interface]\n").unwrap();
|
||||
let entries = [
|
||||
Ok(OsString::from("one.txt")),
|
||||
Ok(OsString::from("two.txt")),
|
||||
Ok(OsString::from("three.txt")),
|
||||
];
|
||||
let mut bundle = VaultBundle::create("password").unwrap();
|
||||
|
||||
let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 2);
|
||||
|
||||
assert!(!report.scan_complete);
|
||||
assert_eq!(
|
||||
report.issues,
|
||||
[SourceIssue {
|
||||
file_name: None,
|
||||
kind: SourceFailureKind::TooManyEntries,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Pure parsing and inspection of caller-supplied WireGuard server configuration.
|
||||
//!
|
||||
//! Parsing never reads or writes files and never changes runtime state.
|
||||
|
||||
pub mod document;
|
||||
pub mod edit;
|
||||
pub mod metadata;
|
||||
|
||||
pub use document::{
|
||||
ConfigDocument, DirectiveSpan, DocumentError, DocumentIssue, DocumentSection, PeerInspection,
|
||||
PeerIssue, SectionKind, SourceSpan,
|
||||
};
|
||||
pub use edit::{
|
||||
EditError, add_client, delete_client, insert_legacy_metadata, project_client, rename_client,
|
||||
};
|
||||
pub use metadata::{ManagedMetadata, MetadataAttachment, MetadataError, MetadataIssue};
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Lossless byte-span representation and redacted public inspection.
|
||||
//!
|
||||
//! The supported input encoding is UTF-8. Input that is not valid UTF-8 is
|
||||
//! rejected, without including any source bytes in the error. The document
|
||||
//! retains the exact input bytes, including mixed line endings and a missing
|
||||
//! final newline. Public views expose only section/key spans, peer public keys,
|
||||
//! and parsed AllowedIPs; no arbitrary directive values are exposed because
|
||||
//! they may contain secrets.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use ipnet::IpNet;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
use super::metadata::{self, MetadataAttachment, MetadataIssue};
|
||||
|
||||
/// A half-open byte range into the original UTF-8 input.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SourceSpan {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl SourceSpan {
|
||||
/// Construct a span from byte offsets. Returns `None` when the end precedes the start.
|
||||
pub const fn from_offsets(start: usize, end: usize) -> Option<Self> {
|
||||
if start <= end {
|
||||
Some(Self { start, end })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn start(self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
pub const fn end(self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
pub const fn len(self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// The section type recognized from a bracketed section header.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SectionKind {
|
||||
Interface,
|
||||
Peer,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A recognized section and its original byte boundaries.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct DocumentSection {
|
||||
pub kind: SectionKind,
|
||||
/// Header name after trimming surrounding ASCII whitespace.
|
||||
pub name: String,
|
||||
/// Includes the header and section body, ending immediately before the next header.
|
||||
pub span: SourceSpan,
|
||||
/// Header line only, excluding its original line ending.
|
||||
pub header_span: SourceSpan,
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DocumentSection {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DocumentSection")
|
||||
.field("kind", &self.kind)
|
||||
.field("span", &self.span)
|
||||
.field("header_span", &self.header_span)
|
||||
.field("line", &self.line)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Spans of a recognized key/value directive; values remain private to the document.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DirectiveSpan {
|
||||
pub line: usize,
|
||||
pub whole: SourceSpan,
|
||||
pub key: SourceSpan,
|
||||
pub value: SourceSpan,
|
||||
}
|
||||
|
||||
/// A public, secret-safe inspection of one `[Peer]` section.
|
||||
///
|
||||
/// Conversion to the narrow `domain::Client` model is intentionally deferred
|
||||
/// to the M2.3 adapter: this view has no alias until managed metadata is parsed.
|
||||
/// That conversion must require one unambiguous identity and route shapes that
|
||||
/// fit `ClientAddresses`; it must reject rather than truncate other AllowedIPs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PeerInspection {
|
||||
pub index: usize,
|
||||
pub section: SourceSpan,
|
||||
pub section_line: usize,
|
||||
/// Present only when this section has exactly one valid PublicKey directive.
|
||||
pub identity: Option<PublicKey>,
|
||||
/// Every valid public key value, even when the section is ambiguous.
|
||||
pub public_key_candidates: Vec<PublicKey>,
|
||||
/// Every PublicKey directive is retained as a span, including duplicates and malformed values.
|
||||
pub public_key_fields: Vec<DirectiveSpan>,
|
||||
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
|
||||
pub allowed_ips: Vec<IpNet>,
|
||||
/// True when a valid parsed route is a subnet rather than a host route.
|
||||
/// This is a capability limitation for the current client model, not a
|
||||
/// malformed configuration issue.
|
||||
pub has_subnet_routes: bool,
|
||||
pub allowed_ips_fields: Vec<DirectiveSpan>,
|
||||
/// Parsed alias and safe owned block span, or an explicit metadata issue.
|
||||
pub metadata: MetadataAttachment,
|
||||
pub issues: Vec<PeerIssue>,
|
||||
}
|
||||
|
||||
/// Issues found while inspecting a peer. Variants contain no source text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PeerIssue {
|
||||
MissingPublicKey,
|
||||
MalformedPublicKey { field: usize },
|
||||
DuplicatePublicKeyField { count: usize },
|
||||
DuplicatePeerIdentity { other_peer_indices: Vec<usize> },
|
||||
MalformedAllowedIp { field: usize, item: usize },
|
||||
UnsupportedAllowedIpShape { field: usize, item: usize },
|
||||
MissingIpv4HostRoute,
|
||||
}
|
||||
|
||||
/// Structural issues found outside peer-level inspection.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DocumentIssue {
|
||||
MalformedSectionHeader { line: usize },
|
||||
DirectiveOutsideSection { line: usize },
|
||||
MalformedDirective { line: usize },
|
||||
ManagedMetadata(MetadataIssue),
|
||||
}
|
||||
|
||||
/// A document-level parse failure. It intentionally never contains input bytes or text.
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum DocumentError {
|
||||
#[error("server configuration is not valid UTF-8")]
|
||||
InvalidEncoding,
|
||||
#[error("server configuration could not be inspected safely")]
|
||||
InvalidStructure,
|
||||
}
|
||||
|
||||
/// Parsed view over the exact caller-supplied configuration bytes.
|
||||
pub struct ConfigDocument {
|
||||
source: String,
|
||||
sections: Vec<DocumentSection>,
|
||||
peers: Vec<PeerInspection>,
|
||||
issues: Vec<DocumentIssue>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ConfigDocument {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ConfigDocument")
|
||||
.field("source_len", &self.source.len())
|
||||
.field("sections", &self.sections)
|
||||
.field("peers", &self.peers)
|
||||
.field("issues", &self.issues)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigDocument {
|
||||
/// Parse UTF-8 configuration supplied by the caller while retaining every source byte.
|
||||
pub fn parse(source: impl AsRef<[u8]>) -> Result<Self, DocumentError> {
|
||||
let bytes = source.as_ref();
|
||||
let source = std::str::from_utf8(bytes).map_err(|_| DocumentError::InvalidEncoding)?;
|
||||
let mut sections = Vec::<DocumentSection>::new();
|
||||
let mut issues = Vec::<DocumentIssue>::new();
|
||||
let mut peer_fields = Vec::<(
|
||||
usize,
|
||||
Vec<DirectiveSpan>,
|
||||
Vec<(DirectiveSpan, Vec<(usize, Result<IpNet, ()>)>)>,
|
||||
)>::new();
|
||||
let mut current_section: Option<usize> = None;
|
||||
let mut offset = 0usize;
|
||||
let mut line_number = 0usize;
|
||||
|
||||
for line_with_ending in source.split_inclusive('\n') {
|
||||
line_number += 1;
|
||||
let line_start = offset;
|
||||
offset += line_with_ending.len();
|
||||
let content = line_with_ending
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(line_with_ending);
|
||||
let content = content.strip_suffix('\r').unwrap_or(content);
|
||||
let line_end = line_start + content.len();
|
||||
let span = SourceSpan {
|
||||
start: line_start,
|
||||
end: offset,
|
||||
};
|
||||
let content_span = SourceSpan {
|
||||
start: line_start,
|
||||
end: line_end,
|
||||
};
|
||||
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with('[') || trimmed.ends_with(']') {
|
||||
if let Some(name) = parse_section_name(trimmed) {
|
||||
if let Some(previous) = current_section {
|
||||
sections[previous].span.end = line_start;
|
||||
}
|
||||
let normalized = name.to_ascii_lowercase();
|
||||
let kind = match normalized.as_str() {
|
||||
"interface" => SectionKind::Interface,
|
||||
"peer" => SectionKind::Peer,
|
||||
_ => SectionKind::Other,
|
||||
};
|
||||
sections.push(DocumentSection {
|
||||
kind,
|
||||
name: name.to_owned(),
|
||||
span: SourceSpan {
|
||||
start: line_start,
|
||||
end: source.len(),
|
||||
},
|
||||
header_span: content_span,
|
||||
line: line_number,
|
||||
});
|
||||
current_section = Some(sections.len() - 1);
|
||||
if kind == SectionKind::Peer {
|
||||
peer_fields.push((line_number, Vec::new(), Vec::new()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
issues.push(DocumentIssue::MalformedSectionHeader { line: line_number });
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(equal_index) = content.find('=') else {
|
||||
issues.push(DocumentIssue::MalformedDirective { line: line_number });
|
||||
continue;
|
||||
};
|
||||
let raw_key = &content[..equal_index];
|
||||
let raw_value = &content[equal_index + 1..];
|
||||
let key = raw_key.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
if key.is_empty() {
|
||||
issues.push(DocumentIssue::MalformedDirective { line: line_number });
|
||||
continue;
|
||||
}
|
||||
let raw_key_start = raw_key.len()
|
||||
- raw_key
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let raw_key_end = raw_key
|
||||
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let raw_value_start = raw_value.len()
|
||||
- raw_value
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let raw_value_end = raw_value
|
||||
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let directive = DirectiveSpan {
|
||||
line: line_number,
|
||||
whole: span,
|
||||
key: SourceSpan {
|
||||
start: line_start + raw_key_start,
|
||||
end: line_start + raw_key_end,
|
||||
},
|
||||
value: SourceSpan {
|
||||
start: line_start + equal_index + 1 + raw_value_start,
|
||||
end: line_start + equal_index + 1 + raw_value_end,
|
||||
},
|
||||
};
|
||||
let Some(section_index) = current_section else {
|
||||
issues.push(DocumentIssue::DirectiveOutsideSection { line: line_number });
|
||||
continue;
|
||||
};
|
||||
if sections[section_index].kind != SectionKind::Peer {
|
||||
continue;
|
||||
}
|
||||
let peer_index = peer_fields.len() - 1;
|
||||
let (_, public_keys, allowed_fields) = &mut peer_fields[peer_index];
|
||||
if key.eq_ignore_ascii_case("PublicKey") {
|
||||
public_keys.push(directive);
|
||||
} else if key.eq_ignore_ascii_case("AllowedIPs") {
|
||||
let value = &source[directive.value.start..directive.value.end];
|
||||
let parsed = value
|
||||
.split(',')
|
||||
.enumerate()
|
||||
.map(|(item, raw)| {
|
||||
let item_value = raw.trim();
|
||||
(item, IpNet::from_str(item_value).map_err(|_| ()))
|
||||
})
|
||||
.collect();
|
||||
allowed_fields.push((directive, parsed));
|
||||
}
|
||||
}
|
||||
|
||||
// `split_inclusive` yields no item for an empty string and its final item does not
|
||||
// necessarily end in LF; offset therefore tracks all source bytes exactly.
|
||||
let mut peers = Vec::with_capacity(peer_fields.len());
|
||||
for (index, (section_line, public_key_fields, allowed_fields)) in
|
||||
peer_fields.into_iter().enumerate()
|
||||
{
|
||||
let Some(section) = sections
|
||||
.iter()
|
||||
.filter(|section| section.kind == SectionKind::Peer)
|
||||
.nth(index)
|
||||
else {
|
||||
return Err(DocumentError::InvalidStructure);
|
||||
};
|
||||
let mut peer_issues = Vec::new();
|
||||
let parsed_keys: Vec<_> = public_key_fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(field, directive)| {
|
||||
let value = &source[directive.value.start..directive.value.end];
|
||||
match PublicKey::from_str(value) {
|
||||
Ok(key) => Some(key),
|
||||
Err(_) => {
|
||||
peer_issues.push(PeerIssue::MalformedPublicKey { field });
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if public_key_fields.is_empty() {
|
||||
peer_issues.push(PeerIssue::MissingPublicKey);
|
||||
}
|
||||
if public_key_fields.len() > 1 {
|
||||
peer_issues.push(PeerIssue::DuplicatePublicKeyField {
|
||||
count: public_key_fields.len(),
|
||||
});
|
||||
}
|
||||
let identity = if public_key_fields.len() == 1 {
|
||||
parsed_keys[0]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut allowed_ips = Vec::new();
|
||||
let mut allowed_ips_fields = Vec::new();
|
||||
let mut has_ipv4_host_route = false;
|
||||
let mut has_ipv6_host_route = false;
|
||||
let mut has_subnet_routes = false;
|
||||
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
|
||||
allowed_ips_fields.push(directive);
|
||||
for (item, result) in items {
|
||||
match result {
|
||||
Ok(net) => {
|
||||
has_subnet_routes |= match net {
|
||||
IpNet::V4(network) => network.prefix_len() < 32,
|
||||
IpNet::V6(network) => network.prefix_len() < 128,
|
||||
};
|
||||
let supported = match net {
|
||||
IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||
let first = !has_ipv4_host_route;
|
||||
has_ipv4_host_route = true;
|
||||
first
|
||||
}
|
||||
IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||
let first = !has_ipv6_host_route;
|
||||
has_ipv6_host_route = true;
|
||||
first
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !supported {
|
||||
peer_issues
|
||||
.push(PeerIssue::UnsupportedAllowedIpShape { field, item });
|
||||
}
|
||||
allowed_ips.push(net);
|
||||
}
|
||||
Err(()) => peer_issues.push(PeerIssue::MalformedAllowedIp { field, item }),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !has_ipv4_host_route {
|
||||
peer_issues.push(PeerIssue::MissingIpv4HostRoute);
|
||||
}
|
||||
peers.push(PeerInspection {
|
||||
index,
|
||||
section: section.span,
|
||||
section_line,
|
||||
identity,
|
||||
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
|
||||
public_key_fields,
|
||||
allowed_ips,
|
||||
has_subnet_routes,
|
||||
allowed_ips_fields,
|
||||
metadata: MetadataAttachment::Absent,
|
||||
issues: peer_issues,
|
||||
});
|
||||
}
|
||||
|
||||
let mut by_identity = BTreeMap::<PublicKey, Vec<usize>>::new();
|
||||
for peer in &peers {
|
||||
for identity in &peer.public_key_candidates {
|
||||
by_identity.entry(*identity).or_default().push(peer.index);
|
||||
}
|
||||
}
|
||||
for indices in by_identity.values().filter(|indices| indices.len() > 1) {
|
||||
let unique_indices: Vec<_> =
|
||||
indices.iter().copied().fold(Vec::new(), |mut acc, index| {
|
||||
if !acc.contains(&index) {
|
||||
acc.push(index);
|
||||
}
|
||||
acc
|
||||
});
|
||||
for index in &unique_indices {
|
||||
let other_peer_indices: Vec<_> = unique_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|other| other != index)
|
||||
.collect();
|
||||
if other_peer_indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
peers[*index]
|
||||
.issues
|
||||
.push(PeerIssue::DuplicatePeerIdentity { other_peer_indices });
|
||||
}
|
||||
}
|
||||
|
||||
let peer_headers: Vec<_> = peers.iter().map(|peer| peer.section.start()).collect();
|
||||
let (metadata_attachments, metadata_issues) =
|
||||
metadata::inspect_document(source, &peer_headers);
|
||||
for (peer, attachment) in peers.iter_mut().zip(metadata_attachments) {
|
||||
peer.metadata = attachment;
|
||||
}
|
||||
issues.extend(
|
||||
metadata_issues
|
||||
.into_iter()
|
||||
.map(DocumentIssue::ManagedMetadata),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
source: source.to_owned(),
|
||||
sections,
|
||||
peers,
|
||||
issues,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exact original bytes, without normalization or reserialization.
|
||||
pub fn render(&self) -> &[u8] {
|
||||
self.source.as_bytes()
|
||||
}
|
||||
|
||||
pub fn sections(&self) -> &[DocumentSection] {
|
||||
&self.sections
|
||||
}
|
||||
|
||||
pub fn peers(&self) -> &[PeerInspection] {
|
||||
&self.peers
|
||||
}
|
||||
|
||||
pub fn issues(&self) -> &[DocumentIssue] {
|
||||
&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> {
|
||||
self.source.get(span.start..span.end)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_section_name(trimmed: &str) -> Option<&str> {
|
||||
let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
|
||||
let name = inner.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
(!name.is_empty() && !name.contains(['[', ']'])).then_some(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
|
||||
#[test]
|
||||
fn wireguard_document_round_trips_line_ending_variants_byte_for_byte() {
|
||||
let inputs: &[&[u8]] = &[
|
||||
b"[Interface]\nAddress = 10.0.0.1/24\n# admin\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.0.0.2/32\n",
|
||||
b"[Interface]\r\nAddress = 10.0.0.1/24\r\n[Peer]\r\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n",
|
||||
b"# first\r\n[Interface]\nUnknown = value\r\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n# tail",
|
||||
b"[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
];
|
||||
for input in inputs {
|
||||
let document = ConfigDocument::parse(input).unwrap();
|
||||
assert_eq!(document.render(), *input);
|
||||
}
|
||||
}
|
||||
|
||||
#[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!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 2001:db8::2/128, 0.0.0.0/0\nPresharedKey = dummy-secret\nUnknown = retained\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 2001:db8::3/128\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
assert_eq!(document.peers().len(), 2);
|
||||
assert_eq!(document.peers()[0].allowed_ips.len(), 3);
|
||||
assert_eq!(document.peers()[1].allowed_ips.len(), 1);
|
||||
assert_eq!(document.peers()[0].identity.unwrap().to_string(), KEY_A);
|
||||
assert!(document.peers()[0].issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 2 }
|
||||
)));
|
||||
assert!(
|
||||
document.peers()[1]
|
||||
.issues
|
||||
.contains(&PeerIssue::MissingIpv4HostRoute)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_unsupported_route_text_is_preserved_and_reported() {
|
||||
let input =
|
||||
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = not-a-route, 2001:db8::2/128\n");
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
assert_eq!(peer.allowed_ips.len(), 1);
|
||||
assert!(
|
||||
peer.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }))
|
||||
);
|
||||
assert_eq!(document.render(), input.as_bytes());
|
||||
assert_eq!(
|
||||
document.source_slice(peer.allowed_ips_fields[0].value),
|
||||
Some("not-a-route, 2001:db8::2/128")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields() {
|
||||
let input = format!(
|
||||
"[Peer]\nPresharedKey = secret-a\n[Peer]\nPublicKey = bad-secret-shaped-input\nPublicKey = {KEY_A}\n[Peer]\nPublicKey = {KEY_A}\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
assert!(
|
||||
document.peers()[0]
|
||||
.issues
|
||||
.contains(&PeerIssue::MissingPublicKey)
|
||||
);
|
||||
assert!(
|
||||
document.peers()[1]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::MalformedPublicKey { .. }))
|
||||
);
|
||||
assert!(
|
||||
document.peers()[1]
|
||||
.issues
|
||||
.contains(&PeerIssue::DuplicatePublicKeyField { count: 2 })
|
||||
);
|
||||
assert!(
|
||||
document.peers()[2]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
|
||||
);
|
||||
let debug = format!("{document:?}");
|
||||
assert!(!debug.contains("secret-a"));
|
||||
assert!(!debug.contains("bad-secret-shaped-input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_errors_and_debug_redact_invalid_text_and_private_values() {
|
||||
let error =
|
||||
ConfigDocument::parse(b"[Interface]\nPrivateKey = definitely-secret\xff").unwrap_err();
|
||||
assert_eq!(error.to_string(), "server configuration is not valid UTF-8");
|
||||
assert!(!format!("{error:?}").contains("definitely-secret"));
|
||||
let document = ConfigDocument::parse(
|
||||
b"[Interface]\nPrivateKey = private-value\n[Peer]\nPresharedKey = peer-secret\n",
|
||||
)
|
||||
.unwrap();
|
||||
let debug = format!("{document:?}");
|
||||
assert!(!debug.contains("private-value"));
|
||||
assert!(!debug.contains("peer-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_sections_and_directive_spans_are_byte_offsets() {
|
||||
let input =
|
||||
format!("[Interface]\r\nPrivateKey = hidden\r\n[Peer]\r\nPublicKey = {KEY_A}\r\n");
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
let key_text = document
|
||||
.source_slice(peer.public_key_fields[0].value)
|
||||
.unwrap();
|
||||
assert_eq!(key_text, KEY_A);
|
||||
assert_eq!(peer.section.start(), input.find("[Peer]").unwrap());
|
||||
assert_eq!(peer.section.end(), input.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_document_exposes_valid_metadata_and_safe_owned_span() {
|
||||
let block = super::super::metadata::render_block("# café = \"laptop\"", None).unwrap();
|
||||
let input = format!("{block}[Peer]\nPublicKey = {KEY_A}\n");
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
let MetadataAttachment::Valid {
|
||||
metadata,
|
||||
block: span,
|
||||
} = &peer.metadata
|
||||
else {
|
||||
panic!("valid attached metadata should be available");
|
||||
};
|
||||
assert_eq!(metadata.alias, "# café = \"laptop\"");
|
||||
assert_eq!(document.source_slice(*span), Some(block.as_str()));
|
||||
assert!(document.issues().is_empty());
|
||||
assert_eq!(document.render(), input.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_document_reports_unsupported_metadata_without_source_text() {
|
||||
let input = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=99\n# wg-admin:alias=c2VjcmV0LW5vdGU\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
assert!(matches!(
|
||||
document.peers()[0].metadata,
|
||||
MetadataAttachment::Invalid(MetadataIssue::UnsupportedVersion { .. })
|
||||
));
|
||||
let debug = format!("{document:?}");
|
||||
assert!(!debug.contains("c2VjcmV0LW5vdGU"));
|
||||
assert!(!debug.contains("secret-note"));
|
||||
assert_eq!(document.render(), input.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Pure, span-based edits to caller-supplied WireGuard server documents.
|
||||
|
||||
use ipnet::IpNet;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::{Client, ClientAddresses, PublicKey, validate_alias};
|
||||
|
||||
use super::document::{ConfigDocument, PeerInspection, PeerIssue};
|
||||
use super::metadata::{MetadataAttachment, MetadataError, render_block};
|
||||
|
||||
/// Redacted failures from an in-memory configuration operation.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum EditError {
|
||||
#[error("the selected client identity is missing")]
|
||||
MissingIdentity,
|
||||
#[error("the selected client identity is ambiguous")]
|
||||
AmbiguousIdentity,
|
||||
#[error("the configuration contains malformed or unsupported structure")]
|
||||
UnsafeDocument,
|
||||
#[error("the selected peer has malformed or unsupported fields")]
|
||||
UnsafePeer,
|
||||
#[error("the client identity already exists")]
|
||||
DuplicateIdentity,
|
||||
#[error("the client alias is invalid")]
|
||||
InvalidAlias,
|
||||
#[error("the creation timestamp is invalid")]
|
||||
InvalidTimestamp,
|
||||
#[error("the peer does not have a usable alias")]
|
||||
MissingAlias,
|
||||
#[error("the peer routes do not fit the supported client address model")]
|
||||
UnsupportedAddresses,
|
||||
#[error("the edited configuration could not be parsed")]
|
||||
ParseFailure,
|
||||
}
|
||||
|
||||
impl From<MetadataError> for EditError {
|
||||
fn from(error: MetadataError) -> Self {
|
||||
match error {
|
||||
MetadataError::InvalidAlias => Self::InvalidAlias,
|
||||
MetadataError::InvalidTimestamp => Self::InvalidTimestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigDocument {
|
||||
/// Add a peer using a caller-provided identity and validated domain client.
|
||||
/// No key generation or filesystem/runtime access occurs here.
|
||||
pub fn add_client(
|
||||
&self,
|
||||
client: &Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<Self, EditError> {
|
||||
add_client(self, client, created_utc)
|
||||
}
|
||||
|
||||
/// Delete the uniquely selected peer. Its header and directives are owned by
|
||||
/// the peer; blank lines and administrator comments remain in their original order.
|
||||
pub fn delete_client(&self, identity: &PublicKey) -> Result<Self, EditError> {
|
||||
delete_client(self, identity)
|
||||
}
|
||||
|
||||
/// Rename a peer, inserting a standard block when it is a legacy peer.
|
||||
pub fn rename_client(&self, identity: &PublicKey, alias: &str) -> Result<Self, EditError> {
|
||||
rename_client(self, identity, alias)
|
||||
}
|
||||
|
||||
/// Explicitly add managed alias metadata to a legacy peer.
|
||||
pub fn insert_legacy_metadata(
|
||||
&self,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<Self, EditError> {
|
||||
insert_legacy_metadata(self, identity, alias, created_utc)
|
||||
}
|
||||
|
||||
/// Project one compatible peer and its metadata into the narrower M1 model.
|
||||
pub fn project_client(&self, identity: &PublicKey) -> Result<Client, EditError> {
|
||||
project_client(self, identity)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_client(
|
||||
document: &ConfigDocument,
|
||||
client: &Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(&client.alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
if find_peer(document, client.identity).is_ok() {
|
||||
return Err(EditError::DuplicateIdentity);
|
||||
}
|
||||
if !matches!(
|
||||
find_peer(document, client.identity),
|
||||
Err(EditError::MissingIdentity)
|
||||
) {
|
||||
return Err(EditError::AmbiguousIdentity);
|
||||
}
|
||||
let block = render_block(&client.alias, created_utc)?;
|
||||
let newline = line_ending(document.render());
|
||||
let mut source =
|
||||
String::from_utf8(document.render().to_vec()).map_err(|_| EditError::ParseFailure)?;
|
||||
if !source.is_empty() && !source.ends_with('\n') {
|
||||
source.push_str(newline);
|
||||
}
|
||||
let block = block.replace('\n', newline);
|
||||
source.push_str(&block);
|
||||
source.push_str("[Peer]");
|
||||
source.push_str(newline);
|
||||
source.push_str("PublicKey = ");
|
||||
source.push_str(&client.identity.to_string());
|
||||
source.push_str(newline);
|
||||
source.push_str("AllowedIPs = ");
|
||||
source.push_str(&format!("{}/32", client.addresses.ipv4));
|
||||
if let Some(ipv6) = client.addresses.ipv6 {
|
||||
source.push_str(", ");
|
||||
source.push_str(&format!("{ipv6}/128"));
|
||||
}
|
||||
source.push_str(newline);
|
||||
parse_edited(source)
|
||||
}
|
||||
|
||||
pub fn delete_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?;
|
||||
let mut output = String::with_capacity(source.len());
|
||||
let prefix_end = match &peer.metadata {
|
||||
MetadataAttachment::Valid { block, .. } => block.start(),
|
||||
MetadataAttachment::Absent => peer.section.start(),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
output.push_str(&source[..prefix_end]);
|
||||
let section = peer.section;
|
||||
let section_text = &source[section.start()..section.end()];
|
||||
let mut offset = section.start();
|
||||
for raw in section_text.split_inclusive('\n') {
|
||||
let content = raw
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(raw)
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or(raw.strip_suffix('\n').unwrap_or(raw));
|
||||
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
let is_header = offset == section.start();
|
||||
let is_comment_or_blank =
|
||||
trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';');
|
||||
if !is_header && is_comment_or_blank {
|
||||
output.push_str(raw);
|
||||
}
|
||||
offset += raw.len();
|
||||
}
|
||||
output.push_str(&source[section.end()..]);
|
||||
let result = parse_edited(output)?;
|
||||
if result
|
||||
.peers()
|
||||
.iter()
|
||||
.any(|candidate| candidate.public_key_candidates.contains(identity))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn rename_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
let created = match &peer.metadata {
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(),
|
||||
MetadataAttachment::Absent => None,
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
set_metadata(document, identity, alias, created)
|
||||
}
|
||||
|
||||
pub fn insert_legacy_metadata(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
if !matches!(peer.metadata, MetadataAttachment::Absent) {
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
set_metadata(document, identity, alias, created_utc)
|
||||
}
|
||||
|
||||
fn set_metadata(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
let created = match &peer.metadata {
|
||||
MetadataAttachment::Absent => created_utc,
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
let rendered = render_block(alias, created)?;
|
||||
let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?;
|
||||
match &peer.metadata {
|
||||
MetadataAttachment::Absent => {
|
||||
let replacement = rendered.replace('\n', line_ending(document.render()));
|
||||
let mut output = String::with_capacity(source.len() + replacement.len());
|
||||
let at = peer.section.start();
|
||||
output.push_str(&source[..at]);
|
||||
output.push_str(&replacement);
|
||||
output.push_str(&source[at..]);
|
||||
parse_edited(output)
|
||||
}
|
||||
MetadataAttachment::Valid { block, .. } => {
|
||||
let encoded_alias = rendered
|
||||
.lines()
|
||||
.nth(2)
|
||||
.and_then(|line| line.strip_prefix("# wg-admin:alias="))
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let block_source = &source[block.start()..block.end()];
|
||||
let alias_line = block_source
|
||||
.find("# wg-admin:alias=")
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let value_start = block.start() + alias_line + "# wg-admin:alias=".len();
|
||||
let value_end = value_start
|
||||
+ source[value_start..block.end()]
|
||||
.find(['\r', '\n'])
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let mut output = String::with_capacity(
|
||||
source.len() - (value_end - value_start) + encoded_alias.len(),
|
||||
);
|
||||
output.push_str(&source[..value_start]);
|
||||
output.push_str(encoded_alias);
|
||||
output.push_str(&source[value_end..]);
|
||||
parse_edited(output)
|
||||
}
|
||||
MetadataAttachment::Invalid(_) => Err(EditError::UnsafePeer),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
) -> Result<Client, EditError> {
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
if peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| !is_address_shape_issue(issue))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
let alias = match &peer.metadata {
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.alias.clone(),
|
||||
MetadataAttachment::Absent => return Err(EditError::MissingAlias),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
if !peer.issues.is_empty() {
|
||||
return Err(EditError::UnsupportedAddresses);
|
||||
}
|
||||
let mut ipv4 = None;
|
||||
let mut ipv6 = None;
|
||||
for route in &peer.allowed_ips {
|
||||
match route {
|
||||
IpNet::V4(network) if network.prefix_len() == 32 && ipv4.is_none() => {
|
||||
ipv4 = Some(network.addr());
|
||||
}
|
||||
IpNet::V6(network) if network.prefix_len() == 128 && ipv6.is_none() => {
|
||||
ipv6 = Some(network.addr());
|
||||
}
|
||||
_ => return Err(EditError::UnsupportedAddresses),
|
||||
}
|
||||
}
|
||||
let ipv4 = ipv4.ok_or(EditError::UnsupportedAddresses)?;
|
||||
Client::new(*identity, alias, ClientAddresses { ipv4, ipv6 })
|
||||
.map_err(|_| EditError::InvalidAlias)
|
||||
}
|
||||
|
||||
fn ensure_document_safe(document: &ConfigDocument) -> Result<(), EditError> {
|
||||
if !document.issues().is_empty() {
|
||||
return Err(EditError::UnsafeDocument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_peer(
|
||||
document: &ConfigDocument,
|
||||
identity: PublicKey,
|
||||
) -> Result<&PeerInspection, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
find_peer(document, identity)
|
||||
}
|
||||
|
||||
fn find_peer(document: &ConfigDocument, identity: PublicKey) -> Result<&PeerInspection, EditError> {
|
||||
let matches: Vec<_> = document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| peer.public_key_candidates.contains(&identity))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[] => Err(EditError::MissingIdentity),
|
||||
[peer]
|
||||
if peer.identity == Some(identity)
|
||||
&& !peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) =>
|
||||
{
|
||||
Ok(peer)
|
||||
}
|
||||
_ => Err(EditError::AmbiguousIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_peer_safe(peer: &PeerInspection) -> Result<(), EditError> {
|
||||
if peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| !is_address_shape_issue(issue))
|
||||
|| matches!(peer.metadata, MetadataAttachment::Invalid(_))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_address_shape_issue(issue: &PeerIssue) -> bool {
|
||||
matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_edited(source: String) -> Result<ConfigDocument, EditError> {
|
||||
ConfigDocument::parse(source.as_bytes()).map_err(|_| EditError::ParseFailure)
|
||||
}
|
||||
|
||||
fn line_ending(source: &[u8]) -> &'static str {
|
||||
source
|
||||
.windows(2)
|
||||
.find(|window| *window == b"\r\n")
|
||||
.map_or("\n", |_| "\r\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
|
||||
fn key(text: &str) -> PublicKey {
|
||||
PublicKey::from_str(text).unwrap()
|
||||
}
|
||||
fn doc(source: &str) -> ConfigDocument {
|
||||
ConfigDocument::parse(source).unwrap()
|
||||
}
|
||||
fn client(identity: &str, alias: &str) -> Client {
|
||||
Client::new(
|
||||
key(identity),
|
||||
alias,
|
||||
ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 0, 0, 7),
|
||||
ipv6: Some("fd00::7".parse().unwrap()),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_add_emits_metadata_dual_stack_and_round_trips() {
|
||||
let original = doc("[Interface]\nAddress = keep\n");
|
||||
let edited = original
|
||||
.add_client(&client(KEY_A, "same alias"), Some("2026-10-18T19:00:00Z"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(edited.render())
|
||||
.contains("AllowedIPs = 10.0.0.7/32, fd00::7/128")
|
||||
);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"same alias"
|
||||
);
|
||||
assert!(original.peers().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_add_preserves_existing_bytes_and_allows_duplicate_aliases() {
|
||||
let source = format!(
|
||||
"# untouched\r\n[Interface]\r\nPrivateKey = hidden\r\n\r\n[Peer]\r\nPublicKey = {KEY_A}\r\nAllowedIPs = 10.0.0.1/32\r\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let mut other = client(KEY_B, "repeated");
|
||||
other.addresses.ipv4 = Ipv4Addr::new(10, 0, 0, 8);
|
||||
let managed = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "repeated", None)
|
||||
.unwrap();
|
||||
let edited = managed.add_client(&other, None).unwrap();
|
||||
let rendered = String::from_utf8(edited.render().to_vec()).unwrap();
|
||||
let peer_header = source.find("[Peer]").unwrap();
|
||||
assert!(rendered.starts_with(&source[..peer_header]));
|
||||
assert!(rendered.contains(&source[peer_header..]));
|
||||
assert_eq!(edited.peers().len(), 2);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"repeated"
|
||||
);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_B)).unwrap().alias,
|
||||
"repeated"
|
||||
);
|
||||
assert!(rendered.contains("\r\n# wg-admin:begin\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_repeated_renames_reparse_spans_and_preserve_creation_time() {
|
||||
let source = format!(
|
||||
"# outside\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\nPresharedKey = secret\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let first = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "first", Some("2024-02-29T12:00:00Z"))
|
||||
.unwrap();
|
||||
let second = first.rename_client(&key(KEY_A), "second").unwrap();
|
||||
let third = second.rename_client(&key(KEY_A), "third").unwrap();
|
||||
let third_text = String::from_utf8(third.render().to_vec()).unwrap();
|
||||
assert_eq!(third.project_client(&key(KEY_A)).unwrap().alias, "third");
|
||||
assert_eq!(third_text.matches("wg-admin:begin").count(), 1);
|
||||
assert!(third_text.contains("2024-02-29T12:00:00Z"));
|
||||
assert!(third_text.starts_with("# outside\n"));
|
||||
assert!(third_text.ends_with("PresharedKey = secret\n"));
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_rename_and_legacy_insertion_preserve_date_and_unknown_directives() {
|
||||
let source = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\nPresharedKey = secret\n# admin\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let added = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "old", Some("2024-01-02T03:04:05Z"))
|
||||
.unwrap();
|
||||
let renamed = added.rename_client(&key(KEY_A), "new # café").unwrap();
|
||||
let view = renamed.project_client(&key(KEY_A)).unwrap();
|
||||
assert_eq!(view.alias, "new # café");
|
||||
assert!(String::from_utf8_lossy(renamed.render()).contains("2024-01-02T03:04:05Z"));
|
||||
assert!(
|
||||
String::from_utf8_lossy(renamed.render()).contains("PresharedKey = secret\n# admin\n")
|
||||
);
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_delete_preserves_admin_and_neighbor_managed_comments() {
|
||||
let block_a = render_block("one", None).unwrap();
|
||||
let block_b = render_block("two", None).unwrap();
|
||||
let source = format!(
|
||||
"# before\n{block_a}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n# keep note\n{block_b}[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
);
|
||||
let edited = doc(&source).delete_client(&key(KEY_A)).unwrap();
|
||||
let output = String::from_utf8(edited.render().to_vec()).unwrap();
|
||||
assert!(!output.contains(KEY_A));
|
||||
assert!(output.contains("# before\n"));
|
||||
assert!(output.contains("# keep note\n"));
|
||||
assert!(output.contains(&block_b));
|
||||
assert_eq!(edited.project_client(&key(KEY_B)).unwrap().alias, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_rejects_ambiguous_edits_and_unsupported_projection_without_mutation() {
|
||||
let source =
|
||||
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32, 10.0.0.2/32\n");
|
||||
let original = doc(&source);
|
||||
assert_eq!(
|
||||
original.project_client(&key(KEY_A)),
|
||||
Err(EditError::MissingAlias)
|
||||
);
|
||||
assert!(matches!(
|
||||
original.delete_client(&key(KEY_B)),
|
||||
Err(EditError::MissingIdentity)
|
||||
));
|
||||
let duplicate = doc(&format!(
|
||||
"{source}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
|
||||
));
|
||||
assert!(matches!(
|
||||
duplicate.rename_client(&key(KEY_A), "renamed"),
|
||||
Err(EditError::AmbiguousIdentity)
|
||||
));
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes() {
|
||||
let ipv4 = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n",
|
||||
render_block("v4", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
ipv4.project_client(&key(KEY_A)).unwrap().addresses.ipv6,
|
||||
None
|
||||
);
|
||||
let ipv6 = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::1/128\n",
|
||||
render_block("v6", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
ipv6.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
let full = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 0.0.0.0/0\n",
|
||||
render_block("full", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
full.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Versioned, comment-only metadata immediately attached to a WireGuard peer.
|
||||
//!
|
||||
//! The alias is encoded as canonical unpadded base64url of its UTF-8 bytes. This
|
||||
//! keeps arbitrary valid aliases on one comment line without introducing config
|
||||
//! syntax, comment termination, or line injection.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
use crate::domain::validate_alias;
|
||||
|
||||
use super::SourceSpan;
|
||||
|
||||
const BEGIN: &str = "# wg-admin:begin";
|
||||
const END: &str = "# wg-admin:end";
|
||||
const VERSION_PREFIX: &str = "# wg-admin:version=";
|
||||
const ALIAS_PREFIX: &str = "# wg-admin:alias=";
|
||||
const CREATED_PREFIX: &str = "# wg-admin:created-utc=";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedMetadata {
|
||||
pub version: u32,
|
||||
pub alias: String,
|
||||
pub created_utc: Option<String>,
|
||||
}
|
||||
|
||||
/// A safe half-open source span for a validated, uniquely attached block.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MetadataAttachment {
|
||||
Absent,
|
||||
Valid {
|
||||
metadata: ManagedMetadata,
|
||||
block: SourceSpan,
|
||||
},
|
||||
Invalid(MetadataIssue),
|
||||
}
|
||||
|
||||
/// Redacted metadata problems. Variants identify a line, never include its text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MetadataIssue {
|
||||
MalformedBlock { line: usize },
|
||||
UnsupportedVersion { line: usize },
|
||||
InvalidAlias { line: usize },
|
||||
InvalidTimestamp { line: usize },
|
||||
UnknownField { line: usize },
|
||||
MissingField { line: usize },
|
||||
DuplicateBlocks { line: usize, count: usize },
|
||||
Misattached { line: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Line<'a> {
|
||||
content: &'a str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
number: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Candidate {
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
span: SourceSpan,
|
||||
issue: Option<MetadataIssue>,
|
||||
}
|
||||
|
||||
/// Inspect all reserved marker pairs and attach blocks only when their final byte
|
||||
/// is the peer header's first byte. Administrator comments outside this namespace
|
||||
/// are ignored completely.
|
||||
pub(super) fn inspect_document(
|
||||
source: &str,
|
||||
peer_headers: &[usize],
|
||||
) -> (Vec<MetadataAttachment>, Vec<MetadataIssue>) {
|
||||
let lines = lines(source);
|
||||
let mut candidates = Vec::new();
|
||||
let mut issues = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < lines.len() {
|
||||
if marker(lines[index].content) != Some(Marker::Begin) {
|
||||
if marker(lines[index].content) == Some(Marker::End) {
|
||||
issues.push(MetadataIssue::MalformedBlock {
|
||||
line: lines[index].number,
|
||||
});
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index;
|
||||
let mut end = None;
|
||||
let mut malformed = false;
|
||||
let mut cursor = index + 1;
|
||||
while cursor < lines.len() {
|
||||
match marker(lines[cursor].content) {
|
||||
Some(Marker::Begin) => {
|
||||
malformed = true;
|
||||
break;
|
||||
}
|
||||
Some(Marker::End) => {
|
||||
end = Some(cursor);
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
let attached_peer = peer_headers.iter().find(|header| {
|
||||
**header > lines[start].start
|
||||
&& (end.is_none()
|
||||
|| **header <= lines[cursor.min(lines.len().saturating_sub(1))].start)
|
||||
});
|
||||
let block_end_line = end.unwrap_or_else(|| {
|
||||
if malformed {
|
||||
cursor
|
||||
} else if let Some(header) = attached_peer {
|
||||
lines
|
||||
.partition_point(|line| line.start < *header)
|
||||
.saturating_sub(1)
|
||||
} else {
|
||||
lines.len().saturating_sub(1)
|
||||
}
|
||||
});
|
||||
let span_end = if let Some(end_line) = end {
|
||||
lines[end_line].end
|
||||
} else if malformed {
|
||||
lines[block_end_line].start
|
||||
} else if let Some(header) = attached_peer {
|
||||
*header
|
||||
} else {
|
||||
source.len()
|
||||
};
|
||||
let Some(span) = SourceSpan::from_offsets(lines[start].start, span_end) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let issue = if malformed || end.is_none() {
|
||||
Some(MetadataIssue::MalformedBlock {
|
||||
line: lines[start].number,
|
||||
})
|
||||
} else {
|
||||
parse_block(&lines[start..=block_end_line]).err()
|
||||
};
|
||||
if let Some(issue) = &issue {
|
||||
issues.push(issue.clone());
|
||||
}
|
||||
candidates.push(Candidate {
|
||||
start_line: start,
|
||||
end_line: block_end_line,
|
||||
span,
|
||||
issue,
|
||||
});
|
||||
if let Some(end) = end {
|
||||
index = end + 1;
|
||||
} else if malformed {
|
||||
index = cursor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut attachments = Vec::with_capacity(peer_headers.len());
|
||||
for header in peer_headers {
|
||||
let ending_here: Vec<_> = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, candidate)| candidate.span.end() == *header)
|
||||
.map(|(candidate, _)| candidate)
|
||||
.collect();
|
||||
if ending_here.is_empty() {
|
||||
attachments.push(MetadataAttachment::Absent);
|
||||
continue;
|
||||
}
|
||||
let Some(last) = ending_here.last().copied() else {
|
||||
attachments.push(MetadataAttachment::Absent);
|
||||
continue;
|
||||
};
|
||||
let mut contiguous = 1;
|
||||
let mut previous = last;
|
||||
while previous > 0
|
||||
&& candidates[previous - 1].span.end() == candidates[previous].span.start()
|
||||
{
|
||||
contiguous += 1;
|
||||
previous -= 1;
|
||||
}
|
||||
let candidate = &candidates[last];
|
||||
if contiguous > 1 {
|
||||
let issue = MetadataIssue::DuplicateBlocks {
|
||||
line: lines[candidate.start_line].number,
|
||||
count: contiguous,
|
||||
};
|
||||
issues.push(issue.clone());
|
||||
attachments.push(MetadataAttachment::Invalid(issue));
|
||||
} else if let Some(issue) = &candidate.issue {
|
||||
attachments.push(MetadataAttachment::Invalid(issue.clone()));
|
||||
} else {
|
||||
let parsed = parse_block(&lines[candidate.start_line..=candidate.end_line]);
|
||||
match parsed {
|
||||
Ok(metadata) => attachments.push(MetadataAttachment::Valid {
|
||||
metadata,
|
||||
block: candidate.span,
|
||||
}),
|
||||
Err(issue) => {
|
||||
attachments.push(MetadataAttachment::Invalid(issue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in &candidates {
|
||||
if !peer_headers.contains(&candidate.span.end()) {
|
||||
let issue = MetadataIssue::Misattached {
|
||||
line: lines[candidate.start_line].number,
|
||||
};
|
||||
issues.push(issue);
|
||||
}
|
||||
}
|
||||
(attachments, issues)
|
||||
}
|
||||
|
||||
/// Render the canonical LF-terminated v1 block. The caller may adapt line endings
|
||||
/// when inserting it into a document; this function performs no source mutation.
|
||||
pub fn render_block(alias: &str, created_utc: Option<&str>) -> Result<String, MetadataError> {
|
||||
validate_alias(alias).map_err(|_| MetadataError::InvalidAlias)?;
|
||||
if let Some(timestamp) = created_utc
|
||||
&& !valid_utc_timestamp(timestamp)
|
||||
{
|
||||
return Err(MetadataError::InvalidTimestamp);
|
||||
}
|
||||
let encoded = URL_SAFE_NO_PAD.encode(alias.as_bytes());
|
||||
let mut block = format!("{BEGIN}\n{VERSION_PREFIX}1\n{ALIAS_PREFIX}{encoded}\n");
|
||||
if let Some(timestamp) = created_utc {
|
||||
block.push_str(CREATED_PREFIX);
|
||||
block.push_str(timestamp);
|
||||
block.push('\n');
|
||||
}
|
||||
block.push_str(END);
|
||||
block.push('\n');
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum MetadataError {
|
||||
#[error("client alias is invalid")]
|
||||
InvalidAlias,
|
||||
#[error("creation timestamp must be a valid UTC timestamp")]
|
||||
InvalidTimestamp,
|
||||
}
|
||||
|
||||
fn parse_block(lines: &[Line<'_>]) -> Result<ManagedMetadata, MetadataIssue> {
|
||||
let first_line = lines.first().map_or(1, |line| line.number);
|
||||
if lines.len() < 4 || lines[0].content != BEGIN || lines[lines.len() - 1].content != END {
|
||||
return Err(MetadataIssue::MalformedBlock { line: first_line });
|
||||
}
|
||||
let version_line = &lines[1];
|
||||
let Some(version_text) = version_line.content.strip_prefix(VERSION_PREFIX) else {
|
||||
return Err(MetadataIssue::MissingField { line: first_line });
|
||||
};
|
||||
if version_text.is_empty() || !version_text.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(MetadataIssue::MalformedBlock {
|
||||
line: version_line.number,
|
||||
});
|
||||
}
|
||||
let version = version_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| MetadataIssue::MalformedBlock {
|
||||
line: version_line.number,
|
||||
})?;
|
||||
if version != 1 {
|
||||
return Err(MetadataIssue::UnsupportedVersion {
|
||||
line: version_line.number,
|
||||
});
|
||||
}
|
||||
let alias_line = &lines[2];
|
||||
let Some(encoded_alias) = alias_line.content.strip_prefix(ALIAS_PREFIX) else {
|
||||
return Err(MetadataIssue::MissingField {
|
||||
line: alias_line.number,
|
||||
});
|
||||
};
|
||||
let decoded =
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(encoded_alias)
|
||||
.map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
if URL_SAFE_NO_PAD.encode(&decoded) != encoded_alias {
|
||||
return Err(MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
});
|
||||
}
|
||||
let alias = String::from_utf8(decoded).map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
validate_alias(&alias).map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
let created_lines = &lines[3..lines.len() - 1];
|
||||
let created_utc = match created_lines {
|
||||
[] => None,
|
||||
[line] => {
|
||||
let Some(value) = line.content.strip_prefix(CREATED_PREFIX) else {
|
||||
return Err(if line.content.starts_with("# wg-admin:") {
|
||||
MetadataIssue::UnknownField { line: line.number }
|
||||
} else {
|
||||
MetadataIssue::MalformedBlock { line: line.number }
|
||||
});
|
||||
};
|
||||
if !valid_utc_timestamp(value) {
|
||||
return Err(MetadataIssue::InvalidTimestamp { line: line.number });
|
||||
}
|
||||
Some(value.to_owned())
|
||||
}
|
||||
[line, ..] => {
|
||||
return Err(if line.content.starts_with("# wg-admin:") {
|
||||
MetadataIssue::UnknownField { line: line.number }
|
||||
} else {
|
||||
MetadataIssue::MalformedBlock { line: line.number }
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(ManagedMetadata {
|
||||
version,
|
||||
alias,
|
||||
created_utc,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Marker {
|
||||
Begin,
|
||||
End,
|
||||
}
|
||||
|
||||
fn marker(line: &str) -> Option<Marker> {
|
||||
if line == BEGIN
|
||||
|| line.starts_with(&format!("{BEGIN} "))
|
||||
|| line.starts_with(&format!("{BEGIN}\t"))
|
||||
{
|
||||
Some(Marker::Begin)
|
||||
} else if line == END
|
||||
|| line.starts_with(&format!("{END} "))
|
||||
|| line.starts_with(&format!("{END}\t"))
|
||||
{
|
||||
Some(Marker::End)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Vec<Line<'_>> {
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
for (index, raw) in source.split_inclusive('\n').enumerate() {
|
||||
let end = offset + raw.len();
|
||||
let content = raw.strip_suffix('\n').unwrap_or(raw);
|
||||
let content = content.strip_suffix('\r').unwrap_or(content);
|
||||
result.push(Line {
|
||||
content,
|
||||
start: offset,
|
||||
end,
|
||||
number: index + 1,
|
||||
});
|
||||
offset = end;
|
||||
}
|
||||
if source.is_empty() {
|
||||
return result;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn valid_utc_timestamp(value: &str) -> bool {
|
||||
if value.len() != 20
|
||||
|| !value.is_ascii()
|
||||
|| value.as_bytes()[4] != b'-'
|
||||
|| value.as_bytes()[7] != b'-'
|
||||
|| value.as_bytes()[10] != b'T'
|
||||
|| value.as_bytes()[13] != b':'
|
||||
|| value.as_bytes()[16] != b':'
|
||||
|| value.as_bytes()[19] != b'Z'
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let digits = |range: std::ops::Range<usize>| value[range].parse::<u32>().ok();
|
||||
let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = (
|
||||
digits(0..4),
|
||||
digits(5..7),
|
||||
digits(8..10),
|
||||
digits(11..13),
|
||||
digits(14..16),
|
||||
digits(17..19),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if year == 0 || !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
|
||||
return false;
|
||||
}
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let days = match month {
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
};
|
||||
(1..=days).contains(&day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_round_trips_aliases_and_optional_timestamp() {
|
||||
let alias = "# café = \"laptop\" <tag>";
|
||||
for timestamp in [None, Some("2024-02-29T23:59:59Z")] {
|
||||
let rendered = render_block(alias, timestamp).unwrap();
|
||||
let header = rendered.len();
|
||||
let (attachments, issues) = inspect_document(
|
||||
&format!(
|
||||
"{rendered}[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"
|
||||
),
|
||||
&[header],
|
||||
);
|
||||
assert!(issues.is_empty());
|
||||
let MetadataAttachment::Valid { metadata, block } = &attachments[0] else {
|
||||
panic!("canonical metadata should inspect successfully");
|
||||
};
|
||||
assert_eq!(metadata.alias, alias);
|
||||
assert_eq!(metadata.created_utc.as_deref(), timestamp);
|
||||
assert_eq!(block.end(), header);
|
||||
assert_eq!(block.start(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_rejects_control_aliases_and_invalid_utc_dates() {
|
||||
assert_eq!(
|
||||
render_block("bad\n# wg-admin:end", None),
|
||||
Err(MetadataError::InvalidAlias)
|
||||
);
|
||||
for timestamp in [
|
||||
"2023-02-29T12:00:00Z",
|
||||
"2024-13-01T12:00:00Z",
|
||||
"2024-01-01T24:00:00Z",
|
||||
"2024-01-01T12:00:60Z",
|
||||
"2024-01-01T12:00:00+00:00",
|
||||
] {
|
||||
assert_eq!(
|
||||
render_block("ok", Some(timestamp)),
|
||||
Err(MetadataError::InvalidTimestamp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_reports_unsupported_and_misattached_blocks_redacted() {
|
||||
let source = "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2s\n# wg-admin:end\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, issues) = inspect_document(source, &[header]);
|
||||
assert!(matches!(attachments[0], MetadataAttachment::Absent));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, MetadataIssue::Misattached { .. }))
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }))
|
||||
);
|
||||
assert!(!format!("{issues:?}").contains("b2s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_missing_end_marker_still_attaches_as_invalid() {
|
||||
let source = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2s\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, _) = inspect_document(source, &[header]);
|
||||
assert!(matches!(
|
||||
attachments[0],
|
||||
MetadataAttachment::Invalid(MetadataIssue::MalformedBlock { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_ignores_unrelated_admin_comments_and_rejects_duplicate_blocks() {
|
||||
let first = render_block("one", None).unwrap();
|
||||
let second = render_block("two", None).unwrap();
|
||||
let source = format!(
|
||||
"# administrator note: wg-admin is not mentioned as an owner\n{first}{second}[Peer]\n"
|
||||
);
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, _) = inspect_document(&source, &[header]);
|
||||
assert!(matches!(
|
||||
attachments[0],
|
||||
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
|
||||
));
|
||||
let source = "# administrator note: wg-admin is not mentioned as an owner\n[Peer]\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, issues) = inspect_document(source, &[header]);
|
||||
assert!(matches!(attachments[0], MetadataAttachment::Absent));
|
||||
assert!(issues.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use wireguard_admin::app::{
|
||||
Application, FirstIssueAttempt, FirstIssueDeliveryOutcome, ReissueFailureKind, ReissueStage,
|
||||
};
|
||||
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
|
||||
use wireguard_admin::delivery::files::FileOutputError;
|
||||
use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot};
|
||||
use wireguard_admin::persistence::SavedStore;
|
||||
use wireguard_admin::vault::import::{parse_client_config, reconcile};
|
||||
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
|
||||
use wireguard_admin::wireguard::ConfigDocument;
|
||||
|
||||
const SETTINGS: &str = r#"
|
||||
interface_name = "wg0"
|
||||
server_config_path = "server.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.test:51820"
|
||||
ipv4_allocation_range = "10.20.0.0/24"
|
||||
ipv6_allocation_range = "fd20::/64"
|
||||
dns_addresses = ["10.20.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
"#;
|
||||
|
||||
struct Fixture {
|
||||
root: tempfile::TempDir,
|
||||
settings: ResolvedSettings,
|
||||
store: SavedStore,
|
||||
states: ClientStates,
|
||||
}
|
||||
|
||||
fn fixture(server: String, states: ClientStates) -> Fixture {
|
||||
let root = tempfile::tempdir().expect("private temporary fixture");
|
||||
let settings = SettingsFile::parse_toml(SETTINGS)
|
||||
.expect("fixture settings parse")
|
||||
.validate()
|
||||
.expect("fixture settings validate")
|
||||
.resolve_paths(root.path());
|
||||
fs::create_dir_all(&settings.client_source_directory).expect("source directory");
|
||||
fs::write(ResolvedSettings::settings_path(root.path()), SETTINGS).expect("settings file");
|
||||
fs::write(&settings.server_config_path, server).expect("server configuration");
|
||||
fs::set_permissions(
|
||||
&settings.server_config_path,
|
||||
fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.expect("private server configuration");
|
||||
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
|
||||
Fixture {
|
||||
root,
|
||||
settings,
|
||||
store,
|
||||
states,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_states() -> ClientStates {
|
||||
ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty state")
|
||||
}
|
||||
|
||||
fn open(fixture: &Fixture) -> Application {
|
||||
Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens")
|
||||
}
|
||||
|
||||
fn new_client_fixture() -> (Fixture, Application, wireguard_admin::domain::PublicKey) {
|
||||
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
|
||||
let server = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n",
|
||||
server_key.to_base64()
|
||||
);
|
||||
let fixture = fixture(server, empty_states());
|
||||
let mut app = open(&fixture);
|
||||
app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault"))
|
||||
.expect("stage vault");
|
||||
app.commit().expect("install vault");
|
||||
let identity = app
|
||||
.prepare_new_client("same alias", None, None)
|
||||
.expect("prepare client");
|
||||
app.commit().expect("commit client");
|
||||
(fixture, app, identity)
|
||||
}
|
||||
|
||||
fn destination(
|
||||
app: &Application,
|
||||
path: impl AsRef<Path>,
|
||||
) -> wireguard_admin::delivery::files::PreparedDestination {
|
||||
app.prepare_first_issue_destination(path)
|
||||
.expect("prepare selected destination")
|
||||
}
|
||||
|
||||
fn archive_name(path: &Path) -> &str {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("synthetic archive filename")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent() {
|
||||
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
|
||||
let server = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n",
|
||||
server_key.to_base64()
|
||||
);
|
||||
let fixture = fixture(server, empty_states());
|
||||
let mut app = open(&fixture);
|
||||
app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault"))
|
||||
.expect("stage vault");
|
||||
app.commit().expect("install vault");
|
||||
let first = app
|
||||
.prepare_new_client("duplicate", None, None)
|
||||
.expect("first client");
|
||||
let second = app
|
||||
.prepare_new_client("duplicate", None, None)
|
||||
.expect("second client");
|
||||
app.commit().expect("commit clients");
|
||||
|
||||
let existing = fixture.root.path().join("occupied.conf");
|
||||
fs::write(&existing, b"existing output").expect("existing regular file");
|
||||
let declined = app
|
||||
.deliver_first_issue_file(
|
||||
first,
|
||||
FirstIssueAttempt::Initial,
|
||||
destination(&app, &existing),
|
||||
)
|
||||
.expect("decline reported as an operation result");
|
||||
assert!(matches!(
|
||||
declined,
|
||||
FirstIssueDeliveryOutcome::Failed {
|
||||
error: FileOutputError::OverwriteDeclined,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let retry_path = fixture.root.path().join("new-dir/first.conf");
|
||||
let retried = app
|
||||
.deliver_first_issue_file(
|
||||
first,
|
||||
FirstIssueAttempt::Retry {
|
||||
confirm_possible_overwrite: false,
|
||||
confirm_manual_cleanup: false,
|
||||
},
|
||||
destination(&app, &retry_path),
|
||||
)
|
||||
.expect("retry remains password free in creation session");
|
||||
assert!(matches!(
|
||||
retried,
|
||||
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
|
||||
));
|
||||
assert_eq!(
|
||||
fs::metadata(&retry_path)
|
||||
.expect("delivered file")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
|
||||
let second_path = fixture.root.path().join("second.conf");
|
||||
let delivered_second = app
|
||||
.deliver_first_issue_file(
|
||||
second,
|
||||
FirstIssueAttempt::Initial,
|
||||
destination(&app, second_path),
|
||||
)
|
||||
.expect("second key has independent eligibility");
|
||||
assert!(matches!(
|
||||
delivered_second,
|
||||
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen() {
|
||||
let (fixture, mut app, identity) = new_client_fixture();
|
||||
let initial_path = fixture.root.path().join("initial.conf");
|
||||
let issued = app
|
||||
.deliver_first_issue_file(
|
||||
identity,
|
||||
FirstIssueAttempt::Initial,
|
||||
destination(&app, &initial_path),
|
||||
)
|
||||
.expect("first file issued");
|
||||
assert!(matches!(
|
||||
issued,
|
||||
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
|
||||
));
|
||||
|
||||
// A draft rename must not change the saved configuration used by reissue.
|
||||
app.rename_client(identity, "unsaved draft alias")
|
||||
.expect("prepare unrelated draft rename");
|
||||
let wrong_path = fixture.root.path().join("wrong-password.conf");
|
||||
let wrong = app.reissue_client_file(
|
||||
identity,
|
||||
"wrong password",
|
||||
destination(&app, &wrong_path),
|
||||
false,
|
||||
);
|
||||
let error = wrong.expect_err("wrong password rejected");
|
||||
assert_eq!(error.stage, ReissueStage::Authorization);
|
||||
assert_eq!(error.kind, ReissueFailureKind::PasswordOrRecordRejected);
|
||||
assert!(!wrong_path.exists());
|
||||
|
||||
let output = fixture.root.path().join("reissue/client.conf");
|
||||
let result = app
|
||||
.reissue_client_file(
|
||||
identity,
|
||||
"fixture vault password",
|
||||
destination(&app, &output),
|
||||
true,
|
||||
)
|
||||
.expect("fresh password authorizes reissue");
|
||||
assert!(result.activation_warning);
|
||||
assert!(result.qr.is_some_and(|qr| qr.is_ok()));
|
||||
let bytes = fs::read(&output).expect("mandatory reissue file");
|
||||
let parsed = parse_client_config(&bytes).expect("rendered file parses");
|
||||
assert_eq!(parsed.identity, identity);
|
||||
assert_eq!(
|
||||
fs::metadata(&output)
|
||||
.expect("private output")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
|
||||
let next_action_path = fixture.root.path().join("next-action.conf");
|
||||
// A successful action does not preserve an unlocked identity for the next
|
||||
// action in the same session.
|
||||
let next_action = app.reissue_client_file(
|
||||
identity,
|
||||
"wrong password",
|
||||
destination(&app, next_action_path),
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
next_action
|
||||
.expect_err("previous action does not cache an unlocked identity")
|
||||
.kind,
|
||||
ReissueFailureKind::PasswordOrRecordRejected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue() {
|
||||
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
|
||||
let client_key = WireGuardPrivateKey::generate().expect("synthetic client key");
|
||||
let identity = client_key.public_key();
|
||||
let server = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {identity}\nAllowedIPs = 10.20.0.2/32\n",
|
||||
server_key.to_base64()
|
||||
);
|
||||
let client = Client::new(
|
||||
identity,
|
||||
"imported",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.2".parse().expect("IPv4"),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.expect("IPv4-only domain client");
|
||||
let snapshot = Snapshot::new([client]).expect("snapshot");
|
||||
let import_fixture = fixture(
|
||||
server.clone(),
|
||||
ClientStates::new(snapshot, None, ApplyStatus::Unknown).expect("client state"),
|
||||
);
|
||||
let document = ConfigDocument::parse(server.as_bytes()).expect("server document");
|
||||
let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key");
|
||||
let source = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 1.1.1.1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = imported.example.test:12345\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n",
|
||||
client_key.to_base64(),
|
||||
server_key.public_key(),
|
||||
psk.to_base64()
|
||||
);
|
||||
let report = reconcile(&document, [("client.conf".to_owned(), source.into_bytes())]);
|
||||
let mut bundle = VaultBundle::create("fixture vault password").expect("vault");
|
||||
report
|
||||
.encrypt_candidate(&mut bundle, 0)
|
||||
.expect("encrypt matching import");
|
||||
let mut app = open(&import_fixture);
|
||||
app.prepare_initial_vault(bundle)
|
||||
.expect("stage imported vault");
|
||||
app.commit().expect("commit imported vault");
|
||||
|
||||
let output = import_fixture.root.path().join("imported.conf");
|
||||
app.reissue_client_file(
|
||||
identity,
|
||||
"fixture vault password",
|
||||
destination(&app, &output),
|
||||
false,
|
||||
)
|
||||
.expect("imported record reissues");
|
||||
let rendered = fs::read_to_string(&output).expect("rendered imported file");
|
||||
assert!(rendered.contains("Address = 10.20.0.2/32\n"));
|
||||
assert!(rendered.contains("DNS = 1.1.1.1\n"));
|
||||
assert!(rendered.contains("MTU = 1380\n"));
|
||||
assert!(rendered.contains("Endpoint = imported.example.test:12345\n"));
|
||||
assert!(rendered.contains("AllowedIPs = 0.0.0.0/0\n"));
|
||||
assert!(rendered.contains("PersistentKeepalive = 25\n"));
|
||||
assert!(rendered.contains("PresharedKey = "));
|
||||
assert!(!rendered.contains("Address = 10.20.0.2/32, fd20::"));
|
||||
|
||||
// A saved peer with a mismatching retained address is authenticated as
|
||||
// blocked during import; an edited public status bit cannot authorize it.
|
||||
let conflict_key = WireGuardPrivateKey::generate().expect("second synthetic key");
|
||||
let blocked_server = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.9/32\n",
|
||||
server_key.to_base64(),
|
||||
conflict_key.public_key()
|
||||
);
|
||||
let blocked_document = ConfigDocument::parse(blocked_server.as_bytes()).expect("server");
|
||||
let blocked_source = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0\n",
|
||||
conflict_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
let blocked_report = reconcile(
|
||||
&blocked_document,
|
||||
[("blocked.conf".to_owned(), blocked_source.into_bytes())],
|
||||
);
|
||||
let mut blocked_bundle = VaultBundle::create("fixture vault password").expect("vault");
|
||||
blocked_report
|
||||
.encrypt_candidate(&mut blocked_bundle, 0)
|
||||
.expect("encrypt blocked candidate");
|
||||
let blocked_fixture = fixture(blocked_server, empty_states());
|
||||
let mut blocked_app = open(&blocked_fixture);
|
||||
blocked_app
|
||||
.prepare_initial_vault(blocked_bundle)
|
||||
.expect("stage blocked vault");
|
||||
blocked_app.commit().expect("commit blocked record");
|
||||
let blocked_path = blocked_fixture.root.path().join("blocked.conf");
|
||||
let blocked = blocked_app.reissue_client_file(
|
||||
conflict_key.public_key(),
|
||||
"fixture vault password",
|
||||
destination(&blocked_app, blocked_path),
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
blocked
|
||||
.expect_err("authenticated discrepancy blocks reissue")
|
||||
.kind,
|
||||
ReissueFailureKind::PasswordOrRecordRejected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password() {
|
||||
let (fixture, mut app, identity) = new_client_fixture();
|
||||
let before_delete_archive = app.create_manual_backup().expect("saved client archive");
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.settings.server_config_path)
|
||||
.expect("server remains installed")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(app.delete_client(identity).expect("delete client"));
|
||||
app.commit().expect("commit deletion");
|
||||
let deleted = app.reissue_client_file(
|
||||
identity,
|
||||
"fixture vault password",
|
||||
destination(&app, fixture.root.path().join("deleted.conf")),
|
||||
false,
|
||||
);
|
||||
assert!(deleted.is_err());
|
||||
assert!(!fixture.root.path().join("deleted.conf").exists());
|
||||
|
||||
let preview = app
|
||||
.preview_restore(archive_name(&before_delete_archive))
|
||||
.expect("preview prior saved client");
|
||||
app.confirm_restore(preview)
|
||||
.expect("restore saved client only");
|
||||
let reopened = open(&fixture);
|
||||
let restored_path = fixture.root.path().join("restored.conf");
|
||||
let restored = reopened
|
||||
.reissue_client_file(
|
||||
identity,
|
||||
"fixture vault password",
|
||||
destination(&reopened, &restored_path),
|
||||
false,
|
||||
)
|
||||
.expect("restored encrypted record requires and accepts password");
|
||||
assert!(restored.activation_warning);
|
||||
assert!(restored_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_confirmation_is_bound_to_the_selected_destination() {
|
||||
let (fixture, app, identity) = new_client_fixture();
|
||||
let path: PathBuf = fixture.root.path().join("output.conf");
|
||||
fs::write(&path, b"replace only after confirmation").expect("existing file");
|
||||
let prepared = destination(&app, &path);
|
||||
let declined = app.reissue_client_file(identity, "fixture vault password", prepared, false);
|
||||
assert!(matches!(
|
||||
declined,
|
||||
Err(error) if error.stage == ReissueStage::FilePublication
|
||||
&& error.kind == ReissueFailureKind::FileOutput(FileOutputError::OverwriteDeclined)
|
||||
));
|
||||
let confirmed = destination(&app, &path)
|
||||
.confirm_overwrite()
|
||||
.expect("confirm exact destination");
|
||||
app.reissue_client_file(identity, "fixture vault password", confirmed, false)
|
||||
.expect("confirmed reissue replaces destination");
|
||||
let parsed = parse_client_config(&fs::read(&path).expect("replacement output"))
|
||||
.expect("replacement is valid config");
|
||||
assert_eq!(parsed.identity, identity);
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use wireguard_admin::app::{AppError, Application};
|
||||
use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes};
|
||||
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
|
||||
use wireguard_admin::domain::{
|
||||
ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot,
|
||||
};
|
||||
use wireguard_admin::persistence::{PersistedState, SavedStore};
|
||||
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,
|
||||
store: SavedStore,
|
||||
settings: ResolvedSettings,
|
||||
states: ClientStates,
|
||||
key: PublicKey,
|
||||
secret: WireGuardPrivateKey,
|
||||
}
|
||||
|
||||
fn fixture(with_client: bool) -> Fixture {
|
||||
let root = tempfile::tempdir().expect("temporary 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.as_bytes(),
|
||||
)
|
||||
.expect("settings file");
|
||||
let secret = WireGuardPrivateKey::generate().expect("synthetic key");
|
||||
let key = secret.public_key();
|
||||
let mut server = b"[Interface]\nPrivateKey = fixture-server-secret\nAddress = 10.20.0.1/24\n\n# Keep this administrator note byte-for-byte.\n".to_vec();
|
||||
let clients = if with_client {
|
||||
server.extend_from_slice(
|
||||
format!("[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n")
|
||||
.as_bytes(),
|
||||
);
|
||||
vec![
|
||||
Client::new(
|
||||
key,
|
||||
"fixture client",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.2".parse().expect("IPv4 fixture"),
|
||||
ipv6: Some("fd20::2".parse().expect("IPv6 fixture")),
|
||||
},
|
||||
)
|
||||
.expect("client"),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
fs::write(&settings.server_config_path, &server).expect("server configuration");
|
||||
fs::set_permissions(
|
||||
&settings.server_config_path,
|
||||
fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.expect("server fixture is private");
|
||||
let snapshot = Snapshot::new(clients).expect("initial snapshot");
|
||||
let states = ClientStates::new(snapshot.clone(), Some(snapshot), ApplyStatus::Verified)
|
||||
.expect("initial states");
|
||||
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
|
||||
Fixture {
|
||||
root,
|
||||
store,
|
||||
settings,
|
||||
states,
|
||||
key,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
fn open(fixture: &Fixture) -> Application {
|
||||
Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens")
|
||||
}
|
||||
|
||||
fn archive_name(path: &Path) -> &str {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("UTF-8 archive name")
|
||||
}
|
||||
|
||||
fn update_checksum(header: &mut [u8]) {
|
||||
header[148..156].fill(b' ');
|
||||
let checksum = header.iter().map(|byte| usize::from(*byte)).sum::<usize>();
|
||||
let encoded = format!("{checksum:06o}\0 ");
|
||||
assert_eq!(encoded.len(), 8);
|
||||
header[148..156].copy_from_slice(encoded.as_bytes());
|
||||
}
|
||||
|
||||
fn archive_with_header_change(
|
||||
source: &[u8],
|
||||
entry: usize,
|
||||
change: impl FnOnce(&mut [u8]),
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = source.to_vec();
|
||||
let header_start = entry * 512;
|
||||
let header = bytes
|
||||
.get_mut(header_start..header_start + 512)
|
||||
.expect("header exists");
|
||||
change(header);
|
||||
update_checksum(header);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_round_trip_records_exact_saved_bytes_absence_and_does_not_rewrite_old_archives() {
|
||||
let fixture = fixture(false);
|
||||
let mut app = open(&fixture);
|
||||
let original = fixture.store.read_saved().expect("saved bytes");
|
||||
let path = app.create_manual_backup().expect("manual backup");
|
||||
let archived_bytes = fs::read(&path).expect("archive bytes");
|
||||
let preview = preview_archive(&path).expect("archive round trip");
|
||||
|
||||
assert_eq!(preview.reason, BackupReason::Manual);
|
||||
assert_eq!(preview.saved_bytes.settings, original.settings);
|
||||
assert_eq!(preview.saved_bytes.server_config, original.server_config);
|
||||
assert_eq!(preview.saved_bytes.internal_state, None);
|
||||
assert_eq!(preview.saved_bytes.vault, None);
|
||||
|
||||
let secret = WireGuardPrivateKey::generate().expect("new synthetic key");
|
||||
app.prepare_initial_vault(VaultBundle::create("test password").expect("vault"))
|
||||
.expect("prepare vault");
|
||||
app.add_client(
|
||||
Client::new(
|
||||
secret.public_key(),
|
||||
"added later",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.3".parse().expect("IPv4"),
|
||||
ipv6: Some("fd20::3".parse().expect("IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("client"),
|
||||
None,
|
||||
)
|
||||
.expect("prepare peer");
|
||||
app.commit().expect("commit later change");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(path).expect("old archive remains readable"),
|
||||
archived_bytes
|
||||
);
|
||||
let reopened = fixture.store.read_saved().expect("new saved bytes");
|
||||
assert!(reopened.vault.is_some());
|
||||
assert!(reopened.internal_state.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_setup_import_and_commit_leave_original_source_files_unchanged() {
|
||||
let fixture = fixture(true);
|
||||
let source = fixture
|
||||
.settings
|
||||
.client_source_directory
|
||||
.join("existing.conf");
|
||||
let server_identity = WireGuardPrivateKey::generate().expect("synthetic server identity");
|
||||
let source_bytes = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 10.20.0.1\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n",
|
||||
fixture.secret.to_base64(),
|
||||
server_identity.public_key()
|
||||
)
|
||||
.into_bytes();
|
||||
fs::write(&source, &source_bytes).expect("source fixture");
|
||||
fs::set_permissions(&source, fs::Permissions::from_mode(0o600))
|
||||
.expect("source fixture is private");
|
||||
let server = ConfigDocument::parse(&fixture.store.read_saved().expect("saved").server_config)
|
||||
.expect("server document");
|
||||
let prepared = wireguard_admin::vault::source::prepare_initial_setup(
|
||||
&fixture.settings,
|
||||
&server,
|
||||
"synthetic password",
|
||||
)
|
||||
.expect("initial import setup");
|
||||
let mut app = open(&fixture);
|
||||
app.prepare_initial_setup(prepared)
|
||||
.expect("stage initial import");
|
||||
assert_eq!(
|
||||
fs::read(&source).expect("source remains unchanged"),
|
||||
source_bytes
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.read_saved()
|
||||
.expect("before commit")
|
||||
.vault
|
||||
.is_none()
|
||||
);
|
||||
|
||||
app.commit().expect("install initial vault");
|
||||
let installed = fixture.store.read_saved().expect("installed state");
|
||||
let vault = VaultBundle::from_toml(
|
||||
std::str::from_utf8(installed.vault.as_deref().expect("vault installed"))
|
||||
.expect("vault UTF-8"),
|
||||
)
|
||||
.expect("vault parses without unlocking");
|
||||
assert_eq!(vault.record_count(), 1);
|
||||
assert!(vault.contains_record(fixture.key));
|
||||
assert_eq!(
|
||||
fs::read(&source).expect("source still unchanged"),
|
||||
source_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes() {
|
||||
let fixture = fixture(false);
|
||||
let mut app = open(&fixture);
|
||||
let empty_archive = app.create_manual_backup().expect("archive empty state");
|
||||
|
||||
let client = Client::new(
|
||||
fixture.key,
|
||||
"created after archive",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.9".parse().expect("IPv4"),
|
||||
ipv6: Some("fd20::9".parse().expect("IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("new client");
|
||||
app.add_client(client, None).expect("stage client");
|
||||
app.commit().expect("save client");
|
||||
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||
|
||||
let preview = app
|
||||
.preview_restore(archive_name(&empty_archive))
|
||||
.expect("preview archived empty set");
|
||||
app.confirm_restore(preview).expect("confirm restore");
|
||||
let archived_state = PersistedState::from_toml(
|
||||
app.saved_bytes()
|
||||
.internal_state
|
||||
.as_deref()
|
||||
.expect("restore carries pending reservations"),
|
||||
)
|
||||
.expect("restored reservation state");
|
||||
assert!(
|
||||
archived_state
|
||||
.reserved_addresses
|
||||
.contains(&"10.20.0.9".to_owned())
|
||||
);
|
||||
assert!(
|
||||
archived_state
|
||||
.reserved_addresses
|
||||
.contains(&"fd20::9".to_owned())
|
||||
);
|
||||
|
||||
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||
assert!(
|
||||
app.states()
|
||||
.reserved_addresses()
|
||||
.contains(&"10.20.0.9".parse().expect("IPv4"))
|
||||
);
|
||||
assert!(
|
||||
app.states()
|
||||
.reserved_addresses()
|
||||
.contains(&"fd20::9".parse().expect("IPv6"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_an_archive_that_switches_the_installations_interface() {
|
||||
let fixture = fixture(true);
|
||||
let app = open(&fixture);
|
||||
let archive_path = app.create_manual_backup().expect("valid source archive");
|
||||
let mut archive_bytes = fs::read(archive_path).expect("archive bytes");
|
||||
let settings_member = 1536; // Manifest data occupies one 512-byte block.
|
||||
let settings_end = archive_bytes[settings_member..]
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.map(|length| settings_member + length)
|
||||
.expect("settings data terminates before padding");
|
||||
let settings =
|
||||
std::str::from_utf8(&archive_bytes[settings_member..settings_end]).expect("settings UTF-8");
|
||||
let changed = settings.replace("interface_name = \"wg0\"", "interface_name = \"wg1\"");
|
||||
assert_ne!(changed, settings);
|
||||
archive_bytes[settings_member..settings_end].copy_from_slice(changed.as_bytes());
|
||||
let changed_archive = fixture
|
||||
.settings
|
||||
.archives_directory
|
||||
.join("wireguard-admin-interface-switch.tar");
|
||||
fs::write(&changed_archive, archive_bytes).expect("write mutated synthetic archive");
|
||||
|
||||
assert!(matches!(
|
||||
app.preview_restore(archive_name(&changed_archive)),
|
||||
Err(AppError::Persistence(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
app.preview_restore("../outside.tar"),
|
||||
Err(AppError::InvalidArchiveSelection)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_parser_rejects_traversal_links_duplicates_and_oversized_members() {
|
||||
let fixture = fixture(false);
|
||||
let path = open(&fixture)
|
||||
.create_manual_backup()
|
||||
.expect("manual archive");
|
||||
let archive = fs::read(&path).expect("archive bytes");
|
||||
|
||||
let traversal = archive_with_header_change(&archive, 0, |header| {
|
||||
header[..100].fill(0);
|
||||
header[..11].copy_from_slice(b"../manifest");
|
||||
});
|
||||
assert!(preview_archive_bytes(&traversal).is_err());
|
||||
|
||||
let link = archive_with_header_change(&archive, 0, |header| header[156] = b'2');
|
||||
assert!(preview_archive_bytes(&link).is_err());
|
||||
let special = archive_with_header_change(&archive, 0, |header| header[156] = b'3');
|
||||
assert!(preview_archive_bytes(&special).is_err());
|
||||
|
||||
let duplicate = archive_with_header_change(&archive, 1, |header| {
|
||||
header[..100].fill(0);
|
||||
header[..11].copy_from_slice(b"server.conf");
|
||||
});
|
||||
assert!(preview_archive_bytes(&duplicate).is_err());
|
||||
|
||||
let oversized = archive_with_header_change(&archive, 1, |header| {
|
||||
header[124..136].fill(b'0');
|
||||
header[124..136].copy_from_slice(b"00050000000\0");
|
||||
});
|
||||
assert!(preview_archive_bytes(&oversized).is_err());
|
||||
assert!(preview_archive_bytes(&archive[..archive.len() - 512]).is_err());
|
||||
|
||||
let symlink = fixture.root.path().join("wireguard-admin-symlink.tar");
|
||||
std::os::unix::fs::symlink(&path, &symlink).expect("archive symlink");
|
||||
assert!(preview_archive(&symlink).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_and_installed_secret_bearing_files_are_private_and_debug_is_redacted() {
|
||||
let fixture = fixture(true);
|
||||
let mut app = open(&fixture);
|
||||
app.prepare_initial_vault(VaultBundle::create("fixture-only password").expect("vault"))
|
||||
.expect("stage vault");
|
||||
let backup = app.create_manual_backup().expect("backup");
|
||||
app.commit().expect("commit");
|
||||
|
||||
let archive_mode = fs::metadata(&backup)
|
||||
.expect("archive metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(archive_mode, 0o600);
|
||||
let saved = fixture.store.read_saved().expect("saved bytes");
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.settings.data_directory.join("vault.toml"))
|
||||
.expect("vault metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.settings.data_directory)
|
||||
.expect("data directory metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
|
||||
let debug = format!("{saved:?} {:?}", preview_archive(&backup).expect("preview"));
|
||||
assert!(!debug.contains("fixture-server-secret"));
|
||||
assert!(!debug.contains(&fixture.secret.to_base64()));
|
||||
assert!(!debug.contains("fixture-only password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_aliases_and_symlinked_destinations_fail_closed() {
|
||||
let fixture = fixture(true);
|
||||
let server = fixture.settings.server_config_path.clone();
|
||||
let target = fixture.root.path().join("target.conf");
|
||||
fs::rename(&server, &target).expect("move synthetic server fixture");
|
||||
std::os::unix::fs::symlink(&target, &server).expect("server symlink");
|
||||
assert!(fixture.store.read_saved().is_err());
|
||||
|
||||
let mut aliased_settings = SettingsFile::parse_toml(SETTINGS).expect("settings parse");
|
||||
aliased_settings.server_config_path = PathBuf::from("wireguard-admin.toml");
|
||||
let aliased = aliased_settings
|
||||
.validate()
|
||||
.expect("settings validate")
|
||||
.resolve_paths(fixture.root.path());
|
||||
assert!(SavedStore::new(fixture.root.path(), aliased).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes() {
|
||||
let primary = fixture(true);
|
||||
let mut app = open(&primary);
|
||||
let original = primary.store.read_saved().expect("saved bytes before");
|
||||
app.rename_client(primary.key, "draft alias")
|
||||
.expect("stage rename");
|
||||
|
||||
let archive_directory = primary.settings.archives_directory.clone();
|
||||
fs::create_dir_all(archive_directory.parent().expect("archive parent"))
|
||||
.expect("create archive parent");
|
||||
fs::write(&archive_directory, b"block backup creation").expect("block archive creation");
|
||||
assert!(app.commit().is_err());
|
||||
assert_eq!(
|
||||
primary
|
||||
.store
|
||||
.read_saved()
|
||||
.expect("saved bytes unchanged")
|
||||
.server_config,
|
||||
original.server_config
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.draft()
|
||||
.get(&primary.key)
|
||||
.expect("draft remains")
|
||||
.alias,
|
||||
"draft alias"
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.saved()
|
||||
.get(&primary.key)
|
||||
.expect("saved remains")
|
||||
.alias,
|
||||
"fixture client"
|
||||
);
|
||||
|
||||
let second = fixture(false);
|
||||
let mut restore_app = open(&second);
|
||||
let selected = restore_app
|
||||
.create_manual_backup()
|
||||
.expect("selected restore archive");
|
||||
let preview = restore_app
|
||||
.preview_restore(archive_name(&selected))
|
||||
.expect("preview archive");
|
||||
restore_app
|
||||
.add_client(
|
||||
Client::new(
|
||||
second.key,
|
||||
"draft survives failed restore",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.4".parse().expect("IPv4"),
|
||||
ipv6: Some("fd20::4".parse().expect("IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("draft client"),
|
||||
None,
|
||||
)
|
||||
.expect("stage draft");
|
||||
let before_restore = second.store.read_saved().expect("saved before restore");
|
||||
fs::remove_dir_all(&second.settings.archives_directory).expect("remove archives");
|
||||
fs::write(
|
||||
&second.settings.archives_directory,
|
||||
b"block precautionary backup",
|
||||
)
|
||||
.expect("block pre-restore backup");
|
||||
assert!(restore_app.confirm_restore(preview).is_err());
|
||||
assert_eq!(
|
||||
second
|
||||
.store
|
||||
.read_saved()
|
||||
.expect("saved unchanged")
|
||||
.server_config,
|
||||
before_restore.server_config
|
||||
);
|
||||
assert_eq!(
|
||||
restore_app
|
||||
.states()
|
||||
.draft()
|
||||
.get(&second.key)
|
||||
.expect("draft survives")
|
||||
.alias,
|
||||
"draft survives failed restore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_uses_public_key_and_removes_only_that_vault_record() {
|
||||
let fixture = fixture(true);
|
||||
let mut app = open(&fixture);
|
||||
let mut vault = VaultBundle::create("fixture password").expect("vault");
|
||||
vault
|
||||
.encrypt_record(fixture.key, &fixture.secret)
|
||||
.expect("selected record");
|
||||
let retained_secret = WireGuardPrivateKey::generate().expect("file-only key");
|
||||
let retained_key = retained_secret.public_key();
|
||||
vault
|
||||
.encrypt_record(retained_key, &retained_secret)
|
||||
.expect("unrelated file-only record");
|
||||
app.prepare_initial_vault(vault).expect("stage vault");
|
||||
app.commit().expect("install vault");
|
||||
|
||||
assert!(
|
||||
app.delete_client(fixture.key)
|
||||
.expect("delete selected public key")
|
||||
);
|
||||
app.commit().expect("commit deletion");
|
||||
let saved = fixture.store.read_saved().expect("saved bytes");
|
||||
let bundle = VaultBundle::from_toml(
|
||||
std::str::from_utf8(saved.vault.as_deref().expect("vault remains installed"))
|
||||
.expect("vault UTF-8"),
|
||||
)
|
||||
.expect("vault parses");
|
||||
assert!(!bundle.contains_record(fixture.key));
|
||||
assert!(bundle.contains_record(retained_key));
|
||||
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use wireguard_admin::domain::{Client, ClientAddresses, PublicKey};
|
||||
use wireguard_admin::wireguard::{
|
||||
ConfigDocument, DocumentIssue, EditError, MetadataAttachment, MetadataIssue, PeerIssue,
|
||||
};
|
||||
|
||||
const FIXTURE: &str = include_str!("fixtures/wireguard/representative.conf");
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
const KEY_C: &str = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=";
|
||||
const KEY_D: &str = "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=";
|
||||
const SECRET_MARKERS: [&str; 3] = [
|
||||
"SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY",
|
||||
"SYNTHETIC-PLACEHOLDER-PEER-PSK-A",
|
||||
"SYNTHETIC-PLACEHOLDER-PEER-PSK-B",
|
||||
];
|
||||
type MetadataCase = (&'static str, &'static str, fn(&MetadataIssue) -> bool);
|
||||
|
||||
fn key(text: &str) -> PublicKey {
|
||||
PublicKey::from_str(text).expect("synthetic public key is valid")
|
||||
}
|
||||
|
||||
fn client(public_key: &str, alias: &str, last_octet: u8) -> Client {
|
||||
Client::new(
|
||||
key(public_key),
|
||||
alias,
|
||||
ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 44, 0, last_octet),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.expect("test alias is valid")
|
||||
}
|
||||
|
||||
fn text(document: &ConfigDocument) -> &str {
|
||||
std::str::from_utf8(document.render()).expect("fixture is UTF-8")
|
||||
}
|
||||
|
||||
fn assert_secret_free_debug<T: std::fmt::Debug>(value: &T) {
|
||||
let debug = format!("{value:?}");
|
||||
for marker in SECRET_MARKERS {
|
||||
assert!(
|
||||
!debug.contains(marker),
|
||||
"Debug leaked a synthetic secret marker"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_lf_crlf_mixed_and_missing_final_newline_inputs() {
|
||||
let lf = FIXTURE.as_bytes().to_vec();
|
||||
let crlf = FIXTURE.replace('\n', "\r\n").into_bytes();
|
||||
let mixed = FIXTURE
|
||||
.lines()
|
||||
.enumerate()
|
||||
.flat_map(|(line, value)| {
|
||||
let ending = if line % 3 == 1 { "\r\n" } else { "\n" };
|
||||
format!("{value}{ending}").into_bytes()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut no_final_newline = lf.clone();
|
||||
while no_final_newline.last() == Some(&b'\n') || no_final_newline.last() == Some(&b'\r') {
|
||||
no_final_newline.pop();
|
||||
}
|
||||
|
||||
for (label, source) in [
|
||||
("LF", lf),
|
||||
("CRLF", crlf),
|
||||
("mixed", mixed),
|
||||
("no final newline", no_final_newline),
|
||||
] {
|
||||
let parsed = ConfigDocument::parse(&source).expect("synthetic UTF-8 parses");
|
||||
assert!(
|
||||
parsed.render() == source.as_slice(),
|
||||
"{label} round trip changed bytes"
|
||||
);
|
||||
assert_eq!(parsed.peers().len(), 2, "{label} lost a peer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed() {
|
||||
let original = ConfigDocument::parse(FIXTURE).unwrap();
|
||||
let original_text = text(&original).to_owned();
|
||||
let interface_end = original_text.find("\n\n# Admin note").unwrap();
|
||||
let expected_interface = &original_text[..interface_end + 1];
|
||||
let with_metadata = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "old", Some("2024-02-29T12:30:00Z"))
|
||||
.unwrap();
|
||||
let renamed = with_metadata
|
||||
.rename_client(&key(KEY_A), "new # = \"café\"")
|
||||
.unwrap();
|
||||
let result = text(&renamed);
|
||||
assert!(
|
||||
result.starts_with(expected_interface),
|
||||
"interface bytes changed"
|
||||
);
|
||||
assert!(result.contains("# Another administrator note.\n"));
|
||||
assert!(result.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A\n"));
|
||||
assert!(result.contains("CustomPeerOption = keep-me\n"));
|
||||
assert!(result.contains("# wg-admin:version=1\n"));
|
||||
assert!(result.contains("# wg-admin:created-utc=2024-02-29T12:30:00Z\n"));
|
||||
assert_eq!(
|
||||
renamed.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"new # = \"café\""
|
||||
);
|
||||
assert_eq!(
|
||||
ConfigDocument::parse(renamed.render()).unwrap().render(),
|
||||
renamed.render()
|
||||
);
|
||||
|
||||
let deleted = renamed.delete_client(&key(KEY_A)).unwrap();
|
||||
let deleted_text = text(&deleted);
|
||||
assert!(
|
||||
deleted_text.starts_with(expected_interface),
|
||||
"delete changed interface bytes"
|
||||
);
|
||||
assert!(!deleted_text.contains(KEY_A));
|
||||
assert!(deleted_text.contains("# Admin note: retained between interface and peer.\n"));
|
||||
assert!(deleted_text.contains("# Another administrator note.\n"));
|
||||
assert!(deleted_text.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B\n"));
|
||||
assert_eq!(deleted.peers().len(), 1);
|
||||
assert_eq!(deleted.peers()[0].identity, Some(key(KEY_B)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_in_mixed_ending_metadata_changes_only_alias_representation() {
|
||||
let source = format!(
|
||||
"# admin preface\r\n# wg-admin:begin\n# wg-admin:version=1\r\n# wg-admin:alias=b2xk\n# wg-admin:created-utc=2024-01-02T03:04:05Z\r\n# wg-admin:end\n[Peer]\r\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\r\nPresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A"
|
||||
);
|
||||
let original = ConfigDocument::parse(&source).unwrap();
|
||||
let renamed = original.rename_client(&key(KEY_A), "new").unwrap();
|
||||
let expected = source.replace("# wg-admin:alias=b2xk", "# wg-admin:alias=bmV3");
|
||||
assert!(
|
||||
text(&renamed) == expected,
|
||||
"rename must replace only alias bytes"
|
||||
);
|
||||
assert_eq!(renamed.project_client(&key(KEY_A)).unwrap().alias, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_delete_and_repeated_edits_recompute_spans_without_touching_neighbors() {
|
||||
let source = "[Interface]\nListenPort = 51820\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.44.0.2/32\n\n# inter-peer note\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.44.0.3/32\n\n[Peer]\nPublicKey = AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\nAllowedIPs = 10.44.0.4/32";
|
||||
let original = ConfigDocument::parse(source).unwrap();
|
||||
let added = original
|
||||
.add_client(&client(KEY_D, "duplicate alias", 9), None)
|
||||
.unwrap();
|
||||
assert!(
|
||||
text(&added).starts_with(source),
|
||||
"add changed existing source bytes"
|
||||
);
|
||||
let with_duplicate_aliases = added
|
||||
.insert_legacy_metadata(&key(KEY_A), "shared alias", None)
|
||||
.unwrap()
|
||||
.insert_legacy_metadata(&key(KEY_B), "shared alias", None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
with_duplicate_aliases
|
||||
.project_client(&key(KEY_A))
|
||||
.unwrap()
|
||||
.alias,
|
||||
with_duplicate_aliases
|
||||
.project_client(&key(KEY_B))
|
||||
.unwrap()
|
||||
.alias
|
||||
);
|
||||
|
||||
let delete_first = original.delete_client(&key(KEY_A)).unwrap();
|
||||
assert!(text(&delete_first).contains("# inter-peer note\n"));
|
||||
assert!(text(&delete_first).contains(KEY_B));
|
||||
assert!(text(&delete_first).contains(KEY_C));
|
||||
let delete_last = delete_first.delete_client(&key(KEY_C)).unwrap();
|
||||
assert!(text(&delete_last).contains(KEY_B));
|
||||
assert!(!text(&delete_last).contains(KEY_C));
|
||||
let delete_only_peer = delete_last.delete_client(&key(KEY_B)).unwrap();
|
||||
assert!(delete_only_peer.peers().is_empty());
|
||||
|
||||
let twice_renamed = added
|
||||
.rename_client(&key(KEY_D), "#marker = \"café\"")
|
||||
.unwrap()
|
||||
.rename_client(&key(KEY_D), "duplicate alias")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
twice_renamed.project_client(&key(KEY_D)).unwrap().alias,
|
||||
"duplicate alias"
|
||||
);
|
||||
assert_eq!(text(&twice_renamed).matches("[Peer]").count(), 4);
|
||||
assert!(text(&twice_renamed).contains("# inter-peer note\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_issues_are_preserved_and_unsafe_edits_fail_without_mutation() {
|
||||
let cases: [MetadataCase; 3] = [
|
||||
(
|
||||
"unknown version",
|
||||
"# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2xk\n# wg-admin:end\n",
|
||||
|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }),
|
||||
),
|
||||
(
|
||||
"unknown field",
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:future=x\n# wg-admin:end\n",
|
||||
|issue| matches!(issue, MetadataIssue::UnknownField { .. }),
|
||||
),
|
||||
(
|
||||
"duplicate field",
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:alias=b3RoZXI\n# wg-admin:end\n",
|
||||
|issue| matches!(issue, MetadataIssue::UnknownField { .. }),
|
||||
),
|
||||
];
|
||||
for (label, block, matches_issue) in cases {
|
||||
let source = format!("{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n");
|
||||
let parsed = ConfigDocument::parse(&source).unwrap();
|
||||
assert!(
|
||||
matches!(&parsed.peers()[0].metadata, MetadataAttachment::Invalid(issue) if matches_issue(issue)),
|
||||
"{label} not reported"
|
||||
);
|
||||
assert!(matches!(
|
||||
parsed.rename_client(&key(KEY_A), "safe"),
|
||||
Err(EditError::UnsafeDocument | EditError::UnsafePeer)
|
||||
));
|
||||
assert!(
|
||||
parsed.render() == source.as_bytes(),
|
||||
"{label} rejection mutated source"
|
||||
);
|
||||
}
|
||||
|
||||
let malformed = format!(
|
||||
"# wg-admin:begin \n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&malformed).unwrap();
|
||||
assert!(!parsed.issues().is_empty());
|
||||
assert!(parsed.rename_client(&key(KEY_A), "safe").is_err());
|
||||
assert!(
|
||||
parsed.render() == malformed.as_bytes(),
|
||||
"malformed input bytes changed"
|
||||
);
|
||||
|
||||
let misattached = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n# admin intervenes\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&misattached).unwrap();
|
||||
assert!(parsed.issues().iter().any(|issue| matches!(
|
||||
issue,
|
||||
DocumentIssue::ManagedMetadata(MetadataIssue::Misattached { .. })
|
||||
)));
|
||||
assert!(parsed.rename_client(&key(KEY_A), "safe").is_err());
|
||||
assert!(
|
||||
parsed.render() == misattached.as_bytes(),
|
||||
"misattached input bytes changed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_adjacent_metadata_blocks_and_ambiguous_peers_remain_visible() {
|
||||
let block = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b25l\n# wg-admin:end\n";
|
||||
let duplicate_blocks =
|
||||
format!("{block}{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n");
|
||||
let parsed = ConfigDocument::parse(&duplicate_blocks).unwrap();
|
||||
assert!(matches!(
|
||||
&parsed.peers()[0].metadata,
|
||||
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
|
||||
));
|
||||
assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err());
|
||||
assert!(
|
||||
parsed.render() == duplicate_blocks.as_bytes(),
|
||||
"duplicate blocks changed"
|
||||
);
|
||||
|
||||
let ambiguous = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\nPublicKey = {KEY_B}\n\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.3/32\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&ambiguous).unwrap();
|
||||
assert!(
|
||||
parsed.peers()[0]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePublicKeyField { .. }))
|
||||
);
|
||||
assert!(
|
||||
parsed.peers()[1]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
|
||||
);
|
||||
assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err());
|
||||
assert!(
|
||||
parsed.render() == ambiguous.as_bytes(),
|
||||
"ambiguous source bytes changed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_encode_unicode_and_syntax_and_reject_control_injection() {
|
||||
for alias in ["# café = \"laptop\"", "# wg-admin:begin", "x=y # z"] {
|
||||
let document = ConfigDocument::parse(format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
))
|
||||
.unwrap()
|
||||
.insert_legacy_metadata(&key(KEY_A), alias, None)
|
||||
.unwrap();
|
||||
assert_eq!(document.project_client(&key(KEY_A)).unwrap().alias, alias);
|
||||
assert!(text(&document).contains("# wg-admin:alias="));
|
||||
let rendered_alias = text(&document)
|
||||
.lines()
|
||||
.find(|line| line.starts_with("# wg-admin:alias="))
|
||||
.expect("metadata has an alias line");
|
||||
assert!(!rendered_alias.contains(alias));
|
||||
}
|
||||
|
||||
let original = ConfigDocument::parse(format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
))
|
||||
.unwrap();
|
||||
for alias in ["line\nbreak", "carriage\rreturn", "terminal\u{001b}escape"] {
|
||||
assert!(matches!(
|
||||
original.insert_legacy_metadata(&key(KEY_A), alias, None),
|
||||
Err(EditError::InvalidAlias)
|
||||
));
|
||||
assert_eq!(original.render().last(), Some(&b'\n'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection_fails() {
|
||||
let cases = [
|
||||
("multiple host routes", "10.44.0.2/32, 10.44.0.22/32"),
|
||||
("network route", "10.44.0.0/24"),
|
||||
("default route", "0.0.0.0/0"),
|
||||
("IPv6 only", "fd42::2/128"),
|
||||
];
|
||||
for (label, routes) in cases {
|
||||
let source = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = {routes}\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&source).unwrap();
|
||||
let peer = &parsed.peers()[0];
|
||||
assert!(
|
||||
parsed.render() == source.as_bytes(),
|
||||
"{label} round trip changed bytes"
|
||||
);
|
||||
match label {
|
||||
"multiple host routes" => assert!(peer.issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }
|
||||
))),
|
||||
"network route" | "default route" => assert!(peer.issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 0 }
|
||||
))),
|
||||
"IPv6 only" => assert!(peer.issues.contains(&PeerIssue::MissingIpv4HostRoute)),
|
||||
_ => unreachable!("test case has an expected route shape"),
|
||||
}
|
||||
let issue_debug = format!("{:?}", peer.issues);
|
||||
assert!(
|
||||
!issue_debug.contains(routes),
|
||||
"{label} issue exposed route text"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses),
|
||||
"{label} projection"
|
||||
);
|
||||
let renamed = parsed.rename_client(&key(KEY_A), "updated").unwrap();
|
||||
let expected = source.replace("# wg-admin:alias=Y2xpZW50", "# wg-admin:alias=dXBkYXRlZA");
|
||||
assert_eq!(
|
||||
text(&renamed),
|
||||
expected,
|
||||
"{label} rename changed other bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety() {
|
||||
let source = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(&source).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
assert!(peer.has_subnet_routes);
|
||||
assert_eq!(peer.allowed_ips.len(), 3);
|
||||
assert!(peer.issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }
|
||||
)));
|
||||
assert_eq!(document.render(), source.as_bytes());
|
||||
assert_eq!(
|
||||
document.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
let renamed = document
|
||||
.rename_client(&key(KEY_A), "alias-only edit remains byte-preserving")
|
||||
.expect("safe alias-only edit remains supported");
|
||||
assert!(
|
||||
String::from_utf8_lossy(renamed.render())
|
||||
.contains("AllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24")
|
||||
);
|
||||
|
||||
let malformed = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, not-an-address\n"
|
||||
);
|
||||
let mixed = ConfigDocument::parse(&malformed).unwrap();
|
||||
assert!(mixed.peers()[0].has_subnet_routes);
|
||||
assert!(
|
||||
mixed.peers()[0]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() {
|
||||
let document = ConfigDocument::parse(FIXTURE).unwrap();
|
||||
assert_secret_free_debug(&document);
|
||||
assert_secret_free_debug(&document.peers());
|
||||
let duplicate_identity = document
|
||||
.add_client(&client(KEY_A, "duplicate", 9), None)
|
||||
.unwrap_err();
|
||||
assert_secret_free_debug(&duplicate_identity);
|
||||
let invalid = ConfigDocument::parse(b"[Interface]\nPrivateKey = SYNTHETIC\n\xff").unwrap_err();
|
||||
assert_secret_free_debug(&invalid);
|
||||
assert!(!invalid.to_string().contains("SYNTHETIC"));
|
||||
|
||||
let original_bytes = document.render().to_vec();
|
||||
assert!(document.rename_client(&key(KEY_C), "not present").is_err());
|
||||
assert!(document.delete_client(&key(KEY_C)).is_err());
|
||||
assert!(
|
||||
document.render() == original_bytes.as_slice(),
|
||||
"failed edits changed source bytes"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user