Compare commits

...

5 Commits

Author SHA1 Message Date
sasa 5418841b92 Implement backups, commit and restoration 2026-10-20 00:16:55 +03:00
sasa c879a8101b Handle client keys, encrypted vault 2026-10-19 23:11:29 +03:00
sasa 766059e6d1 Add server configuration editing with byte preservation 2026-10-18 22:38:30 +03:00
sasa c2b899dd1e Define Rust environment and toolchain 2026-10-18 22:38:30 +03:00
sasa 7b475bfcc8 Add Codex specific files 2026-10-18 21:39:45 +03:00
42 changed files with 14460 additions and 0 deletions
+13
View File
@@ -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.
"""
+18
View File
@@ -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.
"""
+17
View File
@@ -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.
"""
+19
View File
@@ -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.
"""
+31
View File
@@ -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"
+5
View File
@@ -16,3 +16,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/
+63
View File
@@ -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, and previewed restore. The management interface and VPN runtime operations are not implemented yet. Do not report planned modules, tests, or commands as already working.
- Read [SPEC.md](docs/SPEC.md) and [CONTEXT.md](CONTEXT.md) before implementing behavior. The approved specification is the product contract; do not change it to make tests pass.
- For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence.
- 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. 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.
Generated
+1728
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[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"
getrandom = "=0.3.4"
ipnet = "=2.11.0"
rustix = { version = "=1.1.5", default-features = false, features = ["fs", "process", "std"] }
serde = { version = "=1.0.229", features = ["derive"] }
thiserror = "=2.0.20"
tar = { version = "=0.4.44", default-features = false }
tempfile = "=3.23.0"
time = { version = "=0.3.44", default-features = false, features = ["formatting", "parsing", "std"] }
toml = "=1.1.8"
x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] }
zeroize = "=1.9.0"
+1437
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -1,2 +1,110 @@
# wireguard-admin
This repository contains the Rust package, startup boundary, validated settings
loader, client domain model, a byte-preserving in-memory WireGuard configuration
editor, and library APIs for encrypted client vaults and read-only client import.
The library also provides saved-file persistence with recovery, private backups,
prepared Commit, and previewed full restore. The terminal management interface
and WireGuard/systemd runtime operations have not been implemented yet.
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From
the repository root, run:
```sh
cargo run -- --help
cargo run -- --version
```
`--help` and `--version` work without root. Starting with no arguments requires
root; until the management interface is implemented, startup exits with an
error and does not read server configuration or vault files.
## Settings
Settings use TOML in the fixed file `wireguard-admin.toml` beside the
executable. Start from [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml).
The required fields are:
- `interface_name`: existing WireGuard interface name (1–15 ASCII characters).
- `server_config_path`: path to the existing server configuration.
- `client_source_directory`: directory containing existing client files.
- `server_endpoint`: hostname or IPv4 address followed by `:port`, or IPv6 in
brackets followed by `:port` (for example, `[2001:db8::2]:51820`).
- `ipv4_allocation_range` and `ipv6_allocation_range`: CIDR ranges of the
matching address family.
- `dns_addresses`: non-empty list of IPv4 and/or IPv6 literal addresses.
- `systemd_unit`: service unit name ending in `.service`.
- `restart_timeout_seconds`: positive integer.
`data_root` is optional. It defaults to `Backup` beside the executable;
current internal data and the encrypted vault are under `data`, and archives
are under `archives`. Relative configured paths, including an explicit
`data_root`, resolve from the executable directory. Absolute paths remain
absolute. Settings validation checks syntax and values only; it does not
require the referenced files, directories, or service to exist and does not
perform DNS lookups.
## 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
```
Management startup still exits with an explicit “not implemented” error after
its root check. The application library APIs are usable by callers, but are not
yet connected to a management session or runtime VPN adapter.
## 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. Restoring a
backup does not restore runtime state: the running interface is unknown until a
future explicit runtime verification. Pending deleted-client addresses remain
reserved through Commit, reopen, and restore. See
[docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and
[docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details.
## 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`.
+17
View File
@@ -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"
+116
View File
@@ -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.
+68
View File
@@ -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. 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)
+98
View File
@@ -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
```
+40
View File
@@ -0,0 +1,40 @@
# 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 and controlled low-level filesystem operations |
| Encrypted vault | `age` | Recipient encryption and password protection of the vault secret key |
| WireGuard key pairs | `x25519-dalek` | Key generation and public/private key matching |
| Secure randomness | `getrandom` | Operating-system randomness for key generation |
| WireGuard key encoding | `base64` | Reading and writing WireGuard key representations |
| Secret handling | `secrecy`, `zeroize` | Explicit secret exposure and supported memory cleanup |
| WireGuard runtime | `wireguard-uapi` | Direct Linux interface, peer, handshake, and traffic queries |
| Service management | `zbus` | Direct systemd D-Bus requests and job completion tracking |
| Container integration tests | `testcontainers` | Alpine server/client lifecycle; development dependency only |
- Use a separate vault key pair from all WireGuard client/server key pairs. Encrypt each client secret to the vault public key; protect the vault secret key with password-based encryption using the established `age` format. Do not replace this with password-only encryption of every new client record, which would require a password on creation.
- Validate `wireguard-uapi` against the isolated integration environment before relying on it. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application.
- Avoid incompatible duplicate versions of secret-handling types where library APIs exchange them. Use supported re-exports where appropriate rather than adding redundant direct dependencies.
- Additional direct dependencies still require the comparison and prior agreement required by [AGENTS.md](../AGENTS.md), including any test/build helpers. Transitive dependencies of approved crates are resolved through Cargo; review their compatibility and runtime requirements. Do not add a direct `tokio` dependency merely because an approved library uses an async runtime internally.
- `clap`, `chrono`, SQLite, a generic INI writer, a mocking framework, and archive compression libraries are not part of the approved initial set. Use the standard library for the small startup argument surface and background threads/channels where sufficient.
### External runtime and build requirements
- Production requires Linux with WireGuard support, the existing configured systemd service, and the system D-Bus needed by `zbus`. Existing `wg-quick` service dependencies remain necessary if that is how the service manages the interface.
- 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.
+69
View File
@@ -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.
+74
View File
@@ -0,0 +1,74 @@
# 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.
Once prerequisites and Cargo scaffolding exist, the planned cross-build check is:
```sh
cargo build --locked --release --target x86_64-unknown-linux-musl
```
Then run the built executable in Alpine and inspect actual dynamic-library
requirements. A target name alone is not evidence that the binary runs there.
Musl readiness is a milestone 8 prerequisite, not a milestone 1 acceptance gate.
## 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).
+105
View File
@@ -0,0 +1,105 @@
# 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.
+64
View File
@@ -0,0 +1,64 @@
# Project Layout
Read this document when creating or reorganizing modules. The paths below are planned until implemented.
## Target project layout
Start with one Cargo package containing a binary (`src/main.rs`) and a library (`src/lib.rs`). Keep startup, argument handling, and process exit in `main.rs`; place testable application logic in library modules. A library target does not require publishing a separate library package. Introduce a workspace only when independently reusable or separately built components justify it.
The following is the intended layout, not a claim that these files already exist. Create modules and directories as implementation requires them; do not generate empty scaffolding for the entire tree.
```text
Cargo.toml
Cargo.lock
rust-toolchain.toml
AGENTS.md
CONTEXT.md
PLANS.md
README.md
.gitignore
src/
main.rs # Startup, arguments, process exit
lib.rs # Module declarations and application API
config.rs # Application settings and path resolution
domain.rs # Clients, identities, address allocation, change states
app.rs # Application operations and orchestration
wireguard.rs # Server configuration module boundary
wireguard/
document.rs # Parsing and edits preserving original text
metadata.rs # Managed client comment blocks
vault.rs # Encryption, import, secret retrieval
backup.rs # Backup archives and restoration
runtime.rs # Running WireGuard interface and statistics
service.rs # Systemd operations and restart verification
delivery.rs # Client configuration files and QR generation
tui.rs # Terminal UI entry point
tui/
state.rs # Screen state and navigation
events.rs # Keyboard and background events
render.rs # Rendering
terminal.rs # Terminal setup and cleanup
tests/
config_preservation.rs # Untouched server configuration remains identical
commit_restore.rs # Persistence, backups, restore, runtime isolation
client_lifecycle.rs # Import, creation, delivery, deletion, state transitions
fixtures/ # Synthetic configuration and archive inputs
support/
mod.rs # Shared test adapters and helpers
config/
wireguard-admin.example.toml # Safe example; settings format chosen at implementation
docs/
SPEC.md
IMPLEMENTATION.md
adr/
target/ # Cargo output; never commit
```
- Use modules named for their responsibility. Avoid catch-all `utils`, `helpers`, or `common` modules; place supporting code with its owner.
- Begin with a single module file and split into child modules when needed. Prefer `name.rs` with `name/child.rs` for production modules; `tests/support/mod.rs` keeps test helpers from becoming a separate integration test target.
- Declare application modules once in the library, rather than duplicating them in both `main.rs` and `lib.rs`. Keep internals private or `pub(crate)`; expose only the library API needed by the binary and behavioral integration tests.
- Keep focused unit tests beside their modules under `#[cfg(test)]`; put cross-module behavioral tests in `tests/`. Use `tests/support/` for test-only adapters rather than exporting test infrastructure from the production library.
- The `config/` directory contains distributable examples, not live settings. The example filename above is illustrative and does not settle the settings format. Actual settings remain alongside the deployed executable as specified.
- Keep live server configurations, private keys, delivered client files, and runtime `Backup/` data out of the source tree and version control. Ignore generated build output and local deployment data without hiding synthetic fixtures.
- Add `examples/` only for runnable Rust examples and `benches/` only when benchmarks are needed. Do not add empty directories or scripts that duplicate Cargo commands.
- Keep the layout aligned with actual module responsibilities as the code grows. Update this section when files move; do not preserve obsolete paths merely to match the initial tree.
+64
View File
@@ -0,0 +1,64 @@
# 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.
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.
## Integration testing with Alpine Testcontainers
Production access is limited and experimentation on the production VPN is prohibited. Use three complementary levels: ordinary unit/fixture tests, explicitly selected Alpine container integration tests, and a separate disposable Linux VM for real systemd/D-Bus acceptance. No level authorizes using production as a test environment.
### Alpine environment
- Use `testcontainers` to start separate Alpine Linux server and client containers on a dedicated test network. Keep the image minimal: `wireguard-tools`, `iproute2`, and only any additional tools actually needed by the fixture. Alpine is not a systemd test environment.
- Build and cache the fixture image ahead of repeated test runs. Pin the Alpine base/version or digest and document the image recipe; avoid package installation and image rebuilding for every test. Reuse image layers, not mutable containers or secret-bearing volumes between tests.
- The Docker host must provide Linux kernel WireGuard support. Containers share that kernel; installing userspace tools in Alpine does not supply it. Probe prerequisites in the disposable environment and report missing capabilities clearly. Do not automatically load host modules or change host settings.
- Grant `NET_ADMIN` in the test containers for their own network namespaces. Do not default to `--privileged`, `SYS_MODULE`, host networking, or shared host PID namespaces. Configure IPv6 for the dedicated test network as needed; exercise both address families.
- Run the application operations or a test driver linked to the real library inside the server container, including the real `wireguard-uapi` adapter. A host-side test harness may orchestrate containers, but must not query or modify the host's WireGuard interface as a shortcut.
- Build the in-container executable for Alpine compatibility, preferably the matching musl target. Do not assume a glibc-linked host binary runs in Alpine. Keep compiler/toolchain layers out of the small runtime fixture image where practical.
- Generate synthetic server/client keys and configurations for each test. Copy fixtures into container-local filesystems. Never mount production files, the host's `/etc/wireguard`, host system D-Bus sockets, or the Docker socket inside fixture containers.
- Poll explicit readiness and handshake/traffic conditions with deadlines rather than fixed sleeps. Always clean up containers, dedicated networks, and temporary resources after success or failure. Keep diagnostic artifacts redacted.
### Required container scenarios
1. Start an isolated server and existing client, establish a tunnel, and read real peer state through the runtime adapter.
2. Create a dual-stack client through the application, Commit, and verify saved files/vault plus unchanged running peer state. Deliver the client configuration and verify that it cannot connect before explicit application.
3. Use a test-only fixture operation to apply the saved configuration inside the container. Establish the new tunnel, generate IPv4/IPv6 traffic, and verify handshake and increasing counters without asserting exact byte totals.
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.
### Systemd acceptance and test execution
- Use a disposable Linux VM with WireGuard, systemd, and a system D-Bus to verify the actual `zbus` service adapter, job completion/timeouts, restart verification, and recovery from a selected backup. Do not substitute a mounted host D-Bus or claim Alpine tests cover systemd.
- Keep container and VM suites out of the default `cargo test` execution. Once implemented, document their exact opt-in commands and prerequisites in this document. Do not list a planned command as available before its test target exists.
- When integration testing is explicitly requested, unavailable Docker, WireGuard support, IPv6, or required capabilities means the suite is not executed or incomplete, not passed. Distinguish prerequisite failures from application failures in the report.
- 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.
+132
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.97.1"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = ["x86_64-unknown-linux-musl"]
+1482
View File
File diff suppressed because it is too large Load Diff
+973
View File
@@ -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
View File
@@ -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")
);
}
}
+812
View File
@@ -0,0 +1,812 @@
//! Pure client identity, configuration-state, and address-allocation logic.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use ipnet::{Ipv4Net, Ipv6Net};
use thiserror::Error;
/// A WireGuard public key, decoded and validated as exactly 32 bytes.
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PublicKey([u8; 32]);
impl PublicKey {
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl FromStr for PublicKey {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let decoded = STANDARD
.decode(value)
.map_err(|_| DomainError::InvalidPublicKey)?;
let bytes: [u8; 32] = decoded
.try_into()
.map_err(|_| DomainError::InvalidPublicKey)?;
// Reject alternate or noncanonical encodings, including whitespace and missing padding.
if STANDARD.encode(bytes) != value {
return Err(DomainError::InvalidPublicKey);
}
Ok(Self(bytes))
}
}
impl fmt::Display for PublicKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&STANDARD.encode(self.0))
}
}
impl fmt::Debug for PublicKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("PublicKey")
.field(&self.to_string())
.finish()
}
}
/// Client addresses. Existing imported IPv4-only records keep `ipv6` absent.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientAddresses {
pub ipv4: Ipv4Addr,
pub ipv6: Option<Ipv6Addr>,
}
impl ClientAddresses {
pub fn iter(&self) -> impl Iterator<Item = IpAddr> + '_ {
std::iter::once(IpAddr::V4(self.ipv4)).chain(self.ipv6.map(IpAddr::V6))
}
}
/// Public client data only; private keys and connection secrets belong elsewhere.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Client {
pub identity: PublicKey,
pub alias: String,
pub addresses: ClientAddresses,
}
impl Client {
pub fn new(
identity: PublicKey,
alias: impl Into<String>,
addresses: ClientAddresses,
) -> Result<Self, DomainError> {
let alias = alias.into();
validate_alias(&alias)?;
Ok(Self {
identity,
alias,
addresses,
})
}
}
pub fn validate_alias(alias: &str) -> Result<(), DomainError> {
if alias.is_empty() {
return Err(DomainError::EmptyAlias);
}
if alias.chars().any(char::is_control) {
return Err(DomainError::InvalidAlias);
}
Ok(())
}
/// A client snapshot keyed by public key, so aliases can repeat without ambiguity.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Snapshot {
clients: BTreeMap<PublicKey, Client>,
}
impl Snapshot {
pub fn new(clients: impl IntoIterator<Item = Client>) -> Result<Self, DomainError> {
let mut snapshot = Self::default();
for client in clients {
let identity = client.identity;
if snapshot.insert(client)?.is_some() {
return Err(DomainError::DuplicatePublicKey(identity));
}
}
Ok(snapshot)
}
pub fn get(&self, identity: &PublicKey) -> Option<&Client> {
self.clients.get(identity)
}
pub fn clients(&self) -> impl Iterator<Item = &Client> {
self.clients.values()
}
pub fn insert(&mut self, client: Client) -> Result<Option<Client>, DomainError> {
validate_alias(&client.alias)?;
Ok(self.clients.insert(client.identity, client))
}
pub fn remove(&mut self, identity: &PublicKey) -> Option<Client> {
self.clients.remove(identity)
}
fn addresses(&self) -> impl Iterator<Item = IpAddr> + '_ {
self.clients
.values()
.flat_map(|client| client.addresses.iter())
}
}
/// Runtime knowledge is not inferred from saved state or missing data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuntimeObservation {
Unknown,
Unavailable,
Verified,
}
/// Draft, saved, and last verified applied data remain independent snapshots.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStates {
draft: Snapshot,
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation,
reserved_addresses: BTreeSet<IpAddr>,
}
impl ClientStates {
/// Start with a saved snapshot and an explicit initial runtime observation.
pub fn new(
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation,
) -> Result<Self, DomainError> {
if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) {
return Err(DomainError::AppliedSnapshotMismatch);
}
let saved_addresses = saved.addresses().collect::<BTreeSet<_>>();
let reserved_addresses = match &applied_snapshot {
Some(snapshot) => snapshot
.addresses()
.filter(|address| !saved_addresses.contains(address))
.collect(),
None => BTreeSet::new(),
};
Ok(Self {
draft: saved.clone(),
saved,
applied_snapshot,
applied,
reserved_addresses,
})
}
pub fn draft(&self) -> &Snapshot {
&self.draft
}
pub fn saved(&self) -> &Snapshot {
&self.saved
}
pub fn applied(&self) -> &RuntimeObservation {
&self.applied
}
pub fn applied_snapshot(&self) -> Option<&Snapshot> {
self.applied_snapshot.as_ref()
}
pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> {
&self.reserved_addresses
}
/// Restore conservative reservations from saved metadata without asserting
/// that the running interface matches the saved snapshot.
pub fn restore_persisted_reservations(
&mut self,
reservations: impl IntoIterator<Item = IpAddr>,
) -> Result<(), DomainError> {
let reservations = reservations.into_iter().collect::<BTreeSet<_>>();
if reservations
.iter()
.any(|address| self.saved.addresses().any(|saved| saved == *address))
{
return Err(DomainError::ReservationConflictsWithSavedClient);
}
self.reserved_addresses = reservations;
self.applied_snapshot = None;
self.applied = RuntimeObservation::Unknown;
Ok(())
}
pub fn draft_mut(&mut self) -> &mut Snapshot {
&mut self.draft
}
/// Save the draft in the model only. This performs no persistence or runtime operation.
pub fn commit_draft(&mut self) {
let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>();
self.reserved_addresses.extend(
self.saved
.addresses()
.filter(|address| !next_addresses.contains(address)),
);
self.saved = self.draft.clone();
if self.applied == RuntimeObservation::Verified
&& self.applied_snapshot.as_ref() != Some(&self.saved)
{
self.applied = RuntimeObservation::Unknown;
}
}
/// Discard draft edits by returning the draft to the saved snapshot.
pub fn discard_draft(&mut self) {
self.draft = self.saved.clone();
}
/// Record an applied state only when the verified observation matches saved clients.
pub fn mark_applied_after_verification(
&mut self,
observed: Snapshot,
) -> Result<(), DomainError> {
if observed != self.saved {
self.applied = RuntimeObservation::Unknown;
return Err(DomainError::AppliedSnapshotMismatch);
}
self.applied_snapshot = Some(observed);
self.applied = RuntimeObservation::Verified;
self.reserved_addresses.clear();
Ok(())
}
pub fn mark_runtime_unavailable(&mut self) {
self.applied = RuntimeObservation::Unavailable;
}
pub fn mark_runtime_unknown(&mut self) {
self.applied = RuntimeObservation::Unknown;
}
/// Addresses in draft, saved, or last verified applied data cannot be reused.
/// The applied snapshot preserves reservations for committed deletions until verification.
pub fn addresses_in_use(&self) -> BTreeSet<IpAddr> {
self.draft
.addresses()
.chain(self.saved.addresses())
.chain(self.reserved_addresses.iter().copied())
.chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses))
.collect()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AddressFamily {
Ipv4,
Ipv6,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum DomainError {
#[error("public key must be canonical standard Base64 encoding of exactly 32 bytes")]
InvalidPublicKey,
#[error("client alias must not be empty")]
EmptyAlias,
#[error("client alias must not contain control characters")]
InvalidAlias,
#[error("snapshot contains a duplicate public key: {0}")]
DuplicatePublicKey(PublicKey),
#[error("address range is exhausted for {0:?}")]
AddressExhausted(AddressFamily),
#[error("address family does not match the configured range")]
AddressFamilyMismatch,
#[error("address is outside the configured allocation range")]
AddressOutsideRange,
#[error("address is unavailable or not usable in the configured range")]
AddressUnavailable,
#[error("verified applied clients do not match the saved snapshot")]
AppliedSnapshotMismatch,
#[error("persisted reservation conflicts with a saved client address")]
ReservationConflictsWithSavedClient,
}
/// Return the lowest available host in `range` without walking the subnet.
pub fn allocate_ipv4(
range: Ipv4Net,
availability: &AddressAvailability<'_>,
) -> Result<Ipv4Addr, DomainError> {
let network = u32::from(range.network());
let broadcast = u32::from(range.broadcast());
let (first, last) = match range.prefix_len() {
0..=30 => (network + 1, broadcast - 1),
31 => (network, broadcast),
32 => (network, network),
_ => return Err(DomainError::AddressExhausted(AddressFamily::Ipv4)),
};
first_gap(
AddressFamily::Ipv4,
u128::from(first),
u128::from(last),
availability
.addresses()
.filter_map(|address| match address {
IpAddr::V4(address) => Some(u128::from(u32::from(address))),
IpAddr::V6(_) => None,
}),
)
.and_then(|address| {
u32::try_from(address)
.map(Ipv4Addr::from)
.map_err(|_| DomainError::AddressExhausted(AddressFamily::Ipv4))
})
}
/// Return the lowest available address in an IPv6 range without walking the subnet.
pub fn allocate_ipv6(
range: Ipv6Net,
availability: &AddressAvailability<'_>,
) -> Result<Ipv6Addr, DomainError> {
let first = u128::from(range.network());
let last = u128::from(range.broadcast());
first_gap(
AddressFamily::Ipv6,
first,
last,
availability
.addresses()
.filter_map(|address| match address {
IpAddr::V4(_) => None,
IpAddr::V6(address) => Some(u128::from(address)),
}),
)
.map(Ipv6Addr::from)
}
/// The categories are separate inputs to allocation and manual-address validation.
#[derive(Clone, Copy, Debug, Default)]
pub struct AddressAvailability<'a> {
pub server: &'a [IpAddr],
pub occupied: &'a [IpAddr],
pub reserved: &'a [IpAddr],
}
impl AddressAvailability<'_> {
fn addresses(self) -> impl Iterator<Item = IpAddr> {
self.server
.iter()
.chain(self.occupied)
.chain(self.reserved)
.copied()
}
}
pub fn validate_manual_ipv4(
address: Ipv4Addr,
range: Ipv4Net,
availability: &AddressAvailability<'_>,
) -> Result<(), DomainError> {
if !range.contains(&address) {
return Err(DomainError::AddressOutsideRange);
}
let value = u32::from(address);
let network = u32::from(range.network());
let broadcast = u32::from(range.broadcast());
let usable = match range.prefix_len() {
0..=30 => value > network && value < broadcast,
31 | 32 => true,
_ => false,
};
if !usable
|| availability
.addresses()
.any(|used| used == IpAddr::V4(address))
{
return Err(DomainError::AddressUnavailable);
}
Ok(())
}
pub fn validate_manual_ipv6(
address: Ipv6Addr,
range: Ipv6Net,
availability: &AddressAvailability<'_>,
) -> Result<(), DomainError> {
if !range.contains(&address) {
return Err(DomainError::AddressOutsideRange);
}
if availability
.addresses()
.any(|used| used == IpAddr::V6(address))
{
return Err(DomainError::AddressUnavailable);
}
Ok(())
}
pub fn validate_manual_address(
address: IpAddr,
ipv4_range: Ipv4Net,
ipv6_range: Ipv6Net,
availability: &AddressAvailability<'_>,
) -> Result<(), DomainError> {
match address {
IpAddr::V4(address) => validate_manual_ipv4(address, ipv4_range, availability),
IpAddr::V6(address) => validate_manual_ipv6(address, ipv6_range, availability),
}
}
fn first_gap(
family: AddressFamily,
first: u128,
last: u128,
occupied: impl Iterator<Item = u128>,
) -> Result<u128, DomainError> {
let mut occupied = occupied
.filter(|address| *address >= first && *address <= last)
.collect::<Vec<_>>();
occupied.sort_unstable();
let mut candidate = first;
for address in occupied {
if address < candidate {
continue;
}
if address > candidate {
return Ok(candidate);
}
if candidate == last {
return Err(DomainError::AddressExhausted(family));
}
candidate = candidate
.checked_add(1)
.ok_or(DomainError::AddressExhausted(family))?;
}
Ok(candidate)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(byte: u8) -> PublicKey {
PublicKey::from_bytes([byte; 32])
}
fn client(byte: u8, alias: &str, ipv4: &str, ipv6: Option<&str>) -> Client {
Client::new(
key(byte),
alias,
ClientAddresses {
ipv4: ipv4.parse().expect("valid fixture IPv4"),
ipv6: ipv6.map(|address| address.parse().expect("valid fixture IPv6")),
},
)
.expect("fixture client should validate")
}
fn empty_availability<'a>() -> AddressAvailability<'a> {
AddressAvailability::default()
}
#[test]
fn public_key_accepts_canonical_wireguard_base64_and_rejects_invalid_values() {
let expected = key(7);
let encoded = expected.to_string();
assert_eq!(encoded.len(), 44);
assert_eq!(encoded.parse::<PublicKey>(), Ok(expected));
for invalid in ["", "not a key", &STANDARD.encode([1_u8; 31]), "AQ=="] {
assert_eq!(
invalid.parse::<PublicKey>(),
Err(DomainError::InvalidPublicKey)
);
}
assert_eq!(
format!("{}\n", expected).parse::<PublicKey>(),
Err(DomainError::InvalidPublicKey)
);
}
#[test]
fn aliases_may_repeat_but_control_characters_are_rejected() {
let snapshot = Snapshot::new([
client(1, "laptop", "10.0.0.2", None),
client(2, "laptop", "10.0.0.3", None),
])
.expect("duplicate aliases are valid");
assert_eq!(snapshot.clients().count(), 2);
for alias in ["bad\nname", "bad\rname", "bad\u{1b}name", "bad\u{7f}name"] {
assert_eq!(
Client::new(
key(3),
alias,
ClientAddresses {
ipv4: "10.0.0.4".parse().expect("valid fixture IPv4"),
ipv6: None,
},
)
.err(),
Some(DomainError::InvalidAlias)
);
}
}
#[test]
fn unknown_and_unavailable_runtime_observations_are_explicit() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let unknown = ClientStates::new(saved.clone(), None, RuntimeObservation::Unknown).unwrap();
assert_eq!(unknown.applied(), &RuntimeObservation::Unknown);
let unavailable = ClientStates::new(saved, None, RuntimeObservation::Unavailable).unwrap();
assert_eq!(unavailable.applied(), &RuntimeObservation::Unavailable);
}
#[test]
fn verified_state_requires_a_matching_applied_snapshot() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap();
assert_eq!(
ClientStates::new(saved.clone(), None, RuntimeObservation::Verified).err(),
Some(DomainError::AppliedSnapshotMismatch)
);
assert_eq!(
ClientStates::new(saved.clone(), Some(other), RuntimeObservation::Verified).err(),
Some(DomainError::AppliedSnapshotMismatch)
);
let verified =
ClientStates::new(saved.clone(), Some(saved), RuntimeObservation::Verified).unwrap();
assert_eq!(verified.applied(), &RuntimeObservation::Verified);
}
#[test]
fn discard_restores_draft_and_commit_keeps_applied_snapshot_separate() {
let saved = Snapshot::new([
client(1, "one", "10.0.0.2", None),
client(2, "two", "10.0.0.3", None),
])
.unwrap();
let mut states = ClientStates::new(
saved.clone(),
Some(saved.clone()),
RuntimeObservation::Verified,
)
.unwrap();
states.draft_mut().remove(&key(2));
states.discard_draft();
assert_eq!(states.draft(), &saved);
states.draft_mut().remove(&key(2));
states.commit_draft();
assert_eq!(states.saved().clients().count(), 1);
assert_eq!(states.applied(), &RuntimeObservation::Unknown);
assert_eq!(states.applied_snapshot(), Some(&saved));
states.mark_runtime_unavailable();
assert_eq!(states.applied(), &RuntimeObservation::Unavailable);
assert!(states.applied_snapshot().is_some());
assert!(
states
.addresses_in_use()
.contains(&IpAddr::V4("10.0.0.3".parse().unwrap()))
);
assert_eq!(
states.reserved_addresses(),
&[IpAddr::V4("10.0.0.3".parse().unwrap())].into()
);
assert!(
states
.addresses_in_use()
.contains(&IpAddr::V4("10.0.0.3".parse().unwrap()))
);
}
#[test]
fn a_draft_only_deletion_does_not_create_a_committed_reservation() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let mut states = ClientStates::new(saved, None, RuntimeObservation::Unknown).unwrap();
states.draft_mut().remove(&key(1));
assert!(states.reserved_addresses().is_empty());
assert!(
states
.addresses_in_use()
.contains(&IpAddr::V4("10.0.0.2".parse().unwrap()))
);
}
#[test]
fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() {
let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let mut states = ClientStates::new(
initial.clone(),
Some(initial.clone()),
RuntimeObservation::Verified,
)
.unwrap();
let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap();
assert_eq!(
states.mark_applied_after_verification(wrong),
Err(DomainError::AppliedSnapshotMismatch)
);
assert_eq!(states.applied(), &RuntimeObservation::Unknown);
assert_eq!(states.applied_snapshot(), Some(&initial));
states.draft_mut().remove(&key(1));
states.commit_draft();
assert!(!states.reserved_addresses().is_empty());
let saved = states.saved().clone();
states
.mark_applied_after_verification(saved.clone())
.unwrap();
assert_eq!(states.applied(), &RuntimeObservation::Verified);
assert!(states.reserved_addresses().is_empty());
assert!(
!states
.addresses_in_use()
.contains(&IpAddr::V4("10.0.0.2".parse().unwrap()))
);
}
#[test]
fn existing_ipv4_only_clients_remain_ipv4_only() {
let ipv4_only = client(1, "legacy", "10.0.0.2", None);
assert_eq!(ipv4_only.addresses.ipv6, None);
assert_eq!(ipv4_only.addresses.iter().count(), 1);
}
#[test]
fn ipv4_allocation_skips_server_occupied_reserved_network_and_broadcast() {
let server = [IpAddr::V4("10.0.0.1".parse().unwrap())];
let occupied = [IpAddr::V4("10.0.0.2".parse().unwrap())];
let reserved = [IpAddr::V4("10.0.0.3".parse().unwrap())];
let availability = AddressAvailability {
server: &server,
occupied: &occupied,
reserved: &reserved,
};
assert_eq!(
allocate_ipv4("10.0.0.0/29".parse().unwrap(), &availability),
Ok("10.0.0.4".parse().unwrap())
);
}
#[test]
fn ipv4_tiny_ranges_and_exhaustion_follow_rfc3021_and_host_rules() {
let none = empty_availability();
assert_eq!(
allocate_ipv4("192.0.2.0/31".parse().unwrap(), &none),
Ok("192.0.2.0".parse().unwrap())
);
assert_eq!(
allocate_ipv4("192.0.2.8/32".parse().unwrap(), &none),
Ok("192.0.2.8".parse().unwrap())
);
let occupied = [IpAddr::V4("192.0.2.8".parse().unwrap())];
let availability = AddressAvailability {
occupied: &occupied,
..none
};
assert_eq!(
allocate_ipv4("192.0.2.8/31".parse().unwrap(), &availability),
Ok("192.0.2.9".parse().unwrap())
);
let both = [
IpAddr::V4("192.0.2.8".parse().unwrap()),
IpAddr::V4("192.0.2.9".parse().unwrap()),
];
assert_eq!(
allocate_ipv4(
"192.0.2.8/31".parse().unwrap(),
&AddressAvailability {
occupied: &both,
..none
}
),
Err(DomainError::AddressExhausted(AddressFamily::Ipv4))
);
assert_eq!(
allocate_ipv4(
"192.0.2.8/32".parse().unwrap(),
&AddressAvailability {
occupied: &occupied,
..none
}
),
Err(DomainError::AddressExhausted(AddressFamily::Ipv4))
);
}
#[test]
fn ipv6_allocates_first_free_address_from_a_very_large_range() {
// /32 has almost 2^96 addresses; work here depends on the small occupied set.
let occupied = (0..20_000_u32)
.map(|offset| {
IpAddr::V6(Ipv6Addr::from(
0x2001_0db8_0000_0000_0000_0000_0000_0000_u128 + u128::from(offset),
))
})
.collect::<Vec<_>>();
let availability = AddressAvailability {
occupied: &occupied,
..AddressAvailability::default()
};
assert_eq!(
allocate_ipv6("2001:db8::/32".parse().unwrap(), &availability),
Ok(Ipv6Addr::from(
0x2001_0db8_0000_0000_0000_0000_0000_4e20_u128
))
);
}
#[test]
fn ipv6_narrow_ranges_manual_selection_and_family_validation_work() {
let none = empty_availability();
assert_eq!(
allocate_ipv6("2001:db8::/127".parse().unwrap(), &none),
Ok("2001:db8::".parse().unwrap())
);
let server = [IpAddr::V6("2001:db8::".parse().unwrap())];
let availability = AddressAvailability {
server: &server,
..none
};
assert_eq!(
allocate_ipv6("2001:db8::/128".parse().unwrap(), &availability),
Err(DomainError::AddressExhausted(AddressFamily::Ipv6))
);
assert_eq!(
validate_manual_ipv6(
"2001:db8::2".parse().unwrap(),
"2001:db8::/126".parse().unwrap(),
&none
),
Ok(())
);
assert_eq!(
validate_manual_ipv6(
"2001:db8::".parse().unwrap(),
"2001:db8::/126".parse().unwrap(),
&availability
),
Err(DomainError::AddressUnavailable)
);
assert_eq!(
validate_manual_address(
IpAddr::V6("2001:db8::3".parse().unwrap()),
"10.0.0.0/24".parse().unwrap(),
"2001:db8::/126".parse().unwrap(),
&none,
),
Ok(())
);
}
#[test]
fn manual_ipv4_requires_range_membership_usability_and_availability() {
let occupied = [IpAddr::V4("10.0.0.3".parse().unwrap())];
let availability = AddressAvailability {
occupied: &occupied,
..AddressAvailability::default()
};
let range = "10.0.0.0/29".parse().unwrap();
assert_eq!(
validate_manual_ipv4("10.0.0.3".parse().unwrap(), range, &availability),
Err(DomainError::AddressUnavailable)
);
assert_eq!(
validate_manual_ipv4("10.0.0.0".parse().unwrap(), range, &availability),
Err(DomainError::AddressUnavailable)
);
assert_eq!(
validate_manual_ipv4("10.0.1.1".parse().unwrap(), range, &availability),
Err(DomainError::AddressOutsideRange)
);
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod app;
pub mod backup;
pub mod config;
pub mod domain;
pub mod persistence;
pub mod startup;
pub mod vault;
pub mod wireguard;
+34
View File
@@ -0,0 +1,34 @@
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\
The management interface is not implemented yet.";
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::ManagementUnavailable) => {
eprintln!("The management interface is not implemented yet.");
ExitCode::from(1)
}
Err(error) => {
eprintln!("Error: {error}.\n{USAGE}");
ExitCode::from(2)
}
}
}
+1299
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
use std::ffi::OsString;
use std::fmt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StartupAction {
Help,
Version,
ManagementUnavailable,
}
#[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 {}
/// 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::ManagementUnavailable)
} 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_reports_unimplemented_without_side_effects() {
let result = start(&[], || true);
assert_eq!(result, Ok(StartupAction::ManagementUnavailable));
}
#[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));
}
}
}
+730
View File
@@ -0,0 +1,730 @@
//! 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>,
pub client_addresses: Vec<String>,
pub interface_dns: Option<String>,
pub interface_mtu: Option<String>,
pub server_public_key: Option<String>,
pub endpoint: Option<String>,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<String>,
preshared_key: Option<String>,
}
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 into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
}
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();
}
}
}
/// 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())
}
/// 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 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),
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_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)
);
}
}
+815
View File
@@ -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)
);
}
}
+522
View File
@@ -0,0 +1,522 @@
//! Bounded, read-only access to configured client source files.
use std::ffi::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::{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,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SourceImportError {
#[error("vault setup failed")]
Vault(#[from] VaultError),
#[error("source directory is invalid")]
InvalidDirectory,
}
/// 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();
}
#[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,
}]
);
}
}
+16
View File
@@ -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};
+630
View File
@@ -0,0 +1,630 @@
//! 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>,
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;
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
allowed_ips_fields.push(directive);
for (item, result) in items {
match result {
Ok(net) => {
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,
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
}
/// 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 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());
}
}
+534
View File
@@ -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)
);
}
}
+501
View File
@@ -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());
}
}
+524
View File
@@ -0,0 +1,524 @@
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::{
Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, 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),
RuntimeObservation::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(), &RuntimeObservation::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(), &RuntimeObservation::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(), &RuntimeObservation::Unknown);
}
+395
View File
@@ -0,0 +1,395 @@
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 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"
);
}
+22
View File
@@ -0,0 +1,22 @@
# Synthetic server configuration for byte-preservation tests.
[Interface]
PrivateKey = SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY
Address = 10.44.0.1/24, fd42::1/64
ListenPort = 51820
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
UnknownInterfaceDirective = keep exactly = this
# Admin note: retained between interface and peer.
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AllowedIPs = 10.44.0.2/32
PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A
PersistentKeepalive = 25
CustomPeerOption = keep-me
# Another administrator note.
[Peer]
PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=
AllowedIPs = 10.44.0.3/32, fd42::3/128
PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B
CustomPeerOption = also-keep-me
+242
View File
@@ -0,0 +1,242 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::PublicKey;
use wireguard_admin::vault::import::{CandidateStatus, parse_client_config};
use wireguard_admin::vault::source::{
MAX_CANDIDATE_BYTES, SourceFailureKind, import_configured_directory, prepare_initial_setup,
};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
use wireguard_admin::wireguard::ConfigDocument;
fn settings(source: &Path) -> ResolvedSettings {
let raw = format!(
r#"
interface_name = "wg0"
server_config_path = "server.conf"
client_source_directory = {:?}
server_endpoint = "vpn.example:51820"
ipv4_allocation_range = "10.8.0.0/24"
ipv6_allocation_range = "fd00::/64"
dns_addresses = ["10.8.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
"#,
source.display().to_string()
);
SettingsFile::parse_toml(&raw)
.unwrap()
.validate()
.unwrap()
.resolve_paths(Path::new("/synthetic/app"))
}
fn make_private(path: &Path) {
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
fn server_and_client() -> (ConfigDocument, WireGuardPrivateKey, PublicKey) {
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let client_public = client_key.public_key();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n# admin note\n",
server_key.to_base64(),
client_public
)
.as_bytes(),
)
.unwrap();
(server, client_key, client_public)
}
fn client_file(client: &WireGuardPrivateKey, server: &ConfigDocument) -> Vec<u8> {
let server_key = server
.source_slice(server.sections()[0].span)
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("PrivateKey = "))
.map(|value| {
WireGuardPrivateKey::parse_base64(value)
.unwrap()
.public_key()
})
.unwrap();
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n",
client.to_base64(), server_key
)
.into_bytes()
}
#[test]
fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged() {
let directory = tempfile::tempdir().unwrap();
let (server, client, identity) = server_and_client();
let original_server = server.render();
let source_bytes = client_file(&client, &server);
let laptop = directory.path().join("laptop.conf");
fs::write(&laptop, &source_bytes).unwrap();
make_private(&laptop);
fs::write(
directory.path().join("broken.conf"),
b"not a WireGuard file",
)
.unwrap();
let ignored = directory.path().join("ignored.txt");
fs::write(&ignored, &source_bytes).unwrap();
make_private(&ignored);
fs::create_dir(directory.path().join("nested")).unwrap();
let nested = directory.path().join("nested/server.conf");
fs::write(&nested, &source_bytes).unwrap();
make_private(&nested);
let prepared =
prepare_initial_setup(&settings(directory.path()), &server, "setup password").unwrap();
assert!(prepared.report.scan_complete);
assert_eq!(prepared.bundle.record_count(), 1);
let statuses = prepared.report.reconciliation.candidate_summaries();
assert!(statuses.iter().any(|(_, status)| matches!(
status,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: wireguard_admin::vault::import::ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
)));
assert!(
statuses
.iter()
.any(|(_, status)| matches!(status, CandidateStatus::Invalid(_)))
);
let encoded = prepared.bundle.to_toml().unwrap();
assert!(!encoded.contains(&client.to_base64()));
let loaded = VaultBundle::from_toml(&encoded).unwrap();
assert_eq!(loaded.record_count(), 1);
assert!(loaded.contains_record(identity));
assert_eq!(
loaded
.retrieve_record(identity, "setup password")
.unwrap()
.public_key(),
identity
);
assert!(loaded.retrieve_record(identity, "wrong password").is_err());
assert!(fs::read(directory.path().join("laptop.conf")).unwrap() == source_bytes);
assert_eq!(
fs::read(directory.path().join("broken.conf")).unwrap(),
b"not a WireGuard file"
);
assert!(server.render() == original_server);
}
#[test]
fn symlinks_and_oversized_candidates_are_reported_without_import() {
let directory = tempfile::tempdir().unwrap();
let external = tempfile::NamedTempFile::new().unwrap();
fs::write(external.path(), b"outside source root").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(external.path(), directory.path().join("linked.conf")).unwrap();
fs::write(
directory.path().join("large.conf"),
vec![b'x'; MAX_CANDIDATE_BYTES + 1],
)
.unwrap();
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.record_count(), 0);
assert!(!report.scan_complete);
assert!(report.issues.iter().any(|issue| {
issue.kind == SourceFailureKind::FileTooLarge
&& issue.file_name.as_deref() == Some("large.conf")
}));
#[cfg(unix)]
assert!(report.issues.iter().any(|issue| {
issue.kind == SourceFailureKind::FileUnavailable
&& issue.file_name.as_deref() == Some("linked.conf")
}));
}
#[test]
fn enumeration_failure_is_visible_and_does_not_claim_a_complete_scan() {
let absent = tempfile::tempdir().unwrap().path().join("missing");
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_configured_directory(&mut bundle, &settings(&absent), &server).unwrap();
assert!(!report.scan_complete);
assert_eq!(
report.issues[0].kind,
SourceFailureKind::DirectoryUnavailable
);
assert_eq!(bundle.record_count(), 0);
}
#[test]
fn candidate_count_limit_marks_the_scan_incomplete() {
let directory = tempfile::tempdir().unwrap();
for index in 0..=wireguard_admin::vault::source::MAX_CANDIDATES {
fs::write(
directory.path().join(format!("candidate-{index}.conf")),
b"bad",
)
.unwrap();
}
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert!(!report.scan_complete);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::TooManyEntries)
);
assert_eq!(bundle.record_count(), 0);
}
#[test]
fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacement() {
let directory = tempfile::tempdir().unwrap();
let (server, client, identity) = server_and_client();
let initial_source = tempfile::tempdir().unwrap();
let initial =
prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap();
let original_recipient = initial.bundle.recipient().to_owned();
let mut bundle = initial.bundle;
let client_path = directory.path().join("client.conf");
fs::write(&client_path, client_file(&client, &server)).unwrap();
make_private(&client_path);
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.recipient(), original_recipient);
assert_eq!(bundle.record_count(), 1);
assert!(
report
.reconciliation
.candidate_summaries()
.iter()
.any(|(_, status)| matches!(status, CandidateStatus::Matched { .. }))
);
let before_repeat = bundle.to_toml().unwrap();
let repeated =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.to_toml().unwrap(), before_repeat);
assert_eq!(bundle.record_count(), 1);
assert!(
repeated
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::DuplicateRecordEqualityUnverified)
);
let result = bundle.retrieve_record(identity, "password").unwrap();
assert_eq!(result.public_key(), identity);
assert!(parse_client_config(&client_file(&client, &server)).is_ok());
}