From 766059e6d10ce334ecd9a48d8c5f11b416e95936 Mon Sep 17 00:00:00 2001 From: sasa Date: Sun, 18 Oct 2026 22:34:01 +0300 Subject: [PATCH] Add server configuration editing with byte preservation --- AGENTS.md | 2 +- PLANS.md | 307 ++++++++- README.md | 34 +- docs/CONFIG_FORMAT.md | 98 +++ src/lib.rs | 1 + src/wireguard.rs | 16 + src/wireguard/document.rs | 630 +++++++++++++++++++ src/wireguard/edit.rs | 534 ++++++++++++++++ src/wireguard/metadata.rs | 501 +++++++++++++++ tests/config_preservation.rs | 395 ++++++++++++ tests/fixtures/wireguard/representative.conf | 22 + 11 files changed, 2521 insertions(+), 19 deletions(-) create mode 100644 docs/CONFIG_FORMAT.md create mode 100644 src/wireguard.rs create mode 100644 src/wireguard/document.rs create mode 100644 src/wireguard/edit.rs create mode 100644 src/wireguard/metadata.rs create mode 100644 tests/config_preservation.rs create mode 100644 tests/fixtures/wireguard/representative.conf diff --git a/AGENTS.md b/AGENTS.md index 61abca0..eeac4d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, and a pure client domain model. The management interface 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, and an in-memory, byte-preserving WireGuard configuration editor. The management interface, persistence, and VPN 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. diff --git a/PLANS.md b/PLANS.md index f51e411..be2d68e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 | pending | -| 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 | 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 | ### Milestone 1 — Rust foundation, settings, and domain states @@ -337,9 +337,285 @@ later milestones. Stop here; next milestone is 2 and has not started. ### Milestone 2 — Server configuration editing with byte preservation -Implement document-preserving parsing/editing and versioned metadata comments. Server configuration remains authoritative, duplicate aliases are permitted, and only managed client portions change. +Status: accepted (2026-10-18T19:26:14Z; independent R3 review and integrated gates passed). +Prerequisites: milestone 1 accepted; pinned native Rust environment and approved +cached dependencies or registry access. No root, WireGuard, systemd, Docker, or +production files are required. -Acceptance: synthetic fixtures prove byte preservation of untouched settings/comments, client addition/deletion/rename, unknown-field preservation, missing creation dates, and rejection of metadata/terminal injection. No complete generic INI reserialization. +#### Outcome and scope + +Deliver a pure library API that parses an existing server configuration, +inspects its client entries, and prepares additions, deletions, alias changes, +and managed metadata updates entirely in memory. Rendering an unmodified +parsed document must reproduce the original bytes exactly. Editing a client +must preserve every byte outside the explicitly owned edit spans. + +Governing requirements: SPEC.md Data sources, Operations and states, Importing +existing clients, and configuration-preservation acceptance criteria; ADRs +0001 (no implicit apply) and 0003 (server authority); PROJECT_LAYOUT.md, +DEVELOPMENT.md, DEPENDENCIES.md, and TESTING.md. Follow the existing model and +module conventions instead of introducing a second client identity system. + +Out of scope: reading production files, filesystem write/Commit orchestration, +key generation, client-file scanning/import and cryptographic matching, vaults, +backups, TUI, runtime/systemd actions, and network integration tests. Startup +must retain the safe incomplete-management behavior from milestone 1. + +#### Existing code and integration constraints + +- `src/lib.rs` currently exposes `config`, `domain`, and `startup` only. +- `domain::PublicKey` parses canonical Base64 into 32 bytes and provides identity, + equality, ordering, and formatting. Reuse it for lookup; never identify peers + by alias or position alone. `domain::validate_alias` rejects empty/control text + while allowing repeated aliases across clients. +- `domain::Client` contains identity, alias, and `ClientAddresses` with mandatory + IPv4 plus optional IPv6. `Snapshot` rejects duplicate public keys. The document + representation must not force every existing `[Peer]` into these narrow types. +- Existing peers may have multiple AllowedIPs, network prefixes, missing fields, + duplicate keys, IPv6-only addresses, unknown parameters, or malformed metadata. + Retain their original spans and return explicit inspection issues. Do not pick + the first address, collapse routes, drop a peer, or invent missing values to + force a successful conversion. Provide an explicit fallible projection for + compatible records; keep unsupported records available for inspection. +- Settings already expose resolved server paths but this milestone consumes + supplied document content only. `ClientStates` models pure draft/saved/applied + transitions; parsing/rendering must not mutate it or claim anything is applied. +- Cargo already includes base64, ipnet, rustix, serde, thiserror, and toml. + No generic INI parser or new unapproved dependency is needed. Approved `time` + may be added for UTC timestamp validation; do not add crypto or TUI crates early. + +#### Context and implementation steps + +Run tasks sequentially with one writer. The coordinator supplies relevant +requirements, acceptance IDs, owned files, and prior interface agreements to each +worker. Use GPT-6-Luna/high implementer, a separate reviewer, and repairer under +the GPT-6-Sol/medium coordinator. Workers do not edit milestone acceptance state. + +| Task | Owner | Files and deliverable | +| ---- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| M2.1 | implementer | `src/wireguard.rs`, `src/wireguard/document.rs`, `src/lib.rs`, focused tests: lossless document/span representation and peer inspection | +| M2.2 | implementer | `src/wireguard/metadata.rs`, document integration, `docs/CONFIG_FORMAT.md`: versioned managed comment grammar and validated metadata | +| M2.3 | implementer | wireguard module and narrow domain adapters: atomic in-memory add/delete/rename/metadata preparation keyed by PublicKey | +| M2.4 | implementer | `tests/config_preservation.rs`, `tests/fixtures/wireguard/`, README/library usage documentation: integrated preservation and failure scenarios | +| M2.5 | reviewer, repairer | Independent integrated review, bounded fixes and regression checks, re-review | +| M2.6 | coordinator | Integrated gates, evidence/findings ledger, acceptance and next handoff in PLANS.md | +| | | | + +M2.1 defines document ownership before editing. Preserve section order, whitespace, +line endings (LF/CRLF, including mixed endings), blank lines, unknown directives, +comments, and absence of a final newline. Use retained source bytes and spans, +not reconstructed serialization or a map keyed by property name. Separate raw +storage from parsed public views; errors and Debug must not expose the raw server +text, Interface PrivateKey, or peer PresharedKey. Unknown/unparseable content is +preserved, not silently normalized. Document supported input encoding; invalid +text must either remain losslessly opaque or be rejected without lossy decoding. + +Recognize WireGuard sections and key/value syntax deliberately. Repeated `[Peer]` +sections are expected; repeated/conflicting identity fields or the same public +key in multiple peers are ambiguous, not last-write-wins. Inspect them with issues +and refuse edits whose target or boundaries cannot be established safely. + +M2.2 specifies a uniquely recognizable managed block immediately before its peer, +with begin/end markers, explicit format version, alias, and optional creation +UTC timestamp. Before M2.3, document the exact grammar, escaping, ownership and +attachment rules, supported versions, unknown-field policy, and examples in +`docs/CONFIG_FORMAT.md`. Use unambiguous single-line encoding for aliases so `#`, +`=`, quotes, Unicode, or marker-looking text cannot create directives/sections. +Reject control/newline injection using domain validation. Reuse approved parsing +facilities where useful; a TOML-encoded string within comments does not mean the +server file itself is TOML. Never put secrets or a second public-key copy in the +block. Existing peers with unknown creation date have no fabricated timestamp. + +Metadata absence is a legacy state, not corruption. Use a shortened public-key +fallback only in a public view; do not persist it as a chosen alias implicitly. +Client filename fallback belongs to later import work. Malformed, duplicated, +misattached, or unsupported-version blocks must yield explicit issues and block +unsafe metadata edits; do not overwrite them as if absent or interpret arbitrary +administrator comments as managed blocks. + +M2.3 adds a new peer from validated public identity and supplied client addresses, +not a newly generated key. New host routes use `/32` and `/128` as appropriate; +existing AllowedIPs and connection parameters remain unchanged on alias edits. +Allow IPv4-only existing clients, repeated aliases, and caller-supplied timestamps. +Existing-peer metadata preparation is an explicit edit, not a side effect of parse. +A rename changes only the owned alias representation (or inserts an explicit new +managed block when safely absent), preserving any known creation date. + +Deletion removes only the selected peer's owned section content and attached +managed block. Define handling of administrator comments around and within the +peer so deleting it never removes/reassigns another peer's managed metadata or +unrelated comments. Sections such as `[Interface]`, PostUp/PostDown, and arbitrary +server directives must never be rewritten or executed. Unknown peer directives +are retained on non-deletion edits; deletion deliberately removes the selected +peer's configuration, not neighboring content. + +Validate each edit completely before applying it. Missing/ambiguous target, +invalid key/alias/address/metadata, or unsupported structures must leave the +original document unchanged. Sequential edits recompute or otherwise maintain +valid spans; no stale offsets after insertions/deletions. Rendering never writes +files and must not invoke commit_draft, system tools, or runtime adapters. + +M2.4 uses synthetic fixture data only. If secret-shaped input is required for +preservation/redaction checks, generate dummy values in the test or clearly mark +non-production placeholders; do not print full fixtures in assertion failures. +Prefer comparisons of preserved slices and precise changed spans over brittle +snapshots of the entire file. No real server sample is needed or permitted. + +#### Acceptance and validation + +- [x] M2-A1: unmodified round trips are byte-identical for LF, CRLF, mixed endings, + no final newline, whitespace/comments, unknown directives, and multiple peers. +- [x] M2-A2: parse/inspection/edit/render operate only on caller-supplied data; + no filesystem persistence, live config access, runtime mutation, or startup change. +- [x] M2-A3: public identity reuses PublicKey; duplicate aliases are supported; + malformed/missing/duplicate keys and unsupported address shapes remain visible + as issues rather than being silently normalized into Snapshot/Client. +- [x] M2-A4: new peer addition, selected-peer deletion, rename, and explicit legacy + metadata insertion affect only intended spans; all server parameters and + unrelated administrator comments remain byte-identical. +- [x] M2-A5: the exact metadata format is documented with supported version, + escaping/attachment rules and examples. Missing creation dates stay missing; + rename preserves known dates, and no key copy or secret appears in metadata. +- [x] M2-A6: malformed/unknown-version/misattached blocks and ambiguous peers are + preserved and reported; unsafe edits fail without changing any original bytes. +- [x] M2-A7: boundary cases cover first/last/adjacent peer removal, inter-peer + comments, Unicode/quoted/marker-like aliases, control injection, and repeated + edits with no stale spans. Rendering an edit result and parsing it again + produces the intended public records without modifying untouched content. +- [x] M2-A8: unsupported routes, multiple addresses and IPv6-only records round-trip + unchanged; projection to the narrow M1 model fails explicitly when necessary. +- [x] M2-A9: errors/Debug never disclose secret-bearing source lines or complete + documents; input rejection does not corrupt the caller's document. +- [x] M2-A10: existing M1 tests and startup help/version behavior remain intact; + focused/full gates pass and independent review has no unresolved findings. + +Planned commands from the repository root, after M2 targets exist: + +```sh +cargo test --locked wireguard +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 +``` + +Use unit-test names containing `wireguard` for the focused filter, and verify +that the filter actually runs tests. All commands above should exit zero; +negative cases are assertions inside passing tests, not ignored failures. +Run as an unprivileged user with synthetic input. Dependency resolution is needed +only if approved `time` is introduced; update Cargo.lock before --locked gates. +Document the exact commands/results and test counts when execution occurs. +No container or VM result is required or claimed for this pure-library milestone. + +#### Progress + +- [x] Description prepared from the current M1 code and approved documents. +- [x] M2.1 — implementer (`/root/implementer_m2_1`) completed 2026-10-18T19:10:30Z; lossless parser/inspection integrated. +- [x] M2.2 — implementer (`/root/implementer_m2_2`) completed 2026-10-18T19:14:23Z; v1 metadata grammar, attachment, and format document integrated. +- [x] M2.3 — implementer (`/root/implementer_m2_3`) completed 2026-10-18T19:17:05Z; pure peer edits/projection integrated. +- [x] M2.4 — implementer (`/root/implementer_m2_4`) completed 2026-10-18T19:20:05Z; fixture suite and README integrated with one retained failing assertion. +- [x] M2.5 — fresh reviewer (`/root/reviewer_m2_r1`) completed R1/R2/R3; repairer (`/root/repairer_m2_r1`) resolved both findings; R3 found no remaining issue. +- [x] M2.6 — coordinator integrated gates passed and acceptance recorded. + +#### Review and repair + +| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State | +| --- | --- | --- | --- | --- | --- | +| M2-R1-01 (M2-I-01) | implementation/R1 | `src/wireguard/edit.rs:214` rename rebuilt full metadata block and normalized mixed endings outside alias bytes | repairer | Replaced only the encoded alias value; mixed-ending regression and R2/R3 review passed | closed | +| M2-R2-01 | R2 | `PeerInspection.issues` omitted valid but unsupported address shapes, contrary to M2-A3 | repairer | Added redacted unsupported-shape/missing-IPv4 issues and fixture assertions; R3 review passed | closed | + +R1 found the preservation defect. R2 confirmed that fix and found the inspection +gap. R3 confirmed both repairs and found no remaining actionable issue. The +byte-preservation assertion was retained; unsupported records remain visible. + +#### Evidence and handoff + +Planning inspected Cargo.toml, src/lib.rs, domain public-key/client/state types, +settings interfaces, M1 acceptance evidence, and governing documents. M1 records +22 passing tests and independent R2 acceptance; this planning task did not rerun +those checks and makes no new application-validation claim. No M2 source edits, +workers, Cargo commands, production files, containers, or services were involved. + +Execution verified the active coordinator turn as `gpt-6-sol`/`medium` from +session turn context. M1 is accepted. At execution start the only existing +worktree change was this prepared M2 record in PLANS.md; no M2 source files +existed. Rustc/Cargo 1.97.1, rustfmt 1.9.0, and Clippy 0.1.97 are available. +M2.1 contract: a caller-supplied byte buffer with retained source/spans, a +public peer inspection view keyed by the existing `PublicKey`, secret-free +issues/errors/Debug, and exact unchanged-byte rendering. Invalid UTF-8 may be +rejected with a redacted error. Editing remains for later tasks. Next action: +dispatch M2.1. No M2 validation result is claimed yet. +M2.1 added `src/wireguard.rs`, `src/wireguard/document.rs`, and a library +declaration. The coordinator inspected the actual files. The parser retains a +UTF-8 source buffer and byte spans, rejects invalid UTF-8 without echoing it, +reports peer identity/route issues without losing raw content, and renders +unchanged bytes exactly. The worker reported seven focused `wireguard` tests, +28 full tests, fmt, Clippy, release build, and diff check passing. Its turn +context was verified as `gpt-6-luna`/`high`. Metadata grammar and edits remain +unimplemented; next action is M2.2. +M2.2 added `src/wireguard/metadata.rs`, integrated metadata inspection in the +document module, and documented v1 grammar in `docs/CONFIG_FORMAT.md`. The +coordinator inspected those files. Valid blocks expose alias, optional UTC +creation time, and owned span; malformed/unsupported/misattached blocks yield +redacted issues. The worker reported 14 focused `wireguard` tests, 35 full +tests, fmt, Clippy, release build, and diff check passing; no new dependency. +Its turn context was verified as `gpt-6-luna`/`high`. Next action is M2.3. +M2.3 added `src/wireguard/edit.rs` and API exports for add/delete/rename, +explicit legacy metadata insertion, and fallible projection. The coordinator +inspected the code. The worker reported 21 focused `wireguard` tests, 42 full +tests, fmt, Clippy, release build, and diff check passing. Its turn context +was verified as `gpt-6-luna`/`high`. Next: M2.4 fixture-level behavioral tests +and README updates, then independent review. +M2.4 added `tests/config_preservation.rs`, a synthetic server fixture, and +README library API guidance. The coordinator inspected those files. Focused +integration results: eight passed, one failed at the mixed-ending metadata +rename assertion (M2-I-01). Full `cargo test --locked` likewise has 42 unit +tests passing but fails on that integration assertion. Fmt, Clippy, release +build, and diff check passed. Its turn context was verified as +`gpt-6-luna`/`high`. No acceptance gate is claimed passing while this fails. +Next: fresh independent review of all M2 changes and the recorded defect. +R1 reviewer turn context was verified as `gpt-6-luna`/`high`. It reproduced +M2-R1-01, ran 21 focused `wireguard` tests passing, and found fmt, Clippy, +release build, and diff check passing. Integrated test/full test remain red. +Next: repair M2-R1-01, then R2 re-review. +The repairer was configured as `gpt-6-luna`/`high`. First repair restricted an +existing valid metadata rename to its encoded alias value. R2 independently +confirmed the mixed-ending regression passed and identified M2-R2-01: valid +unsupported routes were not reported in `PeerInspection.issues`. The second +repair added redacted address-shape issues and their behavioral assertions, +while retaining raw routes, safe metadata edits, and fallible projection. It +also replaced a parser `expect` on user input with a redacted typed error. R3 +independently verified both repairs and reported no further actionable finding. +R2/R3 reviewer turns used the same independently spawned `gpt-6-luna`/`high` +reviewer; it remained read-only throughout. + +Coordinator integrated gates on the final M2 source, run unprivileged with +synthetic input on 2026-10-18: `cargo test --locked wireguard` passed 21 tests; +`cargo test --locked --test config_preservation` passed 9 tests; `cargo fmt +--all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, +`cargo build --locked --release`, and `git diff --check` exited zero; +`cargo test --locked` passed 42 unit and 9 integration tests (0 doc tests); +release `--help` and `--version` exited zero with the existing safe startup +output. The focused filter was verified to execute 21 tests. No production +files, host VPN/service, container, or systemd VM was accessed; real-system +checks are not required for this pure-library milestone and are not claimed. + +#### Decisions and discoveries + +M1's ClientAddresses cannot represent every valid existing AllowedIPs layout. +Keep a lossless document model plus explicit projection issues rather than +broadening product scope or truncating existing settings. Metadata grammar and +comment ownership must be settled and documented before edit implementation. +No new ADR is required merely for moving parser code into the planned modules. + +#### Outcome + +Accepted after independent R3 review and all M2-A1–A10 checks. No M2 blockers +remain. Stop here; milestone 3 is the next action only on a new request. ### Milestone 3 — Client keys, encrypted vault, and existing-client import @@ -385,10 +661,11 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele ## Current progress and resume point -- Milestone 1 is accepted; milestones 2–9 remain pending. +- Milestones 1 and 2 are accepted; milestones 3–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. -- Next action, only on a later request: prepare Milestone 2; no work on it has started. +- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. +- Next action: stop after accepted M2; begin M3 only if separately requested. - M1 integrated gates and independent re-review passed. No container or VM results are claimed. ## Plan maintenance diff --git a/README.md b/README.md index 572e0bd..898f5eb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # wireguard-admin -This repository now contains the initial Rust package, startup boundary, and -validated settings loader for WireGuard Admin. Client management and the VPN -management interface have not been implemented yet. +This repository contains the initial Rust package, startup boundary, validated +settings loader, client domain model, and a pure in-memory WireGuard server +configuration parser/editor. The terminal management interface, file +persistence, vault, client import workflow, backups, and VPN 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: @@ -41,3 +43,29 @@ absolute. Settings validation checks syntax and values only; it does not require the referenced files, directories, or service to exist and does not perform DNS lookups. +## Library configuration API + +The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8 +configuration bytes supplied by the caller. It retains the source and renders +unmodified input byte-for-byte, including line endings and a missing final +newline. Peer inspection exposes public identities, parsed `AllowedIPs`, spans, +and redacted issues; arbitrary directive values are not included in the view. +The edit methods `add_client`, `delete_client`, `rename_client`, and +`insert_legacy_metadata` return new in-memory documents. They do not read or +write files, save changes, or alter the running VPN. See +[`docs/CONFIG_FORMAT.md`](docs/CONFIG_FORMAT.md) for the managed metadata block +format. + +Example: + +```rust,ignore +use wireguard_admin::wireguard::ConfigDocument; + +let document = ConfigDocument::parse(config_bytes)?; +let updated = document.rename_client(&public_key, "office laptop")?; +let bytes_to_review = updated.render(); // no persistence or VPN operation +``` + +Management startup still exits with an explicit “not implemented” error after +its root check; the library APIs are not yet connected to a management session. + diff --git a/docs/CONFIG_FORMAT.md b/docs/CONFIG_FORMAT.md new file mode 100644 index 0000000..4604e82 --- /dev/null +++ b/docs/CONFIG_FORMAT.md @@ -0,0 +1,98 @@ +# Managed metadata in the WireGuard configuration + +WireGuard Admin stores a client's display alias and optional creation time in a +versioned comment block immediately before that client's `[Peer]` header. The +server configuration remains the source of truth. The block contains no public +key copy, private key, preshared key, password, or other connection secret. + +## Version 1 grammar + +The canonical LF form is: + +```ini +# wg-admin:begin +# wg-admin:version=1 +# wg-admin:alias= +# wg-admin:created-utc=2026-10-18T19:00:00Z +# wg-admin:end +[Peer] +``` + +`created-utc` is optional. If it is unknown, it is omitted; an existing peer's +unknown creation date is never inferred from import time, file time, or edit time. +The timestamp grammar is exactly `YYYY-MM-DDTHH:MM:SSZ`, with a real Gregorian +calendar date, a four-digit nonzero year, and seconds from `00` through `59`. +Leap seconds and fractional seconds are not represented. + +The alias line contains the canonical URL-safe Base64 encoding of the alias's +UTF-8 bytes, with no `=` padding. This representation is a single comment line +and makes characters such as `#`, `=`, quotes, non-ASCII text, and strings that +look like block markers unambiguous. An alias must pass the application's shared +alias validation: it cannot be empty or contain Unicode control characters. +The encoded representation is decoded and re-encoded canonically when read. + +The v1 block has exactly one begin marker, one version field, one alias field, +optional single creation field, and one end marker, in the displayed order. No +other lines or fields are permitted. Unknown fields, duplicate fields, malformed +encoding, or invalid timestamps make the block invalid. A version other than 1 +is reported as unsupported and is not interpreted or rewritten. + +## Ownership and attachment + +The exact reserved marker lines are `# wg-admin:begin` and `# wg-admin:end`. +Marker lines with trailing whitespace or extra text are treated as malformed +reserved markers. Other administrator comments, including comments that merely +mention `wg-admin`, do not become managed blocks. + +A valid block owns only its own comment lines and line endings. It is attached to +one peer only when its end marker line, including its line ending, ends at the +first byte of that peer's `[Peer]` header. No blank line, administrator comment, +or other text may intervene. A block elsewhere is reported as misattached. A +missing block is the legacy state: no alias or creation date is inferred, and no +block span is available to edit. Multiple adjacent blocks before one peer are a +duplicate and block metadata edits. Malformed, misattached, duplicate, and +unsupported-version blocks are retained as supplied and block unsafe metadata +edits; they are not treated as absent metadata. + +The parser exposes the decoded alias only for a valid, uniquely attached v1 +block. It exposes an owned byte span only for that valid block. Issues contain +line numbers and categories but never raw source text. Parsing and rendering are +caller-buffer operations and do not read or write configuration files. + +## Examples + +An alias containing syntax characters and Unicode is encoded, not escaped in +WireGuard syntax. For example, the alias `# café = "laptop"` is represented as: + +```ini +# wg-admin:begin +# wg-admin:version=1 +# wg-admin:alias=IyBjYWbDqSA9ICJsYXB0b3Ai +# wg-admin:end +[Peer] +``` + +A marker-looking alias is handled the same way. Applications should use the +canonical renderer rather than hand-constructing the encoded value. + +An existing client with unknown creation time has no `created-utc` field: + +```ini +# wg-admin:begin +# wg-admin:version=1 +# wg-admin:alias=V29ya2xhcGU +# wg-admin:end +[Peer] +PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +AllowedIPs = 10.0.0.2/32 +``` + +An unrelated administrator comment is preserved and does not describe managed +metadata: + +```ini +# Added during the 2024 network migration; wg-admin is not the owner of this note. +[Peer] +PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= +AllowedIPs = 10.0.0.3/32 +``` diff --git a/src/lib.rs b/src/lib.rs index 83f5c9e..fff7c78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod config; pub mod domain; pub mod startup; +pub mod wireguard; diff --git a/src/wireguard.rs b/src/wireguard.rs new file mode 100644 index 0000000..480c106 --- /dev/null +++ b/src/wireguard.rs @@ -0,0 +1,16 @@ +//! Pure parsing and inspection of caller-supplied WireGuard server configuration. +//! +//! Parsing never reads or writes files and never changes runtime state. + +pub mod document; +pub mod edit; +pub mod metadata; + +pub use document::{ + ConfigDocument, DirectiveSpan, DocumentError, DocumentIssue, DocumentSection, PeerInspection, + PeerIssue, SectionKind, SourceSpan, +}; +pub use edit::{ + EditError, add_client, delete_client, insert_legacy_metadata, project_client, rename_client, +}; +pub use metadata::{ManagedMetadata, MetadataAttachment, MetadataError, MetadataIssue}; diff --git a/src/wireguard/document.rs b/src/wireguard/document.rs new file mode 100644 index 0000000..4c64668 --- /dev/null +++ b/src/wireguard/document.rs @@ -0,0 +1,630 @@ +//! Lossless byte-span representation and redacted public inspection. +//! +//! The supported input encoding is UTF-8. Input that is not valid UTF-8 is +//! rejected, without including any source bytes in the error. The document +//! retains the exact input bytes, including mixed line endings and a missing +//! final newline. Public views expose only section/key spans, peer public keys, +//! and parsed AllowedIPs; no arbitrary directive values are exposed because +//! they may contain secrets. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use ipnet::IpNet; +use thiserror::Error; + +use crate::domain::PublicKey; + +use super::metadata::{self, MetadataAttachment, MetadataIssue}; + +/// A half-open byte range into the original UTF-8 input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SourceSpan { + start: usize, + end: usize, +} + +impl SourceSpan { + /// Construct a span from byte offsets. Returns `None` when the end precedes the start. + pub const fn from_offsets(start: usize, end: usize) -> Option { + if start <= end { + Some(Self { start, end }) + } else { + None + } + } + + pub const fn start(self) -> usize { + self.start + } + + pub const fn end(self) -> usize { + self.end + } + + pub const fn len(self) -> usize { + self.end - self.start + } + + pub const fn is_empty(self) -> bool { + self.start == self.end + } +} + +/// The section type recognized from a bracketed section header. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SectionKind { + Interface, + Peer, + Other, +} + +/// A recognized section and its original byte boundaries. +#[derive(Clone, Eq, PartialEq)] +pub struct DocumentSection { + pub kind: SectionKind, + /// Header name after trimming surrounding ASCII whitespace. + pub name: String, + /// Includes the header and section body, ending immediately before the next header. + pub span: SourceSpan, + /// Header line only, excluding its original line ending. + pub header_span: SourceSpan, + pub line: usize, +} + +impl fmt::Debug for DocumentSection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DocumentSection") + .field("kind", &self.kind) + .field("span", &self.span) + .field("header_span", &self.header_span) + .field("line", &self.line) + .finish() + } +} + +/// Spans of a recognized key/value directive; values remain private to the document. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DirectiveSpan { + pub line: usize, + pub whole: SourceSpan, + pub key: SourceSpan, + pub value: SourceSpan, +} + +/// A public, secret-safe inspection of one `[Peer]` section. +/// +/// Conversion to the narrow `domain::Client` model is intentionally deferred +/// to the M2.3 adapter: this view has no alias until managed metadata is parsed. +/// That conversion must require one unambiguous identity and route shapes that +/// fit `ClientAddresses`; it must reject rather than truncate other AllowedIPs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerInspection { + pub index: usize, + pub section: SourceSpan, + pub section_line: usize, + /// Present only when this section has exactly one valid PublicKey directive. + pub identity: Option, + /// Every valid public key value, even when the section is ambiguous. + pub public_key_candidates: Vec, + /// Every PublicKey directive is retained as a span, including duplicates and malformed values. + pub public_key_fields: Vec, + /// Parsed route values. Unparseable items are represented by `PeerIssue` entries. + pub allowed_ips: Vec, + pub allowed_ips_fields: Vec, + /// Parsed alias and safe owned block span, or an explicit metadata issue. + pub metadata: MetadataAttachment, + pub issues: Vec, +} + +/// Issues found while inspecting a peer. Variants contain no source text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PeerIssue { + MissingPublicKey, + MalformedPublicKey { field: usize }, + DuplicatePublicKeyField { count: usize }, + DuplicatePeerIdentity { other_peer_indices: Vec }, + MalformedAllowedIp { field: usize, item: usize }, + UnsupportedAllowedIpShape { field: usize, item: usize }, + MissingIpv4HostRoute, +} + +/// Structural issues found outside peer-level inspection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DocumentIssue { + MalformedSectionHeader { line: usize }, + DirectiveOutsideSection { line: usize }, + MalformedDirective { line: usize }, + ManagedMetadata(MetadataIssue), +} + +/// A document-level parse failure. It intentionally never contains input bytes or text. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum DocumentError { + #[error("server configuration is not valid UTF-8")] + InvalidEncoding, + #[error("server configuration could not be inspected safely")] + InvalidStructure, +} + +/// Parsed view over the exact caller-supplied configuration bytes. +pub struct ConfigDocument { + source: String, + sections: Vec, + peers: Vec, + issues: Vec, +} + +impl fmt::Debug for ConfigDocument { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConfigDocument") + .field("source_len", &self.source.len()) + .field("sections", &self.sections) + .field("peers", &self.peers) + .field("issues", &self.issues) + .finish() + } +} + +impl ConfigDocument { + /// Parse UTF-8 configuration supplied by the caller while retaining every source byte. + pub fn parse(source: impl AsRef<[u8]>) -> Result { + let bytes = source.as_ref(); + let source = std::str::from_utf8(bytes).map_err(|_| DocumentError::InvalidEncoding)?; + let mut sections = Vec::::new(); + let mut issues = Vec::::new(); + let mut peer_fields = Vec::<( + usize, + Vec, + Vec<(DirectiveSpan, Vec<(usize, Result)>)>, + )>::new(); + let mut current_section: Option = None; + let mut offset = 0usize; + let mut line_number = 0usize; + + for line_with_ending in source.split_inclusive('\n') { + line_number += 1; + let line_start = offset; + offset += line_with_ending.len(); + let content = line_with_ending + .strip_suffix('\n') + .unwrap_or(line_with_ending); + let content = content.strip_suffix('\r').unwrap_or(content); + let line_end = line_start + content.len(); + let span = SourceSpan { + start: line_start, + end: offset, + }; + let content_span = SourceSpan { + start: line_start, + end: line_end, + }; + let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace()); + + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') { + continue; + } + + if trimmed.starts_with('[') || trimmed.ends_with(']') { + if let Some(name) = parse_section_name(trimmed) { + if let Some(previous) = current_section { + sections[previous].span.end = line_start; + } + let normalized = name.to_ascii_lowercase(); + let kind = match normalized.as_str() { + "interface" => SectionKind::Interface, + "peer" => SectionKind::Peer, + _ => SectionKind::Other, + }; + sections.push(DocumentSection { + kind, + name: name.to_owned(), + span: SourceSpan { + start: line_start, + end: source.len(), + }, + header_span: content_span, + line: line_number, + }); + current_section = Some(sections.len() - 1); + if kind == SectionKind::Peer { + peer_fields.push((line_number, Vec::new(), Vec::new())); + } + continue; + } + issues.push(DocumentIssue::MalformedSectionHeader { line: line_number }); + continue; + } + + let Some(equal_index) = content.find('=') else { + issues.push(DocumentIssue::MalformedDirective { line: line_number }); + continue; + }; + let raw_key = &content[..equal_index]; + let raw_value = &content[equal_index + 1..]; + let key = raw_key.trim_matches(|ch: char| ch.is_ascii_whitespace()); + if key.is_empty() { + issues.push(DocumentIssue::MalformedDirective { line: line_number }); + continue; + } + let raw_key_start = raw_key.len() + - raw_key + .trim_start_matches(|ch: char| ch.is_ascii_whitespace()) + .len(); + let raw_key_end = raw_key + .trim_end_matches(|ch: char| ch.is_ascii_whitespace()) + .len(); + let raw_value_start = raw_value.len() + - raw_value + .trim_start_matches(|ch: char| ch.is_ascii_whitespace()) + .len(); + let raw_value_end = raw_value + .trim_end_matches(|ch: char| ch.is_ascii_whitespace()) + .len(); + let directive = DirectiveSpan { + line: line_number, + whole: span, + key: SourceSpan { + start: line_start + raw_key_start, + end: line_start + raw_key_end, + }, + value: SourceSpan { + start: line_start + equal_index + 1 + raw_value_start, + end: line_start + equal_index + 1 + raw_value_end, + }, + }; + let Some(section_index) = current_section else { + issues.push(DocumentIssue::DirectiveOutsideSection { line: line_number }); + continue; + }; + if sections[section_index].kind != SectionKind::Peer { + continue; + } + let peer_index = peer_fields.len() - 1; + let (_, public_keys, allowed_fields) = &mut peer_fields[peer_index]; + if key.eq_ignore_ascii_case("PublicKey") { + public_keys.push(directive); + } else if key.eq_ignore_ascii_case("AllowedIPs") { + let value = &source[directive.value.start..directive.value.end]; + let parsed = value + .split(',') + .enumerate() + .map(|(item, raw)| { + let item_value = raw.trim(); + (item, IpNet::from_str(item_value).map_err(|_| ())) + }) + .collect(); + allowed_fields.push((directive, parsed)); + } + } + + // `split_inclusive` yields no item for an empty string and its final item does not + // necessarily end in LF; offset therefore tracks all source bytes exactly. + let mut peers = Vec::with_capacity(peer_fields.len()); + for (index, (section_line, public_key_fields, allowed_fields)) in + peer_fields.into_iter().enumerate() + { + let Some(section) = sections + .iter() + .filter(|section| section.kind == SectionKind::Peer) + .nth(index) + else { + return Err(DocumentError::InvalidStructure); + }; + let mut peer_issues = Vec::new(); + let parsed_keys: Vec<_> = public_key_fields + .iter() + .enumerate() + .map(|(field, directive)| { + let value = &source[directive.value.start..directive.value.end]; + match PublicKey::from_str(value) { + Ok(key) => Some(key), + Err(_) => { + peer_issues.push(PeerIssue::MalformedPublicKey { field }); + None + } + } + }) + .collect(); + if public_key_fields.is_empty() { + peer_issues.push(PeerIssue::MissingPublicKey); + } + if public_key_fields.len() > 1 { + peer_issues.push(PeerIssue::DuplicatePublicKeyField { + count: public_key_fields.len(), + }); + } + let identity = if public_key_fields.len() == 1 { + parsed_keys[0] + } else { + None + }; + let mut allowed_ips = Vec::new(); + let mut allowed_ips_fields = Vec::new(); + let mut has_ipv4_host_route = false; + let mut has_ipv6_host_route = false; + for (field, (directive, items)) in allowed_fields.into_iter().enumerate() { + allowed_ips_fields.push(directive); + for (item, result) in items { + match result { + Ok(net) => { + let supported = match net { + IpNet::V4(network) if network.prefix_len() == 32 => { + let first = !has_ipv4_host_route; + has_ipv4_host_route = true; + first + } + IpNet::V6(network) if network.prefix_len() == 128 => { + let first = !has_ipv6_host_route; + has_ipv6_host_route = true; + first + } + _ => false, + }; + if !supported { + peer_issues + .push(PeerIssue::UnsupportedAllowedIpShape { field, item }); + } + allowed_ips.push(net); + } + Err(()) => peer_issues.push(PeerIssue::MalformedAllowedIp { field, item }), + } + } + } + if !has_ipv4_host_route { + peer_issues.push(PeerIssue::MissingIpv4HostRoute); + } + peers.push(PeerInspection { + index, + section: section.span, + section_line, + identity, + public_key_candidates: parsed_keys.into_iter().flatten().collect(), + public_key_fields, + allowed_ips, + allowed_ips_fields, + metadata: MetadataAttachment::Absent, + issues: peer_issues, + }); + } + + let mut by_identity = BTreeMap::>::new(); + for peer in &peers { + for identity in &peer.public_key_candidates { + by_identity.entry(*identity).or_default().push(peer.index); + } + } + for indices in by_identity.values().filter(|indices| indices.len() > 1) { + let unique_indices: Vec<_> = + indices.iter().copied().fold(Vec::new(), |mut acc, index| { + if !acc.contains(&index) { + acc.push(index); + } + acc + }); + for index in &unique_indices { + let other_peer_indices: Vec<_> = unique_indices + .iter() + .copied() + .filter(|other| other != index) + .collect(); + if other_peer_indices.is_empty() { + continue; + } + peers[*index] + .issues + .push(PeerIssue::DuplicatePeerIdentity { other_peer_indices }); + } + } + + let peer_headers: Vec<_> = peers.iter().map(|peer| peer.section.start()).collect(); + let (metadata_attachments, metadata_issues) = + metadata::inspect_document(source, &peer_headers); + for (peer, attachment) in peers.iter_mut().zip(metadata_attachments) { + peer.metadata = attachment; + } + issues.extend( + metadata_issues + .into_iter() + .map(DocumentIssue::ManagedMetadata), + ); + + Ok(Self { + source: source.to_owned(), + sections, + peers, + issues, + }) + } + + /// Exact original bytes, without normalization or reserialization. + pub fn render(&self) -> &[u8] { + self.source.as_bytes() + } + + pub fn sections(&self) -> &[DocumentSection] { + &self.sections + } + + pub fn peers(&self) -> &[PeerInspection] { + &self.peers + } + + pub fn issues(&self) -> &[DocumentIssue] { + &self.issues + } + + /// A raw source slice for an explicitly selected span. Callers must treat it as secret-bearing. + /// This narrow primitive supports later edit code that owns and replaces exact spans. + pub fn source_slice(&self, span: SourceSpan) -> Option<&str> { + self.source.get(span.start..span.end) + } +} + +fn parse_section_name(trimmed: &str) -> Option<&str> { + let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?; + let name = inner.trim_matches(|ch: char| ch.is_ascii_whitespace()); + (!name.is_empty() && !name.contains(['[', ']'])).then_some(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; + + #[test] + fn wireguard_document_round_trips_line_ending_variants_byte_for_byte() { + let inputs: &[&[u8]] = &[ + b"[Interface]\nAddress = 10.0.0.1/24\n# admin\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.0.0.2/32\n", + b"[Interface]\r\nAddress = 10.0.0.1/24\r\n[Peer]\r\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n", + b"# first\r\n[Interface]\nUnknown = value\r\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n# tail", + b"[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ]; + for input in inputs { + let document = ConfigDocument::parse(input).unwrap(); + assert_eq!(document.render(), *input); + } + } + + #[test] + fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() { + let input = format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 2001:db8::2/128, 0.0.0.0/0\nPresharedKey = dummy-secret\nUnknown = retained\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 2001:db8::3/128\n" + ); + let document = ConfigDocument::parse(input.as_bytes()).unwrap(); + assert_eq!(document.peers().len(), 2); + assert_eq!(document.peers()[0].allowed_ips.len(), 3); + assert_eq!(document.peers()[1].allowed_ips.len(), 1); + assert_eq!(document.peers()[0].identity.unwrap().to_string(), KEY_A); + assert!(document.peers()[0].issues.iter().any(|issue| matches!( + issue, + PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 2 } + ))); + assert!( + document.peers()[1] + .issues + .contains(&PeerIssue::MissingIpv4HostRoute) + ); + } + + #[test] + fn wireguard_unsupported_route_text_is_preserved_and_reported() { + let input = + format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = not-a-route, 2001:db8::2/128\n"); + let document = ConfigDocument::parse(input.as_bytes()).unwrap(); + let peer = &document.peers()[0]; + assert_eq!(peer.allowed_ips.len(), 1); + assert!( + peer.issues + .iter() + .any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. })) + ); + assert_eq!(document.render(), input.as_bytes()); + assert_eq!( + document.source_slice(peer.allowed_ips_fields[0].value), + Some("not-a-route, 2001:db8::2/128") + ); + } + + #[test] + fn wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields() { + let input = format!( + "[Peer]\nPresharedKey = secret-a\n[Peer]\nPublicKey = bad-secret-shaped-input\nPublicKey = {KEY_A}\n[Peer]\nPublicKey = {KEY_A}\n" + ); + let document = ConfigDocument::parse(input.as_bytes()).unwrap(); + assert!( + document.peers()[0] + .issues + .contains(&PeerIssue::MissingPublicKey) + ); + assert!( + document.peers()[1] + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::MalformedPublicKey { .. })) + ); + assert!( + document.peers()[1] + .issues + .contains(&PeerIssue::DuplicatePublicKeyField { count: 2 }) + ); + assert!( + document.peers()[2] + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) + ); + let debug = format!("{document:?}"); + assert!(!debug.contains("secret-a")); + assert!(!debug.contains("bad-secret-shaped-input")); + } + + #[test] + fn wireguard_errors_and_debug_redact_invalid_text_and_private_values() { + let error = + ConfigDocument::parse(b"[Interface]\nPrivateKey = definitely-secret\xff").unwrap_err(); + assert_eq!(error.to_string(), "server configuration is not valid UTF-8"); + assert!(!format!("{error:?}").contains("definitely-secret")); + let document = ConfigDocument::parse( + b"[Interface]\nPrivateKey = private-value\n[Peer]\nPresharedKey = peer-secret\n", + ) + .unwrap(); + let debug = format!("{document:?}"); + assert!(!debug.contains("private-value")); + assert!(!debug.contains("peer-secret")); + } + + #[test] + fn wireguard_sections_and_directive_spans_are_byte_offsets() { + let input = + format!("[Interface]\r\nPrivateKey = hidden\r\n[Peer]\r\nPublicKey = {KEY_A}\r\n"); + let document = ConfigDocument::parse(input.as_bytes()).unwrap(); + let peer = &document.peers()[0]; + let key_text = document + .source_slice(peer.public_key_fields[0].value) + .unwrap(); + assert_eq!(key_text, KEY_A); + assert_eq!(peer.section.start(), input.find("[Peer]").unwrap()); + assert_eq!(peer.section.end(), input.len()); + } + + #[test] + fn wireguard_document_exposes_valid_metadata_and_safe_owned_span() { + let block = super::super::metadata::render_block("# café = \"laptop\"", None).unwrap(); + let input = format!("{block}[Peer]\nPublicKey = {KEY_A}\n"); + let document = ConfigDocument::parse(input.as_bytes()).unwrap(); + let peer = &document.peers()[0]; + let MetadataAttachment::Valid { + metadata, + block: span, + } = &peer.metadata + else { + panic!("valid attached metadata should be available"); + }; + assert_eq!(metadata.alias, "# café = \"laptop\""); + assert_eq!(document.source_slice(*span), Some(block.as_str())); + assert!(document.issues().is_empty()); + assert_eq!(document.render(), input.as_bytes()); + } + + #[test] + fn wireguard_document_reports_unsupported_metadata_without_source_text() { + let input = format!( + "# wg-admin:begin\n# wg-admin:version=99\n# wg-admin:alias=c2VjcmV0LW5vdGU\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\n" + ); + let document = ConfigDocument::parse(input.as_bytes()).unwrap(); + assert!(matches!( + document.peers()[0].metadata, + MetadataAttachment::Invalid(MetadataIssue::UnsupportedVersion { .. }) + )); + let debug = format!("{document:?}"); + assert!(!debug.contains("c2VjcmV0LW5vdGU")); + assert!(!debug.contains("secret-note")); + assert_eq!(document.render(), input.as_bytes()); + } +} diff --git a/src/wireguard/edit.rs b/src/wireguard/edit.rs new file mode 100644 index 0000000..388597d --- /dev/null +++ b/src/wireguard/edit.rs @@ -0,0 +1,534 @@ +//! Pure, span-based edits to caller-supplied WireGuard server documents. + +use ipnet::IpNet; +use thiserror::Error; + +use crate::domain::{Client, ClientAddresses, PublicKey, validate_alias}; + +use super::document::{ConfigDocument, PeerInspection, PeerIssue}; +use super::metadata::{MetadataAttachment, MetadataError, render_block}; + +/// Redacted failures from an in-memory configuration operation. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum EditError { + #[error("the selected client identity is missing")] + MissingIdentity, + #[error("the selected client identity is ambiguous")] + AmbiguousIdentity, + #[error("the configuration contains malformed or unsupported structure")] + UnsafeDocument, + #[error("the selected peer has malformed or unsupported fields")] + UnsafePeer, + #[error("the client identity already exists")] + DuplicateIdentity, + #[error("the client alias is invalid")] + InvalidAlias, + #[error("the creation timestamp is invalid")] + InvalidTimestamp, + #[error("the peer does not have a usable alias")] + MissingAlias, + #[error("the peer routes do not fit the supported client address model")] + UnsupportedAddresses, + #[error("the edited configuration could not be parsed")] + ParseFailure, +} + +impl From for EditError { + fn from(error: MetadataError) -> Self { + match error { + MetadataError::InvalidAlias => Self::InvalidAlias, + MetadataError::InvalidTimestamp => Self::InvalidTimestamp, + } + } +} + +impl ConfigDocument { + /// Add a peer using a caller-provided identity and validated domain client. + /// No key generation or filesystem/runtime access occurs here. + pub fn add_client( + &self, + client: &Client, + created_utc: Option<&str>, + ) -> Result { + add_client(self, client, created_utc) + } + + /// Delete the uniquely selected peer. Its header and directives are owned by + /// the peer; blank lines and administrator comments remain in their original order. + pub fn delete_client(&self, identity: &PublicKey) -> Result { + delete_client(self, identity) + } + + /// Rename a peer, inserting a standard block when it is a legacy peer. + pub fn rename_client(&self, identity: &PublicKey, alias: &str) -> Result { + rename_client(self, identity, alias) + } + + /// Explicitly add managed alias metadata to a legacy peer. + pub fn insert_legacy_metadata( + &self, + identity: &PublicKey, + alias: &str, + created_utc: Option<&str>, + ) -> Result { + insert_legacy_metadata(self, identity, alias, created_utc) + } + + /// Project one compatible peer and its metadata into the narrower M1 model. + pub fn project_client(&self, identity: &PublicKey) -> Result { + project_client(self, identity) + } +} + +pub fn add_client( + document: &ConfigDocument, + client: &Client, + created_utc: Option<&str>, +) -> Result { + ensure_document_safe(document)?; + validate_alias(&client.alias).map_err(|_| EditError::InvalidAlias)?; + if find_peer(document, client.identity).is_ok() { + return Err(EditError::DuplicateIdentity); + } + if !matches!( + find_peer(document, client.identity), + Err(EditError::MissingIdentity) + ) { + return Err(EditError::AmbiguousIdentity); + } + let block = render_block(&client.alias, created_utc)?; + let newline = line_ending(document.render()); + let mut source = + String::from_utf8(document.render().to_vec()).map_err(|_| EditError::ParseFailure)?; + if !source.is_empty() && !source.ends_with('\n') { + source.push_str(newline); + } + let block = block.replace('\n', newline); + source.push_str(&block); + source.push_str("[Peer]"); + source.push_str(newline); + source.push_str("PublicKey = "); + source.push_str(&client.identity.to_string()); + source.push_str(newline); + source.push_str("AllowedIPs = "); + source.push_str(&format!("{}/32", client.addresses.ipv4)); + if let Some(ipv6) = client.addresses.ipv6 { + source.push_str(", "); + source.push_str(&format!("{ipv6}/128")); + } + source.push_str(newline); + parse_edited(source) +} + +pub fn delete_client( + document: &ConfigDocument, + identity: &PublicKey, +) -> Result { + ensure_document_safe(document)?; + let peer = unique_peer(document, *identity)?; + ensure_peer_safe(peer)?; + let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?; + let mut output = String::with_capacity(source.len()); + let prefix_end = match &peer.metadata { + MetadataAttachment::Valid { block, .. } => block.start(), + MetadataAttachment::Absent => peer.section.start(), + MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer), + }; + output.push_str(&source[..prefix_end]); + let section = peer.section; + let section_text = &source[section.start()..section.end()]; + let mut offset = section.start(); + for raw in section_text.split_inclusive('\n') { + let content = raw + .strip_suffix('\n') + .unwrap_or(raw) + .strip_suffix('\r') + .unwrap_or(raw.strip_suffix('\n').unwrap_or(raw)); + let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace()); + let is_header = offset == section.start(); + let is_comment_or_blank = + trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';'); + if !is_header && is_comment_or_blank { + output.push_str(raw); + } + offset += raw.len(); + } + output.push_str(&source[section.end()..]); + let result = parse_edited(output)?; + if result + .peers() + .iter() + .any(|candidate| candidate.public_key_candidates.contains(identity)) + { + return Err(EditError::UnsafePeer); + } + Ok(result) +} + +pub fn rename_client( + document: &ConfigDocument, + identity: &PublicKey, + alias: &str, +) -> Result { + validate_alias(alias).map_err(|_| EditError::InvalidAlias)?; + let peer = unique_peer(document, *identity)?; + let created = match &peer.metadata { + MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(), + MetadataAttachment::Absent => None, + MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer), + }; + set_metadata(document, identity, alias, created) +} + +pub fn insert_legacy_metadata( + document: &ConfigDocument, + identity: &PublicKey, + alias: &str, + created_utc: Option<&str>, +) -> Result { + ensure_document_safe(document)?; + validate_alias(alias).map_err(|_| EditError::InvalidAlias)?; + let peer = unique_peer(document, *identity)?; + ensure_peer_safe(peer)?; + if !matches!(peer.metadata, MetadataAttachment::Absent) { + return Err(EditError::UnsafePeer); + } + set_metadata(document, identity, alias, created_utc) +} + +fn set_metadata( + document: &ConfigDocument, + identity: &PublicKey, + alias: &str, + created_utc: Option<&str>, +) -> Result { + ensure_document_safe(document)?; + validate_alias(alias).map_err(|_| EditError::InvalidAlias)?; + let peer = unique_peer(document, *identity)?; + ensure_peer_safe(peer)?; + let created = match &peer.metadata { + MetadataAttachment::Absent => created_utc, + MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(), + MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer), + }; + let rendered = render_block(alias, created)?; + let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?; + match &peer.metadata { + MetadataAttachment::Absent => { + let replacement = rendered.replace('\n', line_ending(document.render())); + let mut output = String::with_capacity(source.len() + replacement.len()); + let at = peer.section.start(); + output.push_str(&source[..at]); + output.push_str(&replacement); + output.push_str(&source[at..]); + parse_edited(output) + } + MetadataAttachment::Valid { block, .. } => { + let encoded_alias = rendered + .lines() + .nth(2) + .and_then(|line| line.strip_prefix("# wg-admin:alias=")) + .ok_or(EditError::ParseFailure)?; + let block_source = &source[block.start()..block.end()]; + let alias_line = block_source + .find("# wg-admin:alias=") + .ok_or(EditError::ParseFailure)?; + let value_start = block.start() + alias_line + "# wg-admin:alias=".len(); + let value_end = value_start + + source[value_start..block.end()] + .find(['\r', '\n']) + .ok_or(EditError::ParseFailure)?; + let mut output = String::with_capacity( + source.len() - (value_end - value_start) + encoded_alias.len(), + ); + output.push_str(&source[..value_start]); + output.push_str(encoded_alias); + output.push_str(&source[value_end..]); + parse_edited(output) + } + MetadataAttachment::Invalid(_) => Err(EditError::UnsafePeer), + } +} + +pub fn project_client( + document: &ConfigDocument, + identity: &PublicKey, +) -> Result { + let peer = unique_peer(document, *identity)?; + if peer + .issues + .iter() + .any(|issue| !is_address_shape_issue(issue)) + { + return Err(EditError::UnsafePeer); + } + let alias = match &peer.metadata { + MetadataAttachment::Valid { metadata, .. } => metadata.alias.clone(), + MetadataAttachment::Absent => return Err(EditError::MissingAlias), + MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer), + }; + if !peer.issues.is_empty() { + return Err(EditError::UnsupportedAddresses); + } + let mut ipv4 = None; + let mut ipv6 = None; + for route in &peer.allowed_ips { + match route { + IpNet::V4(network) if network.prefix_len() == 32 && ipv4.is_none() => { + ipv4 = Some(network.addr()); + } + IpNet::V6(network) if network.prefix_len() == 128 && ipv6.is_none() => { + ipv6 = Some(network.addr()); + } + _ => return Err(EditError::UnsupportedAddresses), + } + } + let ipv4 = ipv4.ok_or(EditError::UnsupportedAddresses)?; + Client::new(*identity, alias, ClientAddresses { ipv4, ipv6 }) + .map_err(|_| EditError::InvalidAlias) +} + +fn ensure_document_safe(document: &ConfigDocument) -> Result<(), EditError> { + if !document.issues().is_empty() { + return Err(EditError::UnsafeDocument); + } + Ok(()) +} + +fn unique_peer( + document: &ConfigDocument, + identity: PublicKey, +) -> Result<&PeerInspection, EditError> { + ensure_document_safe(document)?; + find_peer(document, identity) +} + +fn find_peer(document: &ConfigDocument, identity: PublicKey) -> Result<&PeerInspection, EditError> { + let matches: Vec<_> = document + .peers() + .iter() + .filter(|peer| peer.public_key_candidates.contains(&identity)) + .collect(); + match matches.as_slice() { + [] => Err(EditError::MissingIdentity), + [peer] + if peer.identity == Some(identity) + && !peer + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) => + { + Ok(peer) + } + _ => Err(EditError::AmbiguousIdentity), + } +} + +fn ensure_peer_safe(peer: &PeerInspection) -> Result<(), EditError> { + if peer + .issues + .iter() + .any(|issue| !is_address_shape_issue(issue)) + || matches!(peer.metadata, MetadataAttachment::Invalid(_)) + { + return Err(EditError::UnsafePeer); + } + Ok(()) +} + +fn is_address_shape_issue(issue: &PeerIssue) -> bool { + matches!( + issue, + PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute + ) +} + +fn parse_edited(source: String) -> Result { + ConfigDocument::parse(source.as_bytes()).map_err(|_| EditError::ParseFailure) +} + +fn line_ending(source: &[u8]) -> &'static str { + source + .windows(2) + .find(|window| *window == b"\r\n") + .map_or("\n", |_| "\r\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + use std::str::FromStr; + + const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; + + fn key(text: &str) -> PublicKey { + PublicKey::from_str(text).unwrap() + } + fn doc(source: &str) -> ConfigDocument { + ConfigDocument::parse(source).unwrap() + } + fn client(identity: &str, alias: &str) -> Client { + Client::new( + key(identity), + alias, + ClientAddresses { + ipv4: Ipv4Addr::new(10, 0, 0, 7), + ipv6: Some("fd00::7".parse().unwrap()), + }, + ) + .unwrap() + } + + #[test] + fn wireguard_add_emits_metadata_dual_stack_and_round_trips() { + let original = doc("[Interface]\nAddress = keep\n"); + let edited = original + .add_client(&client(KEY_A, "same alias"), Some("2026-10-18T19:00:00Z")) + .unwrap(); + assert!( + String::from_utf8_lossy(edited.render()) + .contains("AllowedIPs = 10.0.0.7/32, fd00::7/128") + ); + assert_eq!( + edited.project_client(&key(KEY_A)).unwrap().alias, + "same alias" + ); + assert!(original.peers().is_empty()); + } + + #[test] + fn wireguard_add_preserves_existing_bytes_and_allows_duplicate_aliases() { + let source = format!( + "# untouched\r\n[Interface]\r\nPrivateKey = hidden\r\n\r\n[Peer]\r\nPublicKey = {KEY_A}\r\nAllowedIPs = 10.0.0.1/32\r\n" + ); + let original = doc(&source); + let mut other = client(KEY_B, "repeated"); + other.addresses.ipv4 = Ipv4Addr::new(10, 0, 0, 8); + let managed = original + .insert_legacy_metadata(&key(KEY_A), "repeated", None) + .unwrap(); + let edited = managed.add_client(&other, None).unwrap(); + let rendered = String::from_utf8(edited.render().to_vec()).unwrap(); + let peer_header = source.find("[Peer]").unwrap(); + assert!(rendered.starts_with(&source[..peer_header])); + assert!(rendered.contains(&source[peer_header..])); + assert_eq!(edited.peers().len(), 2); + assert_eq!( + edited.project_client(&key(KEY_A)).unwrap().alias, + "repeated" + ); + assert_eq!( + edited.project_client(&key(KEY_B)).unwrap().alias, + "repeated" + ); + assert!(rendered.contains("\r\n# wg-admin:begin\r\n")); + } + + #[test] + fn wireguard_repeated_renames_reparse_spans_and_preserve_creation_time() { + let source = format!( + "# outside\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\nPresharedKey = secret\n" + ); + let original = doc(&source); + let first = original + .insert_legacy_metadata(&key(KEY_A), "first", Some("2024-02-29T12:00:00Z")) + .unwrap(); + let second = first.rename_client(&key(KEY_A), "second").unwrap(); + let third = second.rename_client(&key(KEY_A), "third").unwrap(); + let third_text = String::from_utf8(third.render().to_vec()).unwrap(); + assert_eq!(third.project_client(&key(KEY_A)).unwrap().alias, "third"); + assert_eq!(third_text.matches("wg-admin:begin").count(), 1); + assert!(third_text.contains("2024-02-29T12:00:00Z")); + assert!(third_text.starts_with("# outside\n")); + assert!(third_text.ends_with("PresharedKey = secret\n")); + assert_eq!(original.render(), source.as_bytes()); + } + + #[test] + fn wireguard_rename_and_legacy_insertion_preserve_date_and_unknown_directives() { + let source = format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\nPresharedKey = secret\n# admin\n" + ); + let original = doc(&source); + let added = original + .insert_legacy_metadata(&key(KEY_A), "old", Some("2024-01-02T03:04:05Z")) + .unwrap(); + let renamed = added.rename_client(&key(KEY_A), "new # café").unwrap(); + let view = renamed.project_client(&key(KEY_A)).unwrap(); + assert_eq!(view.alias, "new # café"); + assert!(String::from_utf8_lossy(renamed.render()).contains("2024-01-02T03:04:05Z")); + assert!( + String::from_utf8_lossy(renamed.render()).contains("PresharedKey = secret\n# admin\n") + ); + assert_eq!(original.render(), source.as_bytes()); + } + + #[test] + fn wireguard_delete_preserves_admin_and_neighbor_managed_comments() { + let block_a = render_block("one", None).unwrap(); + let block_b = render_block("two", None).unwrap(); + let source = format!( + "# before\n{block_a}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n# keep note\n{block_b}[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.2/32\n" + ); + let edited = doc(&source).delete_client(&key(KEY_A)).unwrap(); + let output = String::from_utf8(edited.render().to_vec()).unwrap(); + assert!(!output.contains(KEY_A)); + assert!(output.contains("# before\n")); + assert!(output.contains("# keep note\n")); + assert!(output.contains(&block_b)); + assert_eq!(edited.project_client(&key(KEY_B)).unwrap().alias, "two"); + } + + #[test] + fn wireguard_rejects_ambiguous_edits_and_unsupported_projection_without_mutation() { + let source = + format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32, 10.0.0.2/32\n"); + let original = doc(&source); + assert_eq!( + original.project_client(&key(KEY_A)), + Err(EditError::MissingAlias) + ); + assert!(matches!( + original.delete_client(&key(KEY_B)), + Err(EditError::MissingIdentity) + )); + let duplicate = doc(&format!( + "{source}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n" + )); + assert!(matches!( + duplicate.rename_client(&key(KEY_A), "renamed"), + Err(EditError::AmbiguousIdentity) + )); + assert_eq!(original.render(), source.as_bytes()); + } + + #[test] + fn wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes() { + let ipv4 = doc(&format!( + "{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n", + render_block("v4", None).unwrap() + )); + assert_eq!( + ipv4.project_client(&key(KEY_A)).unwrap().addresses.ipv6, + None + ); + let ipv6 = doc(&format!( + "{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::1/128\n", + render_block("v6", None).unwrap() + )); + assert_eq!( + ipv6.project_client(&key(KEY_A)), + Err(EditError::UnsupportedAddresses) + ); + let full = doc(&format!( + "{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 0.0.0.0/0\n", + render_block("full", None).unwrap() + )); + assert_eq!( + full.project_client(&key(KEY_A)), + Err(EditError::UnsupportedAddresses) + ); + } +} diff --git a/src/wireguard/metadata.rs b/src/wireguard/metadata.rs new file mode 100644 index 0000000..7888ab1 --- /dev/null +++ b/src/wireguard/metadata.rs @@ -0,0 +1,501 @@ +//! Versioned, comment-only metadata immediately attached to a WireGuard peer. +//! +//! The alias is encoded as canonical unpadded base64url of its UTF-8 bytes. This +//! keeps arbitrary valid aliases on one comment line without introducing config +//! syntax, comment termination, or line injection. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; + +use crate::domain::validate_alias; + +use super::SourceSpan; + +const BEGIN: &str = "# wg-admin:begin"; +const END: &str = "# wg-admin:end"; +const VERSION_PREFIX: &str = "# wg-admin:version="; +const ALIAS_PREFIX: &str = "# wg-admin:alias="; +const CREATED_PREFIX: &str = "# wg-admin:created-utc="; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManagedMetadata { + pub version: u32, + pub alias: String, + pub created_utc: Option, +} + +/// A safe half-open source span for a validated, uniquely attached block. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MetadataAttachment { + Absent, + Valid { + metadata: ManagedMetadata, + block: SourceSpan, + }, + Invalid(MetadataIssue), +} + +/// Redacted metadata problems. Variants identify a line, never include its text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MetadataIssue { + MalformedBlock { line: usize }, + UnsupportedVersion { line: usize }, + InvalidAlias { line: usize }, + InvalidTimestamp { line: usize }, + UnknownField { line: usize }, + MissingField { line: usize }, + DuplicateBlocks { line: usize, count: usize }, + Misattached { line: usize }, +} + +#[derive(Clone, Debug)] +struct Line<'a> { + content: &'a str, + start: usize, + end: usize, + number: usize, +} + +#[derive(Clone, Debug)] +struct Candidate { + start_line: usize, + end_line: usize, + span: SourceSpan, + issue: Option, +} + +/// Inspect all reserved marker pairs and attach blocks only when their final byte +/// is the peer header's first byte. Administrator comments outside this namespace +/// are ignored completely. +pub(super) fn inspect_document( + source: &str, + peer_headers: &[usize], +) -> (Vec, Vec) { + let lines = lines(source); + let mut candidates = Vec::new(); + let mut issues = Vec::new(); + let mut index = 0; + while index < lines.len() { + if marker(lines[index].content) != Some(Marker::Begin) { + if marker(lines[index].content) == Some(Marker::End) { + issues.push(MetadataIssue::MalformedBlock { + line: lines[index].number, + }); + } + index += 1; + continue; + } + let start = index; + let mut end = None; + let mut malformed = false; + let mut cursor = index + 1; + while cursor < lines.len() { + match marker(lines[cursor].content) { + Some(Marker::Begin) => { + malformed = true; + break; + } + Some(Marker::End) => { + end = Some(cursor); + break; + } + None => {} + } + cursor += 1; + } + let attached_peer = peer_headers.iter().find(|header| { + **header > lines[start].start + && (end.is_none() + || **header <= lines[cursor.min(lines.len().saturating_sub(1))].start) + }); + let block_end_line = end.unwrap_or_else(|| { + if malformed { + cursor + } else if let Some(header) = attached_peer { + lines + .partition_point(|line| line.start < *header) + .saturating_sub(1) + } else { + lines.len().saturating_sub(1) + } + }); + let span_end = if let Some(end_line) = end { + lines[end_line].end + } else if malformed { + lines[block_end_line].start + } else if let Some(header) = attached_peer { + *header + } else { + source.len() + }; + let Some(span) = SourceSpan::from_offsets(lines[start].start, span_end) else { + index += 1; + continue; + }; + let issue = if malformed || end.is_none() { + Some(MetadataIssue::MalformedBlock { + line: lines[start].number, + }) + } else { + parse_block(&lines[start..=block_end_line]).err() + }; + if let Some(issue) = &issue { + issues.push(issue.clone()); + } + candidates.push(Candidate { + start_line: start, + end_line: block_end_line, + span, + issue, + }); + if let Some(end) = end { + index = end + 1; + } else if malformed { + index = cursor; + } else { + break; + } + } + + let mut attachments = Vec::with_capacity(peer_headers.len()); + for header in peer_headers { + let ending_here: Vec<_> = candidates + .iter() + .enumerate() + .filter(|(_, candidate)| candidate.span.end() == *header) + .map(|(candidate, _)| candidate) + .collect(); + if ending_here.is_empty() { + attachments.push(MetadataAttachment::Absent); + continue; + } + let Some(last) = ending_here.last().copied() else { + attachments.push(MetadataAttachment::Absent); + continue; + }; + let mut contiguous = 1; + let mut previous = last; + while previous > 0 + && candidates[previous - 1].span.end() == candidates[previous].span.start() + { + contiguous += 1; + previous -= 1; + } + let candidate = &candidates[last]; + if contiguous > 1 { + let issue = MetadataIssue::DuplicateBlocks { + line: lines[candidate.start_line].number, + count: contiguous, + }; + issues.push(issue.clone()); + attachments.push(MetadataAttachment::Invalid(issue)); + } else if let Some(issue) = &candidate.issue { + attachments.push(MetadataAttachment::Invalid(issue.clone())); + } else { + let parsed = parse_block(&lines[candidate.start_line..=candidate.end_line]); + match parsed { + Ok(metadata) => attachments.push(MetadataAttachment::Valid { + metadata, + block: candidate.span, + }), + Err(issue) => { + attachments.push(MetadataAttachment::Invalid(issue)); + } + } + } + } + + for candidate in &candidates { + if !peer_headers.contains(&candidate.span.end()) { + let issue = MetadataIssue::Misattached { + line: lines[candidate.start_line].number, + }; + issues.push(issue); + } + } + (attachments, issues) +} + +/// Render the canonical LF-terminated v1 block. The caller may adapt line endings +/// when inserting it into a document; this function performs no source mutation. +pub fn render_block(alias: &str, created_utc: Option<&str>) -> Result { + validate_alias(alias).map_err(|_| MetadataError::InvalidAlias)?; + if let Some(timestamp) = created_utc + && !valid_utc_timestamp(timestamp) + { + return Err(MetadataError::InvalidTimestamp); + } + let encoded = URL_SAFE_NO_PAD.encode(alias.as_bytes()); + let mut block = format!("{BEGIN}\n{VERSION_PREFIX}1\n{ALIAS_PREFIX}{encoded}\n"); + if let Some(timestamp) = created_utc { + block.push_str(CREATED_PREFIX); + block.push_str(timestamp); + block.push('\n'); + } + block.push_str(END); + block.push('\n'); + Ok(block) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum MetadataError { + #[error("client alias is invalid")] + InvalidAlias, + #[error("creation timestamp must be a valid UTC timestamp")] + InvalidTimestamp, +} + +fn parse_block(lines: &[Line<'_>]) -> Result { + let first_line = lines.first().map_or(1, |line| line.number); + if lines.len() < 4 || lines[0].content != BEGIN || lines[lines.len() - 1].content != END { + return Err(MetadataIssue::MalformedBlock { line: first_line }); + } + let version_line = &lines[1]; + let Some(version_text) = version_line.content.strip_prefix(VERSION_PREFIX) else { + return Err(MetadataIssue::MissingField { line: first_line }); + }; + if version_text.is_empty() || !version_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(MetadataIssue::MalformedBlock { + line: version_line.number, + }); + } + let version = version_text + .parse::() + .map_err(|_| MetadataIssue::MalformedBlock { + line: version_line.number, + })?; + if version != 1 { + return Err(MetadataIssue::UnsupportedVersion { + line: version_line.number, + }); + } + let alias_line = &lines[2]; + let Some(encoded_alias) = alias_line.content.strip_prefix(ALIAS_PREFIX) else { + return Err(MetadataIssue::MissingField { + line: alias_line.number, + }); + }; + let decoded = + URL_SAFE_NO_PAD + .decode(encoded_alias) + .map_err(|_| MetadataIssue::InvalidAlias { + line: alias_line.number, + })?; + if URL_SAFE_NO_PAD.encode(&decoded) != encoded_alias { + return Err(MetadataIssue::InvalidAlias { + line: alias_line.number, + }); + } + let alias = String::from_utf8(decoded).map_err(|_| MetadataIssue::InvalidAlias { + line: alias_line.number, + })?; + validate_alias(&alias).map_err(|_| MetadataIssue::InvalidAlias { + line: alias_line.number, + })?; + let created_lines = &lines[3..lines.len() - 1]; + let created_utc = match created_lines { + [] => None, + [line] => { + let Some(value) = line.content.strip_prefix(CREATED_PREFIX) else { + return Err(if line.content.starts_with("# wg-admin:") { + MetadataIssue::UnknownField { line: line.number } + } else { + MetadataIssue::MalformedBlock { line: line.number } + }); + }; + if !valid_utc_timestamp(value) { + return Err(MetadataIssue::InvalidTimestamp { line: line.number }); + } + Some(value.to_owned()) + } + [line, ..] => { + return Err(if line.content.starts_with("# wg-admin:") { + MetadataIssue::UnknownField { line: line.number } + } else { + MetadataIssue::MalformedBlock { line: line.number } + }); + } + }; + Ok(ManagedMetadata { + version, + alias, + created_utc, + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Marker { + Begin, + End, +} + +fn marker(line: &str) -> Option { + if line == BEGIN + || line.starts_with(&format!("{BEGIN} ")) + || line.starts_with(&format!("{BEGIN}\t")) + { + Some(Marker::Begin) + } else if line == END + || line.starts_with(&format!("{END} ")) + || line.starts_with(&format!("{END}\t")) + { + Some(Marker::End) + } else { + None + } +} + +fn lines(source: &str) -> Vec> { + let mut result = Vec::new(); + let mut offset = 0; + for (index, raw) in source.split_inclusive('\n').enumerate() { + let end = offset + raw.len(); + let content = raw.strip_suffix('\n').unwrap_or(raw); + let content = content.strip_suffix('\r').unwrap_or(content); + result.push(Line { + content, + start: offset, + end, + number: index + 1, + }); + offset = end; + } + if source.is_empty() { + return result; + } + result +} + +fn valid_utc_timestamp(value: &str) -> bool { + if value.len() != 20 + || !value.is_ascii() + || value.as_bytes()[4] != b'-' + || value.as_bytes()[7] != b'-' + || value.as_bytes()[10] != b'T' + || value.as_bytes()[13] != b':' + || value.as_bytes()[16] != b':' + || value.as_bytes()[19] != b'Z' + { + return false; + } + let digits = |range: std::ops::Range| value[range].parse::().ok(); + let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = ( + digits(0..4), + digits(5..7), + digits(8..10), + digits(11..13), + digits(14..16), + digits(17..19), + ) else { + return false; + }; + if year == 0 || !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 { + return false; + } + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + (1..=days).contains(&day) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wireguard_metadata_round_trips_aliases_and_optional_timestamp() { + let alias = "# café = \"laptop\" "; + for timestamp in [None, Some("2024-02-29T23:59:59Z")] { + let rendered = render_block(alias, timestamp).unwrap(); + let header = rendered.len(); + let (attachments, issues) = inspect_document( + &format!( + "{rendered}[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" + ), + &[header], + ); + assert!(issues.is_empty()); + let MetadataAttachment::Valid { metadata, block } = &attachments[0] else { + panic!("canonical metadata should inspect successfully"); + }; + assert_eq!(metadata.alias, alias); + assert_eq!(metadata.created_utc.as_deref(), timestamp); + assert_eq!(block.end(), header); + assert_eq!(block.start(), 0); + } + } + + #[test] + fn wireguard_metadata_rejects_control_aliases_and_invalid_utc_dates() { + assert_eq!( + render_block("bad\n# wg-admin:end", None), + Err(MetadataError::InvalidAlias) + ); + for timestamp in [ + "2023-02-29T12:00:00Z", + "2024-13-01T12:00:00Z", + "2024-01-01T24:00:00Z", + "2024-01-01T12:00:60Z", + "2024-01-01T12:00:00+00:00", + ] { + assert_eq!( + render_block("ok", Some(timestamp)), + Err(MetadataError::InvalidTimestamp) + ); + } + } + + #[test] + fn wireguard_metadata_reports_unsupported_and_misattached_blocks_redacted() { + let source = "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2s\n# wg-admin:end\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"; + let header = source.find("[Peer]").unwrap(); + let (attachments, issues) = inspect_document(source, &[header]); + assert!(matches!(attachments[0], MetadataAttachment::Absent)); + assert!( + issues + .iter() + .any(|issue| matches!(issue, MetadataIssue::Misattached { .. })) + ); + assert!( + issues + .iter() + .any(|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. })) + ); + assert!(!format!("{issues:?}").contains("b2s")); + } + + #[test] + fn wireguard_metadata_missing_end_marker_still_attaches_as_invalid() { + let source = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2s\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"; + let header = source.find("[Peer]").unwrap(); + let (attachments, _) = inspect_document(source, &[header]); + assert!(matches!( + attachments[0], + MetadataAttachment::Invalid(MetadataIssue::MalformedBlock { .. }) + )); + } + + #[test] + fn wireguard_metadata_ignores_unrelated_admin_comments_and_rejects_duplicate_blocks() { + let first = render_block("one", None).unwrap(); + let second = render_block("two", None).unwrap(); + let source = format!( + "# administrator note: wg-admin is not mentioned as an owner\n{first}{second}[Peer]\n" + ); + let header = source.find("[Peer]").unwrap(); + let (attachments, _) = inspect_document(&source, &[header]); + assert!(matches!( + attachments[0], + MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. }) + )); + let source = "# administrator note: wg-admin is not mentioned as an owner\n[Peer]\n"; + let header = source.find("[Peer]").unwrap(); + let (attachments, issues) = inspect_document(source, &[header]); + assert!(matches!(attachments[0], MetadataAttachment::Absent)); + assert!(issues.is_empty()); + } +} diff --git a/tests/config_preservation.rs b/tests/config_preservation.rs new file mode 100644 index 0000000..3e6a9c3 --- /dev/null +++ b/tests/config_preservation.rs @@ -0,0 +1,395 @@ +use std::net::Ipv4Addr; +use std::str::FromStr; + +use wireguard_admin::domain::{Client, ClientAddresses, PublicKey}; +use wireguard_admin::wireguard::{ + ConfigDocument, DocumentIssue, EditError, MetadataAttachment, MetadataIssue, PeerIssue, +}; + +const FIXTURE: &str = include_str!("fixtures/wireguard/representative.conf"); +const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; +const KEY_C: &str = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="; +const KEY_D: &str = "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="; +const SECRET_MARKERS: [&str; 3] = [ + "SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY", + "SYNTHETIC-PLACEHOLDER-PEER-PSK-A", + "SYNTHETIC-PLACEHOLDER-PEER-PSK-B", +]; +type MetadataCase = (&'static str, &'static str, fn(&MetadataIssue) -> bool); + +fn key(text: &str) -> PublicKey { + PublicKey::from_str(text).expect("synthetic public key is valid") +} + +fn client(public_key: &str, alias: &str, last_octet: u8) -> Client { + Client::new( + key(public_key), + alias, + ClientAddresses { + ipv4: Ipv4Addr::new(10, 44, 0, last_octet), + ipv6: None, + }, + ) + .expect("test alias is valid") +} + +fn text(document: &ConfigDocument) -> &str { + std::str::from_utf8(document.render()).expect("fixture is UTF-8") +} + +fn assert_secret_free_debug(value: &T) { + let debug = format!("{value:?}"); + for marker in SECRET_MARKERS { + assert!( + !debug.contains(marker), + "Debug leaked a synthetic secret marker" + ); + } +} + +#[test] +fn round_trip_preserves_lf_crlf_mixed_and_missing_final_newline_inputs() { + let lf = FIXTURE.as_bytes().to_vec(); + let crlf = FIXTURE.replace('\n', "\r\n").into_bytes(); + let mixed = FIXTURE + .lines() + .enumerate() + .flat_map(|(line, value)| { + let ending = if line % 3 == 1 { "\r\n" } else { "\n" }; + format!("{value}{ending}").into_bytes() + }) + .collect::>(); + let mut no_final_newline = lf.clone(); + while no_final_newline.last() == Some(&b'\n') || no_final_newline.last() == Some(&b'\r') { + no_final_newline.pop(); + } + + for (label, source) in [ + ("LF", lf), + ("CRLF", crlf), + ("mixed", mixed), + ("no final newline", no_final_newline), + ] { + let parsed = ConfigDocument::parse(&source).expect("synthetic UTF-8 parses"); + assert!( + parsed.render() == source.as_slice(), + "{label} round trip changed bytes" + ); + assert_eq!(parsed.peers().len(), 2, "{label} lost a peer"); + } +} + +#[test] +fn edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed() { + let original = ConfigDocument::parse(FIXTURE).unwrap(); + let original_text = text(&original).to_owned(); + let interface_end = original_text.find("\n\n# Admin note").unwrap(); + let expected_interface = &original_text[..interface_end + 1]; + let with_metadata = original + .insert_legacy_metadata(&key(KEY_A), "old", Some("2024-02-29T12:30:00Z")) + .unwrap(); + let renamed = with_metadata + .rename_client(&key(KEY_A), "new # = \"café\"") + .unwrap(); + let result = text(&renamed); + assert!( + result.starts_with(expected_interface), + "interface bytes changed" + ); + assert!(result.contains("# Another administrator note.\n")); + assert!(result.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A\n")); + assert!(result.contains("CustomPeerOption = keep-me\n")); + assert!(result.contains("# wg-admin:version=1\n")); + assert!(result.contains("# wg-admin:created-utc=2024-02-29T12:30:00Z\n")); + assert_eq!( + renamed.project_client(&key(KEY_A)).unwrap().alias, + "new # = \"café\"" + ); + assert_eq!( + ConfigDocument::parse(renamed.render()).unwrap().render(), + renamed.render() + ); + + let deleted = renamed.delete_client(&key(KEY_A)).unwrap(); + let deleted_text = text(&deleted); + assert!( + deleted_text.starts_with(expected_interface), + "delete changed interface bytes" + ); + assert!(!deleted_text.contains(KEY_A)); + assert!(deleted_text.contains("# Admin note: retained between interface and peer.\n")); + assert!(deleted_text.contains("# Another administrator note.\n")); + assert!(deleted_text.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B\n")); + assert_eq!(deleted.peers().len(), 1); + assert_eq!(deleted.peers()[0].identity, Some(key(KEY_B))); +} + +#[test] +fn rename_in_mixed_ending_metadata_changes_only_alias_representation() { + let source = format!( + "# admin preface\r\n# wg-admin:begin\n# wg-admin:version=1\r\n# wg-admin:alias=b2xk\n# wg-admin:created-utc=2024-01-02T03:04:05Z\r\n# wg-admin:end\n[Peer]\r\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\r\nPresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A" + ); + let original = ConfigDocument::parse(&source).unwrap(); + let renamed = original.rename_client(&key(KEY_A), "new").unwrap(); + let expected = source.replace("# wg-admin:alias=b2xk", "# wg-admin:alias=bmV3"); + assert!( + text(&renamed) == expected, + "rename must replace only alias bytes" + ); + assert_eq!(renamed.project_client(&key(KEY_A)).unwrap().alias, "new"); +} + +#[test] +fn add_delete_and_repeated_edits_recompute_spans_without_touching_neighbors() { + let source = "[Interface]\nListenPort = 51820\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.44.0.2/32\n\n# inter-peer note\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.44.0.3/32\n\n[Peer]\nPublicKey = AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\nAllowedIPs = 10.44.0.4/32"; + let original = ConfigDocument::parse(source).unwrap(); + let added = original + .add_client(&client(KEY_D, "duplicate alias", 9), None) + .unwrap(); + assert!( + text(&added).starts_with(source), + "add changed existing source bytes" + ); + let with_duplicate_aliases = added + .insert_legacy_metadata(&key(KEY_A), "shared alias", None) + .unwrap() + .insert_legacy_metadata(&key(KEY_B), "shared alias", None) + .unwrap(); + assert_eq!( + with_duplicate_aliases + .project_client(&key(KEY_A)) + .unwrap() + .alias, + with_duplicate_aliases + .project_client(&key(KEY_B)) + .unwrap() + .alias + ); + + let delete_first = original.delete_client(&key(KEY_A)).unwrap(); + assert!(text(&delete_first).contains("# inter-peer note\n")); + assert!(text(&delete_first).contains(KEY_B)); + assert!(text(&delete_first).contains(KEY_C)); + let delete_last = delete_first.delete_client(&key(KEY_C)).unwrap(); + assert!(text(&delete_last).contains(KEY_B)); + assert!(!text(&delete_last).contains(KEY_C)); + let delete_only_peer = delete_last.delete_client(&key(KEY_B)).unwrap(); + assert!(delete_only_peer.peers().is_empty()); + + let twice_renamed = added + .rename_client(&key(KEY_D), "#marker = \"café\"") + .unwrap() + .rename_client(&key(KEY_D), "duplicate alias") + .unwrap(); + assert_eq!( + twice_renamed.project_client(&key(KEY_D)).unwrap().alias, + "duplicate alias" + ); + assert_eq!(text(&twice_renamed).matches("[Peer]").count(), 4); + assert!(text(&twice_renamed).contains("# inter-peer note\n")); +} + +#[test] +fn metadata_issues_are_preserved_and_unsafe_edits_fail_without_mutation() { + let cases: [MetadataCase; 3] = [ + ( + "unknown version", + "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2xk\n# wg-admin:end\n", + |issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }), + ), + ( + "unknown field", + "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:future=x\n# wg-admin:end\n", + |issue| matches!(issue, MetadataIssue::UnknownField { .. }), + ), + ( + "duplicate field", + "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:alias=b3RoZXI\n# wg-admin:end\n", + |issue| matches!(issue, MetadataIssue::UnknownField { .. }), + ), + ]; + for (label, block, matches_issue) in cases { + let source = format!("{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"); + let parsed = ConfigDocument::parse(&source).unwrap(); + assert!( + matches!(&parsed.peers()[0].metadata, MetadataAttachment::Invalid(issue) if matches_issue(issue)), + "{label} not reported" + ); + assert!(matches!( + parsed.rename_client(&key(KEY_A), "safe"), + Err(EditError::UnsafeDocument | EditError::UnsafePeer) + )); + assert!( + parsed.render() == source.as_bytes(), + "{label} rejection mutated source" + ); + } + + let malformed = format!( + "# wg-admin:begin \n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" + ); + let parsed = ConfigDocument::parse(&malformed).unwrap(); + assert!(!parsed.issues().is_empty()); + assert!(parsed.rename_client(&key(KEY_A), "safe").is_err()); + assert!( + parsed.render() == malformed.as_bytes(), + "malformed input bytes changed" + ); + + let misattached = format!( + "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n# admin intervenes\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" + ); + let parsed = ConfigDocument::parse(&misattached).unwrap(); + assert!(parsed.issues().iter().any(|issue| matches!( + issue, + DocumentIssue::ManagedMetadata(MetadataIssue::Misattached { .. }) + ))); + assert!(parsed.rename_client(&key(KEY_A), "safe").is_err()); + assert!( + parsed.render() == misattached.as_bytes(), + "misattached input bytes changed" + ); +} + +#[test] +fn duplicate_adjacent_metadata_blocks_and_ambiguous_peers_remain_visible() { + let block = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b25l\n# wg-admin:end\n"; + let duplicate_blocks = + format!("{block}{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"); + let parsed = ConfigDocument::parse(&duplicate_blocks).unwrap(); + assert!(matches!( + &parsed.peers()[0].metadata, + MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. }) + )); + assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err()); + assert!( + parsed.render() == duplicate_blocks.as_bytes(), + "duplicate blocks changed" + ); + + let ambiguous = format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\nPublicKey = {KEY_B}\n\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.3/32\n" + ); + let parsed = ConfigDocument::parse(&ambiguous).unwrap(); + assert!( + parsed.peers()[0] + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::DuplicatePublicKeyField { .. })) + ); + assert!( + parsed.peers()[1] + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) + ); + assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err()); + assert!( + parsed.render() == ambiguous.as_bytes(), + "ambiguous source bytes changed" + ); +} + +#[test] +fn aliases_encode_unicode_and_syntax_and_reject_control_injection() { + for alias in ["# café = \"laptop\"", "# wg-admin:begin", "x=y # z"] { + let document = ConfigDocument::parse(format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" + )) + .unwrap() + .insert_legacy_metadata(&key(KEY_A), alias, None) + .unwrap(); + assert_eq!(document.project_client(&key(KEY_A)).unwrap().alias, alias); + assert!(text(&document).contains("# wg-admin:alias=")); + let rendered_alias = text(&document) + .lines() + .find(|line| line.starts_with("# wg-admin:alias=")) + .expect("metadata has an alias line"); + assert!(!rendered_alias.contains(alias)); + } + + let original = ConfigDocument::parse(format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" + )) + .unwrap(); + for alias in ["line\nbreak", "carriage\rreturn", "terminal\u{001b}escape"] { + assert!(matches!( + original.insert_legacy_metadata(&key(KEY_A), alias, None), + Err(EditError::InvalidAlias) + )); + assert_eq!(original.render().last(), Some(&b'\n')); + } +} + +#[test] +fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection_fails() { + let cases = [ + ("multiple host routes", "10.44.0.2/32, 10.44.0.22/32"), + ("network route", "10.44.0.0/24"), + ("default route", "0.0.0.0/0"), + ("IPv6 only", "fd42::2/128"), + ]; + for (label, routes) in cases { + let source = format!( + "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = {routes}\n" + ); + let parsed = ConfigDocument::parse(&source).unwrap(); + let peer = &parsed.peers()[0]; + assert!( + parsed.render() == source.as_bytes(), + "{label} round trip changed bytes" + ); + match label { + "multiple host routes" => assert!(peer.issues.iter().any(|issue| matches!( + issue, + PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 } + ))), + "network route" | "default route" => assert!(peer.issues.iter().any(|issue| matches!( + issue, + PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 0 } + ))), + "IPv6 only" => assert!(peer.issues.contains(&PeerIssue::MissingIpv4HostRoute)), + _ => unreachable!("test case has an expected route shape"), + } + let issue_debug = format!("{:?}", peer.issues); + assert!( + !issue_debug.contains(routes), + "{label} issue exposed route text" + ); + assert_eq!( + parsed.project_client(&key(KEY_A)), + Err(EditError::UnsupportedAddresses), + "{label} projection" + ); + let renamed = parsed.rename_client(&key(KEY_A), "updated").unwrap(); + let expected = source.replace("# wg-admin:alias=Y2xpZW50", "# wg-admin:alias=dXBkYXRlZA"); + assert_eq!( + text(&renamed), + expected, + "{label} rename changed other bytes" + ); + } +} + +#[test] +fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() { + let document = ConfigDocument::parse(FIXTURE).unwrap(); + assert_secret_free_debug(&document); + assert_secret_free_debug(&document.peers()); + let duplicate_identity = document + .add_client(&client(KEY_A, "duplicate", 9), None) + .unwrap_err(); + assert_secret_free_debug(&duplicate_identity); + let invalid = ConfigDocument::parse(b"[Interface]\nPrivateKey = SYNTHETIC\n\xff").unwrap_err(); + assert_secret_free_debug(&invalid); + assert!(!invalid.to_string().contains("SYNTHETIC")); + + let original_bytes = document.render().to_vec(); + assert!(document.rename_client(&key(KEY_C), "not present").is_err()); + assert!(document.delete_client(&key(KEY_C)).is_err()); + assert!( + document.render() == original_bytes.as_slice(), + "failed edits changed source bytes" + ); +} diff --git a/tests/fixtures/wireguard/representative.conf b/tests/fixtures/wireguard/representative.conf new file mode 100644 index 0000000..698b253 --- /dev/null +++ b/tests/fixtures/wireguard/representative.conf @@ -0,0 +1,22 @@ +# Synthetic server configuration for byte-preservation tests. +[Interface] +PrivateKey = SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY +Address = 10.44.0.1/24, fd42::1/64 +ListenPort = 51820 +PostUp = iptables -A FORWARD -i wg0 -j ACCEPT +UnknownInterfaceDirective = keep exactly = this + +# Admin note: retained between interface and peer. +[Peer] +PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +AllowedIPs = 10.44.0.2/32 +PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A +PersistentKeepalive = 25 +CustomPeerOption = keep-me + +# Another administrator note. +[Peer] +PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= +AllowedIPs = 10.44.0.3/32, fd42::3/128 +PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B +CustomPeerOption = also-keep-me