Implement backups, commit and restoration

This commit is contained in:
2026-10-20 00:13:39 +03:00
parent c879a8101b
commit 5418841b92
17 changed files with 5049 additions and 38 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation.
The project contains a Rust package with startup, settings validation, a pure client domain model, an in-memory, byte-preserving WireGuard configuration editor, and library APIs for an encrypted vault and read-only client import. The management interface, installed persistence, and VPN operations are not implemented yet. Do not report planned modules, tests, or commands as already working.
The project contains a Rust package with startup, settings validation, a pure client domain model, a byte-preserving WireGuard configuration editor, encrypted vault and read-only import APIs, saved-file persistence with recovery, backups, Commit, 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.
Generated
+80
View File
@@ -305,6 +305,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -395,6 +404,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-crate"
version = "0.6.3"
@@ -770,6 +789,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "keccak"
version = "0.1.6"
@@ -865,6 +890,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -969,6 +1000,12 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1327,6 +1364,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "tempfile"
version = "3.23.0"
@@ -1380,6 +1427,37 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "time"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -1566,8 +1644,10 @@ dependencies = [
"ipnet",
"rustix",
"serde",
"tar",
"tempfile",
"thiserror 2.0.20",
"time",
"toml 1.1.8+spec-1.1.0",
"x25519-dalek",
"zeroize",
+3 -3
View File
@@ -21,9 +21,9 @@ 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"
[dev-dependencies]
tempfile = "=3.23.0"
+377 -16
View File
@@ -108,17 +108,17 @@ Actual systemd/D-Bus acceptance runs in a disposable VM. Never use production to
Each row is a delivery milestone, not a completion claim. Expand its record before execution; retain its original ID and acceptance scope.
| ID | Milestone | Prerequisites | State |
| --- | -------------------------------------------------------- | ----------------------------------------- | ------------- |
| 1 | Rust foundation, settings, and domain states | None | accepted |
| 2 | Server configuration editing with byte preservation | 1 | needs_changes |
| 3 | Client keys, encrypted vault, and existing-client import | 2 | pending |
| 4 | Backups, safe Commit, and full restoration | 3 | pending |
| 5 | Runtime observation and systemd restart adapter | 4 | pending |
| 6 | Client configuration delivery and reissue lifecycle | 5 | pending |
| 7 | Complete terminal management workflow | 6 | pending |
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | pending |
| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | pending |
| ID | Milestone | Prerequisites | State |
| --- | -------------------------------------------------------- | ----------------------------------------- | -------- |
| 1 | Rust foundation, settings, and domain states | None | accepted |
| 2 | Server configuration editing with byte preservation | 1 | accepted |
| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted |
| 4 | Backups, safe Commit, and full restoration | 3 | accepted |
| 5 | Runtime observation and systemd restart adapter | 4 | pending |
| 6 | Client configuration delivery and reissue lifecycle | 5 | pending |
| 7 | Complete terminal management workflow | 6 | pending |
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | pending |
| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | pending |
### Milestone 1 — Rust foundation, settings, and domain states
@@ -1025,9 +1025,369 @@ occurred.
### Milestone 4 — Backups, safe Commit, and full restoration
Implement versioned tar archives/manifests, mandatory pre-operation backups, internal state persistence, Commit orchestration, and previewed full restoration without decryption. Define partial-write handling and archive validation. Persist address reservations and verified-state bookkeeping as needed.
Status: accepted (2026-10-19T21:10:25Z; independent R5 closed all findings and final coordinator gates passed).
Prerequisites: milestones 1–3 accepted; pinned native Rust environment and
available approved dependencies. This milestone uses synthetic temporary
filesystems and injected failures, not root, production files, or running services.
Acceptance: backup failure prevents protected mutations; Commit/restore never change the running VPN; archive composition matches the specification; malformed/traversal/link entries are rejected; restore returns saved settings/configuration/vault without a password. Tests demonstrate draft cancellation and deletion of current secrets while older archives remain unchanged.
#### Outcome and scope
Deliver library/application operations that install prepared M3 vault data,
save an M2-edited server document through Commit, preserve internal allocation
state, create/list backups, and preview/confirm full restoration without a vault
password. Writes must be honest about partial failure and recoverable from the
mandatory pre-operation backup. Neither Commit nor restore applies VPN changes.
Read SPEC.md Data locations, Data sources, Operations and states, Encrypted vault,
and Backups and restoration; all three ADRs; VAULT_FORMAT.md, CONFIG_FORMAT.md,
DEVELOPMENT.md, DEPENDENCIES.md, ENVIRONMENT.md, and TESTING.md. Distinguish the
application's Commit action from Git commits, which are not authorized here.
Out of scope: TUI, output files/QR, password prompts, reissue reconciliation,
real WireGuard/systemd observation or restart, automatic failed-restart rollback,
concurrent editor detection/locking, compression, retention cleanup, migration to
different server paths, and production access. The startup management placeholder
remains safe until later milestones; expose testable APIs rather than a new CLI.
#### Existing code and integration constraints
- `config::ResolvedSettings` provides executable-relative server/client/data paths
and data/archive directories; settings live at `wireguard-admin.toml` beside
the executable. Use explicit resolved paths in adapters, never process cwd.
- M2 `ConfigDocument` and edits preserve original bytes and report unsupported
peers. Commit persists the prepared document without reserializing server
settings, silently projecting away anomalies, or rewriting unchanged comments.
- M3 `VaultBundle::to_toml`/`from_toml` encode/load encrypted v1 bundles without
password entry. VAULT_FORMAT.md fixes the intended path as data/vault.toml.
Preserve recipient, encrypted identity, records, and authenticated blocked flags;
backups/restore do not decrypt or rebuild them from plaintext imports.
- `PreparedImportSetup` contains bundle/report; read-only scans never installed
application state. M4 owns initial installation through the same persistence
and pre-Commit backup boundary as later prepared imports.
- Vault records are private and there is currently no public removal operation.
Add a narrow public-key removal API as needed; do not serialize/reparse internals
to bypass encapsulation or expose plaintext. Do not automatically purge file-only
imported records merely because they are absent from the server configuration.
- M1 `ClientStates::commit_draft` updates the in-memory saved snapshot and address
reservations only. Invoke/reflect that transition after successful disk commit,
never before it. Current reservation state is private and has no persistence
codec; add explicit validated state encode/load support, not generic secret or
whole-document serialization. Preserve unsupported M2 peers outside narrow Client.
- Historical verified/applied snapshots are not fresh runtime observations. On
reopening/restoring, preserve needed reservation/history data but mark current
runtime unknown until M5 verifies it. Restore never frees a pending deleted IP.
- Cargo already includes M1–M3 libraries and tempfile for tests. Approved `tar`
and `time` may be introduced with minimal features, and tempfile/rustix used
for secure writes. Moving tempfile to normal dependencies is allowed if needed.
No new hash/UUID/compression/database/framework dependency is implicitly approved.
#### Context and implementation steps
Use sequential tasks and one writer. GPT-6-Sol/medium coordinates; GPT-6-Luna/high
implements, reviews independently, and repairs. Supply explicit ownership, scope,
accepted interfaces, validation, and task report requirements on each dispatch.
| Task | Owner | Files and deliverable |
| --- | --- | --- |
| M4.1 | implementer | `src/backup.rs` and child modules as needed, `docs/BACKUP_FORMAT.md`: versioned allowlisted archive, limits, inventory, validated preview |
| M4.2 | implementer | narrow persistence module, `src/config.rs`/`src/domain.rs` adapters as needed, `docs/PERSISTENCE.md`: safe path handling, validated state, staged writes and recovery contract |
| M4.3 | implementer | `src/app.rs`, `src/vault.rs` narrow deletion API, library exports: prepared change sets, initial setup/Commit and mandatory backup ordering |
| M4.4 | implementer | application/backup/persistence modules: manual and pre-restart backup APIs, previewed confirmed full restore, restart-history boundary |
| M4.5 | implementer | `tests/commit_restore.rs`, test-only failure adapters and README: round trips, fault injection, archive attacks, permission and reservation scenarios |
| M4.6 | reviewer, repairer | Independent integrated review, bounded repairs, re-review of failure paths and regressions |
| M4.7 | coordinator | Integrated gates, evidence/findings ledger and acceptance/handoff in PLANS.md |
M4.1 defines a versioned uncompressed tar archive with a bounded TOML manifest.
Use logical fixed member names for settings, server configuration, internal state,
and encrypted vault; never restore directly to tar-supplied paths. Record UTC
creation time, operation reason (manual/pre-Commit/pre-restore/pre-restart), member
inventory/lengths, and historical verification status without inventing working
state. Define collision-safe archive naming without overwriting an existing file.
A new archive is visible in the list only when fully written and finalized.
Capture the currently installed saved bytes, not the proposed draft or decrypted
records. Settings and server config may contain secrets: archives require 0600.
Exclude source client files, drafts, previous archives, staging/journal artifacts,
and delivered plaintext output. Initial setup can lack vault/internal state:
represent genuine absence explicitly, not as corruption or an empty fake vault.
Restoring that backup must restore absence for those owned components. Missing
required settings/server config or read errors are not optional absence.
Bound archive/member size and entry count before reading/extracting everything.
Reject unsupported manifest versions, missing/duplicate/unexpected members,
absolute or traversal paths, links, device/special entries, inconsistent sizes,
truncation, and unsafe extension/header interpretations. Parse allowed files in
staging before mutation using existing settings/document/vault validators. Do not
reject a losslessly supported anomalous peer simply because project_client fails.
The tar checksum is not authentication; do not advertise tamper-proof backups or
silently add a signing/hash dependency. Bounds/structural validation remain required.
M4.2 documents file layout and a versioned internal-state schema for reservations,
historical apply evidence, and operation recovery if needed. Keep aliases and
client configuration authoritative in the server file. Reject unknown or malformed
state rather than silently losing reservations. A file loaded from disk must not
self-assert current RuntimeObservation::Verified without runtime verification.
Separate orchestration from a narrow filesystem adapter for deterministic errors.
Use safe no-follow/anchored path operations, regular-file checks and private
staging. Detect target aliasing/overlap (for example vault and server resolving to
the same file) before mutations. Do not traverse a symlink or use archive metadata
to chmod/chown arbitrary targets. Create files/directories with 0600/0700 from
creation; preserve protected directory access and handle existing permissions
explicitly. Do not recursively delete the data directory to implement restore.
Use same-filesystem temporary files, complete writes, flush/sync, atomic rename,
and directory sync where applicable. A rename is not a transaction across server,
settings, and data directories. Document ordering, durable recovery markers or an
explicit equivalent, and recovery from interruption between replacements before
implementing it. Never claim multi-file atomicity just because each rename is
atomic. Backup-before-mutation and write recovery are not optimistic locking.
On replacement failure, either leave the old set intact or return an explicit
incomplete/recovery-required result with the preserved pre-operation backup.
Do not publish success, advance saved memory, or accept another mutation over
unresolved partial state. Recovery must be deterministic and tested after reopening
from partial files. It may finish/restore a local write transaction as documented;
it must never apply runtime changes or become automatic failed-restart rollback.
Report cleanup failures honestly and keep recoverable evidence if cleanup is unsafe.
M4.3 builds a prepared change set containing edited server bytes, staged encrypted
vault, and validated internal state. Preview/validate it before installation.
Complete the pre-Commit backup before the first protected destination replacement.
Failure to create/finalize the backup stops Commit and preserves installed bytes
and saved in-memory state. Then stage/install the set and only after success
advance saved snapshots and expose successful Commit. A failed Commit retains
retryable draft intent without treating possibly partial disk state as consistent.
Deletion removes the explicitly selected client record and vault secret in the
same committed operation; unknown/missing vault records need clear behavior, since
server-only clients are legitimate. Unrelated imported/file-only records remain.
Earlier archives remain unchanged. Prepared imports/setup never overwrite an
existing vault identity silently and never clear authenticated reissue blocking.
Discarding an uncommitted preparation must not change installed files. Persistent
settings updates are not a new product feature; Commit handles agreed client and
internal changes while settings are included in backups/restoration.
M4.4 exposes listing and manual backup plus a reusable pre-restart backup operation
for M5, without restarting anything here. Sorting/time labels do not imply a backup
is working. Accept historical verification only from an explicit verified-apply
boundary intended for M5; represent no verified state honestly. Any identity/token
used to associate a saved generation with verification must not be a fresh runtime
claim or a new crypto dependency. Never mark pre-restart bytes known-working based
on the backup reason alone.
Restore has a read-only preview followed by explicit confirmation of that prepared
archive. Validate the chosen content and determine affected files before writes;
then complete the precautionary backup, stage all replacement/deletion actions,
and install. Restore settings, whole server file, internal state, and opaque vault
as a complete saved set, not a client merge. Whole-file restoration of the archived
server is intentional; ordinary Commit still obeys M2 client-only editing rules.
Use current trusted destination mapping and enforce the installation's invariant
paths. Archived settings cannot redirect writes outside that mapping or switch data
roots during restore; report incompatible paths instead of adding migration logic.
Keep the selected validated archive stable between preview and confirmation so
confirmation cannot apply different bytes. Do not silently discard the active draft
until confirmed; cancellation/validation/backup failure preserves it. Successful
restore discards it and reloads the restored saved state, retaining conservative
pending reservations across the still-unchanged runtime boundary. Historical
restored state cannot assert the current interface matches it. Caller never needs
or supplies a password for archive listing, backup, preview or restore.
M4.5 injects failures at backup creation/write/finalization, staging writes, sync,
each destination replacement, recovery and cleanup. Verify exact file sets and
memory transitions rather than only error messages. Avoid permission tests that
pass accidentally as root; run unprivileged and inject deterministic failures.
Use temporary private directories and generated encrypted bundles. Do not log
server fixture contents or decrypted data in failing assertions.
#### Acceptance and validation
- [x] M4-A1: documented versioned archive/state layouts round-trip settings,
server bytes, encrypted vault and internal state without any password/decryption;
initial missing vault/state is represented and restored explicitly.
- [x] M4-A2: complete backups precede Commit/restore and the future restart boundary;
backup/read/finalization failure prevents protected replacements and successful
memory transitions. Manual backup/listing works without mutations to saved data.
- [x] M4-A3: successful Commit writes the prepared client changes, vault and state;
untouched server spans remain identical; saved memory advances only on success,
runtime stays unchanged/unknown, and draft discard does not write files.
- [x] M4-A4: explicit client deletion removes only its current vault record while
server-only and file-only anomalies remain supported; old archives are unchanged.
- [x] M4-A5: confirmed restore fully replaces owned saved components, including
removal of components absent in the archive, without merging or reading plaintext
client sources. Preview/cancel does not mutate or silently discard the draft.
- [x] M4-A6: original client source files, unrelated files and all archives outside
the selected/pre-operation artifacts are protected from traversal, symlink/link
escapes, target aliasing and unsafe tar entries; test bounded parsing and rejection.
- [x] M4-A7: 0600 secret-bearing files/archives and 0700 private directories apply
from creation, including staging; errors and Debug redact secret-bearing bytes.
- [x] M4-A8: injected failures/interruption between multi-file replacements produce
no false success or premature saved-memory change; reopening detects incomplete
work and follows the documented deterministic recovery path before new mutations.
- [x] M4-A9: saved/deleted address reservations survive reopen and restore across
unapplied changes; neither action releases them or trusts historical verification
as current runtime. Missing/malformed state cannot silently permit address reuse.
- [x] M4-A10: listing records UTC reason and explicit historical apply status;
pre-Commit/pre-restart archives and newest archives are not automatically working.
- [x] M4-A11: incompatible archived destination settings are rejected; restored
server/config/vault stay at trusted invariant paths, and no whole-directory wipe
or recursive archive inclusion occurs.
- [x] M4-A12: M1–M3 regressions and integrated gates pass; independent review closes
actionable persistence, archive, backup-order and failure-recovery findings.
Planned commands from the repository root after M4 targets and lockfile exist:
```sh
cargo test --locked backup
cargo test --locked persistence
cargo test --locked --test commit_restore
cargo test --locked --test vault_import
cargo test --locked --test config_preservation
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --locked --release
./target/release/wireguard-admin --help
./target/release/wireguard-admin --version
git diff --check
```
Filters must execute actual focused tests. All commands should exit zero;
negative cases are assertions within passing tests. Resolve approved added crates
and update Cargo.lock before locked checks. Record actual counts, commands and
failure-injection coverage. Tests are unprivileged filesystem/library checks only;
no container/VM or live restart acceptance is required or claimed in this milestone.
#### Progress
- [x] Description prepared from accepted M1–M3 interfaces and current documents.
- [x] M4.1 — `/root/implementer_m4_1` added `src/backup.rs`,
`docs/BACKUP_FORMAT.md`, approved `tar`/`time` dependencies, and six focused
archive tests (2026-10-19T20:23:23Z). Coordinator inspected the actual code;
final integrated review and gates subsequently passed.
- [x] M4.2 — `/root/implementer_m4_2` added `src/persistence.rs`,
`docs/PERSISTENCE.md`, and validated reservation restoration in `src/domain.rs`
(2026-10-19T20:30:34Z). Integrated review subsequently passed.
- [x] M4.3 — `/root/implementer_m4_3` added `src/app.rs`, a narrow vault
record-removal API, and one trusted archive-directory accessor (2026-10-19T20:35:58Z).
Commit backup/install ordering and draft tests passed integrated review.
- [x] M4.4 — `/root/implementer_m4_4` added list/manual/pre-restart backup
boundaries and byte-bound restore preview/confirmation, with path-invariant
validation and focused tests (2026-10-19T20:42:20Z); integrated review passed.
- [x] M4.5 — `/root/implementer_m4_5` added nine cross-module tests and fixed
restore reservations plus interface-name invariant (2026-10-19T20:49:14Z).
Documentation was synchronized; independent review passed.
- [x] M4.6 — fresh `/root/reviewer_m4_r1` reviewed the integrated result;
separate `/root/repairer_m4_r1` repaired R1 and R4 findings; R2/R3/R5
re-review closed all with no new actionable findings (2026-10-19T21:10:25Z).
- [x] M4.7 — coordinator ran all planned integrated gates and accepted M4
(2026-10-19T21:10:25Z).
#### Review and repair
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
| --- | --- | --- | --- | --- | --- |
| M4-R1-01 | R1/R2 | `src/persistence.rs` saved reads accepted existing secret-bearing files with broad permissions. | `/root/repairer_m4_r1` | R2 confirmed fail-closed server/vault mode checks and regression tests. | closed |
| M4-R1-02 | R1/R2/R3 | Archive and installation failure paths lacked deterministic evidence. | `/root/repairer_m4_r1` | R3 confirmed partial restore interruption after server replacement, unchanged session saved/draft/applied state, failed rollback and fresh reopen recovery, and intact pre-restore archive; focused test passed. | closed |
| M4-R4-01 | R4/R5 | `src/app.rs` lost verified saved-generation history on reopen and mislabeled pre-Commit backups. | `/root/repairer_m4_r1` | R5 confirmed durable history after mandatory backup, truthful archive labeling, reopen history with runtime Unknown, and failure/clear tests. | closed |
R1 independently inspected the integrated code; `cargo test --locked --test
commit_restore` passed 9 tests and `git diff --check` was clean. Reviewer did not
claim Alpine, VM, live WireGuard, or host-service evidence. R2/R3/R5 completed
the required re-reviews and closed all findings.
#### Evidence and handoff
Planning inspected Cargo.toml, M3 vault serialization and source/import APIs,
VAULT_FORMAT.md, M1 state methods, M3 recorded acceptance and governing documents.
M3 records independent R7 review, 64 unit tests plus 9 preservation and 5 vault
import integration tests, and passing coordinator gates. These are historical
results; this planning task did not rerun tests or modify application code,
dependencies, files in configured runtime directories, or installed vault state.
M4.1 worker reported `cargo test --locked --offline` (70 unit, 9 preservation,
5 vault-import), `cargo test --locked --offline backup` (6), Clippy, format,
and `git diff --check` passing. These are worker results, not final integrated
acceptance. The archive API accepts explicit saved bytes and validates a bounded
fixed-member UStar format without extraction; app-level semantic validation and
trusted path handling remain for M4.2–M4.4. M4.2 worker reported 10 persistence
tests, full suite (80 unit + 9 preservation + 5 vault-import), Clippy, release
build, format and diff checks passing. It supplies `SavedStore` with validated
read/install/recover, a private journal, and explicit recovery-required results;
per-syscall fault injection remains for M4.5. These are worker results only.
M4.3 worker reported formatting, Clippy, full tests (89 unit + 14 integration),
release build, and diff checks passing. Its `Application` stages public-key edits,
pre-Commit backups exact installed bytes, and advances saved memory only after
install. Broad-route legacy deletion fails closed because the persisted reservation
format holds single addresses; exact-host legacy routes are retained. This is
worker evidence, not integrated acceptance. M4.4 worker
reported 95 unit + 14 integration tests, formatting, Clippy, offline release
build and diff checks passing. Coordinator inspection identified additional
reservation/path failure cases for M4.5 tests and independent review. M4.5
worker reported 95 unit + 23 integration tests, focused backup (11), persistence
(10), and commit_restore (9), plus format, Clippy, release build, help/version,
and diff checks passing. Its tests reproduced and fixed restore dropping current
saved-client reservations and accepting an archived interface-name switch.
Syscall-level write/fsync fault injection is not yet exhaustive; this remains
an explicit review concern. These are worker results, not final acceptance.
Next action: fresh independent M4 reviewer, then repair/re-review as needed.
R1 returned M4-R1-01 and M4-R1-02 as actionable. Next action: a separate
repairer fixes those findings, then the reviewer rechecks. Repairer reported
both fixed, with 102 unit + 23 integration tests and formatting, Clippy,
release build and diff checks passing (2026-10-19T20:57:23Z). The coordinator
has not yet accepted those claims; next action is independent R2 re-review.
R2 (2026-10-19T20:58:52Z) closed M4-R1-01 and partially closed M4-R1-02.
Focused persistence (15), backup (12 unit + 2 integration), commit_restore (9),
and injected Commit test (1) passed. Reviewer requires a restore partial-install
failure/recovery test at the application boundary. Next action: targeted repair
and R3 re-review; milestone remains in progress.
Targeted repair added a private restore-install test seam and regression. R3
(2026-10-19T21:01:08Z) closed M4-R1-02, found no new actionable defects,
and passed the focused test plus `git diff --check`. Next action: coordinator
integrated gates and A1–A12 decision.
Coordinator gates passed on the R3 revision, but a targeted historical-status
audit found M4-R4-01 (2026-10-19T21:03:13Z). Those gate results will be rerun
after repair. The milestone remains in review; next action is targeted repair
and independent re-review.
Final integrated evidence after R5 repair (coordinator, 2026-10-19T21:10:25Z):
`cargo test --locked backup` passed 14 unit + 2 integration matches;
`cargo test --locked persistence` passed 15; `cargo test --locked --test
commit_restore` passed 9; `cargo test --locked --test vault_import` passed 5;
`cargo test --locked --test config_preservation` passed 9. `cargo fmt --all
-- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo
test --locked` (108 library unit + 9 Commit/restore + 9 preservation + 5 vault
import, 0 doctests), `cargo build --locked --release`, release `--help` and
`--version`, and `git diff --check` all exited zero. All three findings are
closed by independent R2/R3/R5; R5 found no new actionable issue. Evidence is
unprivileged synthetic/unit-fixture only. No Alpine, disposable systemd VM,
live VPN, host service, production file or production key was accessed or
claimed. The terminal workflow and real runtime/systemd checks belong to later
milestones. M4 has no remaining blocker.
#### Decisions and discoveries
Vault v1 already fixes data/vault.toml and authenticated conflict behavior.
Preserve that format and opaque ciphertext. New state/backup formats belong in
their own documents rather than altering vault or managed-comment v1 without need.
The persistence layer spans directories and cannot assume a single atomic rename.
Interruption recovery and conservative address reservations are required write
integrity, not concurrent editing support or automatic failed-restart rollback.
Confirmed archive restoration replaces server settings as part of explicit recovery;
normal client Commit continues to preserve administrator-owned bytes.
The v1 internal-state historical timestamp follows the application's
single-administrator save boundary; it is not a digest check against manual
out-of-band file changes. Reopen treats history as historical, not current
runtime verification. Restoration carries forward pending address reservations
for safety while replacing archived client/settings/vault saved content.
#### Outcome
Accepted after independent R5 review, closure of M4-R1-01/M4-R1-02/M4-R4-01,
and all M4-A1–A12 gates. Stop after M4. Begin M5 only on a separate request;
no Git commit, deployment or production access occurred.
### Milestone 5 — Runtime observation and systemd restart adapter
@@ -1061,13 +1421,14 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele
## Current progress and resume point
- Milestones 1, 2 and 3 are accepted; milestones 4–9 remain pending.
- Milestones 1–4 are accepted; milestones 5–9 remain pending.
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
- Milestone 1 is expanded and the Rust environment is pinned.
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
- Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter.
- Next action: stop after accepted M3; begin M4 only if separately requested.
- M1 integrated gates and independent re-review passed. No container or VM results are claimed.
- Milestone 4 delivered versioned private backups, journaled saved-file Commit and recovery, previewed full restore, durable address reservations and historical verification bookkeeping, with independent R5 closure and coordinator gates.
- Next action: stop after accepted M4; begin M5 only if separately requested.
- M1–M4 integrated gates and independent reviews passed. No Alpine or VM results are claimed.
## Plan maintenance
+24 -3
View File
@@ -3,8 +3,9 @@
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 terminal management interface, installed vault persistence, backups, and VPN
operations have not been implemented yet.
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:
@@ -67,7 +68,27 @@ 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 library APIs are not yet connected to a management session.
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
+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.
+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.
+3 -4
View File
@@ -7,12 +7,11 @@ unknown fields, malformed labels, invalid Base64, and oversized bundles.
## Version 1 structure
For M4, the current encrypted bundle is intended to live at
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. M3 only prepares and serializes vault bytes in memory; it does
not create this file or install application state. M4 persistence will own that
write.
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:
+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());
}
}
+21
View File
@@ -211,6 +211,25 @@ impl ClientStates {
&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
}
@@ -297,6 +316,8 @@ pub enum DomainError {
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.
+3
View File
@@ -1,5 +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;
+1299
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -258,6 +258,13 @@ impl VaultBundle {
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,
+14 -3
View File
@@ -366,6 +366,11 @@ 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() {
@@ -386,7 +391,9 @@ mod tests {
client_key.to_base64(),
server_key.public_key()
);
fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap();
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")),
@@ -424,13 +431,16 @@ mod tests {
client_key.to_base64(),
server_key.public_key()
);
fs::write(source.join("client.conf"), &bytes).unwrap();
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(
source.join("replacement.conf"),
&replacement,
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n",
decoy_key.to_base64(),
@@ -438,6 +448,7 @@ mod tests {
),
)
.unwrap();
make_private(&replacement);
let entries = rfs::Dir::read_from(&directory).unwrap();
let mut bundle = VaultBundle::create("password").unwrap();
+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);
}
+17 -8
View File
@@ -1,4 +1,5 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
@@ -32,6 +33,10 @@ restart_timeout_seconds = 30
.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();
@@ -73,15 +78,21 @@ fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged()
let (server, client, identity) = server_and_client();
let original_server = server.render();
let source_bytes = client_file(&client, &server);
fs::write(directory.path().join("laptop.conf"), &source_bytes).unwrap();
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();
fs::write(directory.path().join("ignored.txt"), &source_bytes).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();
fs::write(directory.path().join("nested/server.conf"), &source_bytes).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();
@@ -200,11 +211,9 @@ fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacem
prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap();
let original_recipient = initial.bundle.recipient().to_owned();
let mut bundle = initial.bundle;
fs::write(
directory.path().join("client.conf"),
client_file(&client, &server),
)
.unwrap();
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);