Add server configuration editing with byte preservation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Managed metadata in the WireGuard configuration
|
||||
|
||||
WireGuard Admin stores a client's display alias and optional creation time in a
|
||||
versioned comment block immediately before that client's `[Peer]` header. The
|
||||
server configuration remains the source of truth. The block contains no public
|
||||
key copy, private key, preshared key, password, or other connection secret.
|
||||
|
||||
## Version 1 grammar
|
||||
|
||||
The canonical LF form is:
|
||||
|
||||
```ini
|
||||
# wg-admin:begin
|
||||
# wg-admin:version=1
|
||||
# wg-admin:alias=<base64url-utf8-without-padding>
|
||||
# wg-admin:created-utc=2026-10-18T19:00:00Z
|
||||
# wg-admin:end
|
||||
[Peer]
|
||||
```
|
||||
|
||||
`created-utc` is optional. If it is unknown, it is omitted; an existing peer's
|
||||
unknown creation date is never inferred from import time, file time, or edit time.
|
||||
The timestamp grammar is exactly `YYYY-MM-DDTHH:MM:SSZ`, with a real Gregorian
|
||||
calendar date, a four-digit nonzero year, and seconds from `00` through `59`.
|
||||
Leap seconds and fractional seconds are not represented.
|
||||
|
||||
The alias line contains the canonical URL-safe Base64 encoding of the alias's
|
||||
UTF-8 bytes, with no `=` padding. This representation is a single comment line
|
||||
and makes characters such as `#`, `=`, quotes, non-ASCII text, and strings that
|
||||
look like block markers unambiguous. An alias must pass the application's shared
|
||||
alias validation: it cannot be empty or contain Unicode control characters.
|
||||
The encoded representation is decoded and re-encoded canonically when read.
|
||||
|
||||
The v1 block has exactly one begin marker, one version field, one alias field,
|
||||
optional single creation field, and one end marker, in the displayed order. No
|
||||
other lines or fields are permitted. Unknown fields, duplicate fields, malformed
|
||||
encoding, or invalid timestamps make the block invalid. A version other than 1
|
||||
is reported as unsupported and is not interpreted or rewritten.
|
||||
|
||||
## Ownership and attachment
|
||||
|
||||
The exact reserved marker lines are `# wg-admin:begin` and `# wg-admin:end`.
|
||||
Marker lines with trailing whitespace or extra text are treated as malformed
|
||||
reserved markers. Other administrator comments, including comments that merely
|
||||
mention `wg-admin`, do not become managed blocks.
|
||||
|
||||
A valid block owns only its own comment lines and line endings. It is attached to
|
||||
one peer only when its end marker line, including its line ending, ends at the
|
||||
first byte of that peer's `[Peer]` header. No blank line, administrator comment,
|
||||
or other text may intervene. A block elsewhere is reported as misattached. A
|
||||
missing block is the legacy state: no alias or creation date is inferred, and no
|
||||
block span is available to edit. Multiple adjacent blocks before one peer are a
|
||||
duplicate and block metadata edits. Malformed, misattached, duplicate, and
|
||||
unsupported-version blocks are retained as supplied and block unsafe metadata
|
||||
edits; they are not treated as absent metadata.
|
||||
|
||||
The parser exposes the decoded alias only for a valid, uniquely attached v1
|
||||
block. It exposes an owned byte span only for that valid block. Issues contain
|
||||
line numbers and categories but never raw source text. Parsing and rendering are
|
||||
caller-buffer operations and do not read or write configuration files.
|
||||
|
||||
## Examples
|
||||
|
||||
An alias containing syntax characters and Unicode is encoded, not escaped in
|
||||
WireGuard syntax. For example, the alias `# café = "laptop"` is represented as:
|
||||
|
||||
```ini
|
||||
# wg-admin:begin
|
||||
# wg-admin:version=1
|
||||
# wg-admin:alias=IyBjYWbDqSA9ICJsYXB0b3Ai
|
||||
# wg-admin:end
|
||||
[Peer]
|
||||
```
|
||||
|
||||
A marker-looking alias is handled the same way. Applications should use the
|
||||
canonical renderer rather than hand-constructing the encoded value.
|
||||
|
||||
An existing client with unknown creation time has no `created-utc` field:
|
||||
|
||||
```ini
|
||||
# wg-admin:begin
|
||||
# wg-admin:version=1
|
||||
# wg-admin:alias=V29ya2xhcGU
|
||||
# wg-admin:end
|
||||
[Peer]
|
||||
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
```
|
||||
|
||||
An unrelated administrator comment is preserved and does not describe managed
|
||||
metadata:
|
||||
|
||||
```ini
|
||||
# Added during the 2024 network migration; wg-admin is not the owner of this note.
|
||||
[Peer]
|
||||
PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=
|
||||
AllowedIPs = 10.0.0.3/32
|
||||
```
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod startup;
|
||||
pub mod wireguard;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Pure parsing and inspection of caller-supplied WireGuard server configuration.
|
||||
//!
|
||||
//! Parsing never reads or writes files and never changes runtime state.
|
||||
|
||||
pub mod document;
|
||||
pub mod edit;
|
||||
pub mod metadata;
|
||||
|
||||
pub use document::{
|
||||
ConfigDocument, DirectiveSpan, DocumentError, DocumentIssue, DocumentSection, PeerInspection,
|
||||
PeerIssue, SectionKind, SourceSpan,
|
||||
};
|
||||
pub use edit::{
|
||||
EditError, add_client, delete_client, insert_legacy_metadata, project_client, rename_client,
|
||||
};
|
||||
pub use metadata::{ManagedMetadata, MetadataAttachment, MetadataError, MetadataIssue};
|
||||
@@ -0,0 +1,630 @@
|
||||
//! Lossless byte-span representation and redacted public inspection.
|
||||
//!
|
||||
//! The supported input encoding is UTF-8. Input that is not valid UTF-8 is
|
||||
//! rejected, without including any source bytes in the error. The document
|
||||
//! retains the exact input bytes, including mixed line endings and a missing
|
||||
//! final newline. Public views expose only section/key spans, peer public keys,
|
||||
//! and parsed AllowedIPs; no arbitrary directive values are exposed because
|
||||
//! they may contain secrets.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use ipnet::IpNet;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::PublicKey;
|
||||
|
||||
use super::metadata::{self, MetadataAttachment, MetadataIssue};
|
||||
|
||||
/// A half-open byte range into the original UTF-8 input.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SourceSpan {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl SourceSpan {
|
||||
/// Construct a span from byte offsets. Returns `None` when the end precedes the start.
|
||||
pub const fn from_offsets(start: usize, end: usize) -> Option<Self> {
|
||||
if start <= end {
|
||||
Some(Self { start, end })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn start(self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
pub const fn end(self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
pub const fn len(self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// The section type recognized from a bracketed section header.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SectionKind {
|
||||
Interface,
|
||||
Peer,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A recognized section and its original byte boundaries.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct DocumentSection {
|
||||
pub kind: SectionKind,
|
||||
/// Header name after trimming surrounding ASCII whitespace.
|
||||
pub name: String,
|
||||
/// Includes the header and section body, ending immediately before the next header.
|
||||
pub span: SourceSpan,
|
||||
/// Header line only, excluding its original line ending.
|
||||
pub header_span: SourceSpan,
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DocumentSection {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DocumentSection")
|
||||
.field("kind", &self.kind)
|
||||
.field("span", &self.span)
|
||||
.field("header_span", &self.header_span)
|
||||
.field("line", &self.line)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Spans of a recognized key/value directive; values remain private to the document.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DirectiveSpan {
|
||||
pub line: usize,
|
||||
pub whole: SourceSpan,
|
||||
pub key: SourceSpan,
|
||||
pub value: SourceSpan,
|
||||
}
|
||||
|
||||
/// A public, secret-safe inspection of one `[Peer]` section.
|
||||
///
|
||||
/// Conversion to the narrow `domain::Client` model is intentionally deferred
|
||||
/// to the M2.3 adapter: this view has no alias until managed metadata is parsed.
|
||||
/// That conversion must require one unambiguous identity and route shapes that
|
||||
/// fit `ClientAddresses`; it must reject rather than truncate other AllowedIPs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PeerInspection {
|
||||
pub index: usize,
|
||||
pub section: SourceSpan,
|
||||
pub section_line: usize,
|
||||
/// Present only when this section has exactly one valid PublicKey directive.
|
||||
pub identity: Option<PublicKey>,
|
||||
/// Every valid public key value, even when the section is ambiguous.
|
||||
pub public_key_candidates: Vec<PublicKey>,
|
||||
/// Every PublicKey directive is retained as a span, including duplicates and malformed values.
|
||||
pub public_key_fields: Vec<DirectiveSpan>,
|
||||
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
|
||||
pub allowed_ips: Vec<IpNet>,
|
||||
pub allowed_ips_fields: Vec<DirectiveSpan>,
|
||||
/// Parsed alias and safe owned block span, or an explicit metadata issue.
|
||||
pub metadata: MetadataAttachment,
|
||||
pub issues: Vec<PeerIssue>,
|
||||
}
|
||||
|
||||
/// Issues found while inspecting a peer. Variants contain no source text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PeerIssue {
|
||||
MissingPublicKey,
|
||||
MalformedPublicKey { field: usize },
|
||||
DuplicatePublicKeyField { count: usize },
|
||||
DuplicatePeerIdentity { other_peer_indices: Vec<usize> },
|
||||
MalformedAllowedIp { field: usize, item: usize },
|
||||
UnsupportedAllowedIpShape { field: usize, item: usize },
|
||||
MissingIpv4HostRoute,
|
||||
}
|
||||
|
||||
/// Structural issues found outside peer-level inspection.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DocumentIssue {
|
||||
MalformedSectionHeader { line: usize },
|
||||
DirectiveOutsideSection { line: usize },
|
||||
MalformedDirective { line: usize },
|
||||
ManagedMetadata(MetadataIssue),
|
||||
}
|
||||
|
||||
/// A document-level parse failure. It intentionally never contains input bytes or text.
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum DocumentError {
|
||||
#[error("server configuration is not valid UTF-8")]
|
||||
InvalidEncoding,
|
||||
#[error("server configuration could not be inspected safely")]
|
||||
InvalidStructure,
|
||||
}
|
||||
|
||||
/// Parsed view over the exact caller-supplied configuration bytes.
|
||||
pub struct ConfigDocument {
|
||||
source: String,
|
||||
sections: Vec<DocumentSection>,
|
||||
peers: Vec<PeerInspection>,
|
||||
issues: Vec<DocumentIssue>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ConfigDocument {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ConfigDocument")
|
||||
.field("source_len", &self.source.len())
|
||||
.field("sections", &self.sections)
|
||||
.field("peers", &self.peers)
|
||||
.field("issues", &self.issues)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigDocument {
|
||||
/// Parse UTF-8 configuration supplied by the caller while retaining every source byte.
|
||||
pub fn parse(source: impl AsRef<[u8]>) -> Result<Self, DocumentError> {
|
||||
let bytes = source.as_ref();
|
||||
let source = std::str::from_utf8(bytes).map_err(|_| DocumentError::InvalidEncoding)?;
|
||||
let mut sections = Vec::<DocumentSection>::new();
|
||||
let mut issues = Vec::<DocumentIssue>::new();
|
||||
let mut peer_fields = Vec::<(
|
||||
usize,
|
||||
Vec<DirectiveSpan>,
|
||||
Vec<(DirectiveSpan, Vec<(usize, Result<IpNet, ()>)>)>,
|
||||
)>::new();
|
||||
let mut current_section: Option<usize> = None;
|
||||
let mut offset = 0usize;
|
||||
let mut line_number = 0usize;
|
||||
|
||||
for line_with_ending in source.split_inclusive('\n') {
|
||||
line_number += 1;
|
||||
let line_start = offset;
|
||||
offset += line_with_ending.len();
|
||||
let content = line_with_ending
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(line_with_ending);
|
||||
let content = content.strip_suffix('\r').unwrap_or(content);
|
||||
let line_end = line_start + content.len();
|
||||
let span = SourceSpan {
|
||||
start: line_start,
|
||||
end: offset,
|
||||
};
|
||||
let content_span = SourceSpan {
|
||||
start: line_start,
|
||||
end: line_end,
|
||||
};
|
||||
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with('[') || trimmed.ends_with(']') {
|
||||
if let Some(name) = parse_section_name(trimmed) {
|
||||
if let Some(previous) = current_section {
|
||||
sections[previous].span.end = line_start;
|
||||
}
|
||||
let normalized = name.to_ascii_lowercase();
|
||||
let kind = match normalized.as_str() {
|
||||
"interface" => SectionKind::Interface,
|
||||
"peer" => SectionKind::Peer,
|
||||
_ => SectionKind::Other,
|
||||
};
|
||||
sections.push(DocumentSection {
|
||||
kind,
|
||||
name: name.to_owned(),
|
||||
span: SourceSpan {
|
||||
start: line_start,
|
||||
end: source.len(),
|
||||
},
|
||||
header_span: content_span,
|
||||
line: line_number,
|
||||
});
|
||||
current_section = Some(sections.len() - 1);
|
||||
if kind == SectionKind::Peer {
|
||||
peer_fields.push((line_number, Vec::new(), Vec::new()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
issues.push(DocumentIssue::MalformedSectionHeader { line: line_number });
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(equal_index) = content.find('=') else {
|
||||
issues.push(DocumentIssue::MalformedDirective { line: line_number });
|
||||
continue;
|
||||
};
|
||||
let raw_key = &content[..equal_index];
|
||||
let raw_value = &content[equal_index + 1..];
|
||||
let key = raw_key.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
if key.is_empty() {
|
||||
issues.push(DocumentIssue::MalformedDirective { line: line_number });
|
||||
continue;
|
||||
}
|
||||
let raw_key_start = raw_key.len()
|
||||
- raw_key
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let raw_key_end = raw_key
|
||||
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let raw_value_start = raw_value.len()
|
||||
- raw_value
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let raw_value_end = raw_value
|
||||
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
.len();
|
||||
let directive = DirectiveSpan {
|
||||
line: line_number,
|
||||
whole: span,
|
||||
key: SourceSpan {
|
||||
start: line_start + raw_key_start,
|
||||
end: line_start + raw_key_end,
|
||||
},
|
||||
value: SourceSpan {
|
||||
start: line_start + equal_index + 1 + raw_value_start,
|
||||
end: line_start + equal_index + 1 + raw_value_end,
|
||||
},
|
||||
};
|
||||
let Some(section_index) = current_section else {
|
||||
issues.push(DocumentIssue::DirectiveOutsideSection { line: line_number });
|
||||
continue;
|
||||
};
|
||||
if sections[section_index].kind != SectionKind::Peer {
|
||||
continue;
|
||||
}
|
||||
let peer_index = peer_fields.len() - 1;
|
||||
let (_, public_keys, allowed_fields) = &mut peer_fields[peer_index];
|
||||
if key.eq_ignore_ascii_case("PublicKey") {
|
||||
public_keys.push(directive);
|
||||
} else if key.eq_ignore_ascii_case("AllowedIPs") {
|
||||
let value = &source[directive.value.start..directive.value.end];
|
||||
let parsed = value
|
||||
.split(',')
|
||||
.enumerate()
|
||||
.map(|(item, raw)| {
|
||||
let item_value = raw.trim();
|
||||
(item, IpNet::from_str(item_value).map_err(|_| ()))
|
||||
})
|
||||
.collect();
|
||||
allowed_fields.push((directive, parsed));
|
||||
}
|
||||
}
|
||||
|
||||
// `split_inclusive` yields no item for an empty string and its final item does not
|
||||
// necessarily end in LF; offset therefore tracks all source bytes exactly.
|
||||
let mut peers = Vec::with_capacity(peer_fields.len());
|
||||
for (index, (section_line, public_key_fields, allowed_fields)) in
|
||||
peer_fields.into_iter().enumerate()
|
||||
{
|
||||
let Some(section) = sections
|
||||
.iter()
|
||||
.filter(|section| section.kind == SectionKind::Peer)
|
||||
.nth(index)
|
||||
else {
|
||||
return Err(DocumentError::InvalidStructure);
|
||||
};
|
||||
let mut peer_issues = Vec::new();
|
||||
let parsed_keys: Vec<_> = public_key_fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(field, directive)| {
|
||||
let value = &source[directive.value.start..directive.value.end];
|
||||
match PublicKey::from_str(value) {
|
||||
Ok(key) => Some(key),
|
||||
Err(_) => {
|
||||
peer_issues.push(PeerIssue::MalformedPublicKey { field });
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if public_key_fields.is_empty() {
|
||||
peer_issues.push(PeerIssue::MissingPublicKey);
|
||||
}
|
||||
if public_key_fields.len() > 1 {
|
||||
peer_issues.push(PeerIssue::DuplicatePublicKeyField {
|
||||
count: public_key_fields.len(),
|
||||
});
|
||||
}
|
||||
let identity = if public_key_fields.len() == 1 {
|
||||
parsed_keys[0]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut allowed_ips = Vec::new();
|
||||
let mut allowed_ips_fields = Vec::new();
|
||||
let mut has_ipv4_host_route = false;
|
||||
let mut has_ipv6_host_route = false;
|
||||
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
|
||||
allowed_ips_fields.push(directive);
|
||||
for (item, result) in items {
|
||||
match result {
|
||||
Ok(net) => {
|
||||
let supported = match net {
|
||||
IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||
let first = !has_ipv4_host_route;
|
||||
has_ipv4_host_route = true;
|
||||
first
|
||||
}
|
||||
IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||
let first = !has_ipv6_host_route;
|
||||
has_ipv6_host_route = true;
|
||||
first
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !supported {
|
||||
peer_issues
|
||||
.push(PeerIssue::UnsupportedAllowedIpShape { field, item });
|
||||
}
|
||||
allowed_ips.push(net);
|
||||
}
|
||||
Err(()) => peer_issues.push(PeerIssue::MalformedAllowedIp { field, item }),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !has_ipv4_host_route {
|
||||
peer_issues.push(PeerIssue::MissingIpv4HostRoute);
|
||||
}
|
||||
peers.push(PeerInspection {
|
||||
index,
|
||||
section: section.span,
|
||||
section_line,
|
||||
identity,
|
||||
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
|
||||
public_key_fields,
|
||||
allowed_ips,
|
||||
allowed_ips_fields,
|
||||
metadata: MetadataAttachment::Absent,
|
||||
issues: peer_issues,
|
||||
});
|
||||
}
|
||||
|
||||
let mut by_identity = BTreeMap::<PublicKey, Vec<usize>>::new();
|
||||
for peer in &peers {
|
||||
for identity in &peer.public_key_candidates {
|
||||
by_identity.entry(*identity).or_default().push(peer.index);
|
||||
}
|
||||
}
|
||||
for indices in by_identity.values().filter(|indices| indices.len() > 1) {
|
||||
let unique_indices: Vec<_> =
|
||||
indices.iter().copied().fold(Vec::new(), |mut acc, index| {
|
||||
if !acc.contains(&index) {
|
||||
acc.push(index);
|
||||
}
|
||||
acc
|
||||
});
|
||||
for index in &unique_indices {
|
||||
let other_peer_indices: Vec<_> = unique_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|other| other != index)
|
||||
.collect();
|
||||
if other_peer_indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
peers[*index]
|
||||
.issues
|
||||
.push(PeerIssue::DuplicatePeerIdentity { other_peer_indices });
|
||||
}
|
||||
}
|
||||
|
||||
let peer_headers: Vec<_> = peers.iter().map(|peer| peer.section.start()).collect();
|
||||
let (metadata_attachments, metadata_issues) =
|
||||
metadata::inspect_document(source, &peer_headers);
|
||||
for (peer, attachment) in peers.iter_mut().zip(metadata_attachments) {
|
||||
peer.metadata = attachment;
|
||||
}
|
||||
issues.extend(
|
||||
metadata_issues
|
||||
.into_iter()
|
||||
.map(DocumentIssue::ManagedMetadata),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
source: source.to_owned(),
|
||||
sections,
|
||||
peers,
|
||||
issues,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exact original bytes, without normalization or reserialization.
|
||||
pub fn render(&self) -> &[u8] {
|
||||
self.source.as_bytes()
|
||||
}
|
||||
|
||||
pub fn sections(&self) -> &[DocumentSection] {
|
||||
&self.sections
|
||||
}
|
||||
|
||||
pub fn peers(&self) -> &[PeerInspection] {
|
||||
&self.peers
|
||||
}
|
||||
|
||||
pub fn issues(&self) -> &[DocumentIssue] {
|
||||
&self.issues
|
||||
}
|
||||
|
||||
/// A raw source slice for an explicitly selected span. Callers must treat it as secret-bearing.
|
||||
/// This narrow primitive supports later edit code that owns and replaces exact spans.
|
||||
pub fn source_slice(&self, span: SourceSpan) -> Option<&str> {
|
||||
self.source.get(span.start..span.end)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_section_name(trimmed: &str) -> Option<&str> {
|
||||
let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
|
||||
let name = inner.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
(!name.is_empty() && !name.contains(['[', ']'])).then_some(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
|
||||
#[test]
|
||||
fn wireguard_document_round_trips_line_ending_variants_byte_for_byte() {
|
||||
let inputs: &[&[u8]] = &[
|
||||
b"[Interface]\nAddress = 10.0.0.1/24\n# admin\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.0.0.2/32\n",
|
||||
b"[Interface]\r\nAddress = 10.0.0.1/24\r\n[Peer]\r\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n",
|
||||
b"# first\r\n[Interface]\nUnknown = value\r\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n# tail",
|
||||
b"[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
];
|
||||
for input in inputs {
|
||||
let document = ConfigDocument::parse(input).unwrap();
|
||||
assert_eq!(document.render(), *input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() {
|
||||
let input = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 2001:db8::2/128, 0.0.0.0/0\nPresharedKey = dummy-secret\nUnknown = retained\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 2001:db8::3/128\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
assert_eq!(document.peers().len(), 2);
|
||||
assert_eq!(document.peers()[0].allowed_ips.len(), 3);
|
||||
assert_eq!(document.peers()[1].allowed_ips.len(), 1);
|
||||
assert_eq!(document.peers()[0].identity.unwrap().to_string(), KEY_A);
|
||||
assert!(document.peers()[0].issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 2 }
|
||||
)));
|
||||
assert!(
|
||||
document.peers()[1]
|
||||
.issues
|
||||
.contains(&PeerIssue::MissingIpv4HostRoute)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_unsupported_route_text_is_preserved_and_reported() {
|
||||
let input =
|
||||
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = not-a-route, 2001:db8::2/128\n");
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
assert_eq!(peer.allowed_ips.len(), 1);
|
||||
assert!(
|
||||
peer.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }))
|
||||
);
|
||||
assert_eq!(document.render(), input.as_bytes());
|
||||
assert_eq!(
|
||||
document.source_slice(peer.allowed_ips_fields[0].value),
|
||||
Some("not-a-route, 2001:db8::2/128")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields() {
|
||||
let input = format!(
|
||||
"[Peer]\nPresharedKey = secret-a\n[Peer]\nPublicKey = bad-secret-shaped-input\nPublicKey = {KEY_A}\n[Peer]\nPublicKey = {KEY_A}\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
assert!(
|
||||
document.peers()[0]
|
||||
.issues
|
||||
.contains(&PeerIssue::MissingPublicKey)
|
||||
);
|
||||
assert!(
|
||||
document.peers()[1]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::MalformedPublicKey { .. }))
|
||||
);
|
||||
assert!(
|
||||
document.peers()[1]
|
||||
.issues
|
||||
.contains(&PeerIssue::DuplicatePublicKeyField { count: 2 })
|
||||
);
|
||||
assert!(
|
||||
document.peers()[2]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
|
||||
);
|
||||
let debug = format!("{document:?}");
|
||||
assert!(!debug.contains("secret-a"));
|
||||
assert!(!debug.contains("bad-secret-shaped-input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_errors_and_debug_redact_invalid_text_and_private_values() {
|
||||
let error =
|
||||
ConfigDocument::parse(b"[Interface]\nPrivateKey = definitely-secret\xff").unwrap_err();
|
||||
assert_eq!(error.to_string(), "server configuration is not valid UTF-8");
|
||||
assert!(!format!("{error:?}").contains("definitely-secret"));
|
||||
let document = ConfigDocument::parse(
|
||||
b"[Interface]\nPrivateKey = private-value\n[Peer]\nPresharedKey = peer-secret\n",
|
||||
)
|
||||
.unwrap();
|
||||
let debug = format!("{document:?}");
|
||||
assert!(!debug.contains("private-value"));
|
||||
assert!(!debug.contains("peer-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_sections_and_directive_spans_are_byte_offsets() {
|
||||
let input =
|
||||
format!("[Interface]\r\nPrivateKey = hidden\r\n[Peer]\r\nPublicKey = {KEY_A}\r\n");
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
let key_text = document
|
||||
.source_slice(peer.public_key_fields[0].value)
|
||||
.unwrap();
|
||||
assert_eq!(key_text, KEY_A);
|
||||
assert_eq!(peer.section.start(), input.find("[Peer]").unwrap());
|
||||
assert_eq!(peer.section.end(), input.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_document_exposes_valid_metadata_and_safe_owned_span() {
|
||||
let block = super::super::metadata::render_block("# café = \"laptop\"", None).unwrap();
|
||||
let input = format!("{block}[Peer]\nPublicKey = {KEY_A}\n");
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
let peer = &document.peers()[0];
|
||||
let MetadataAttachment::Valid {
|
||||
metadata,
|
||||
block: span,
|
||||
} = &peer.metadata
|
||||
else {
|
||||
panic!("valid attached metadata should be available");
|
||||
};
|
||||
assert_eq!(metadata.alias, "# café = \"laptop\"");
|
||||
assert_eq!(document.source_slice(*span), Some(block.as_str()));
|
||||
assert!(document.issues().is_empty());
|
||||
assert_eq!(document.render(), input.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_document_reports_unsupported_metadata_without_source_text() {
|
||||
let input = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=99\n# wg-admin:alias=c2VjcmV0LW5vdGU\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\n"
|
||||
);
|
||||
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
||||
assert!(matches!(
|
||||
document.peers()[0].metadata,
|
||||
MetadataAttachment::Invalid(MetadataIssue::UnsupportedVersion { .. })
|
||||
));
|
||||
let debug = format!("{document:?}");
|
||||
assert!(!debug.contains("c2VjcmV0LW5vdGU"));
|
||||
assert!(!debug.contains("secret-note"));
|
||||
assert_eq!(document.render(), input.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Pure, span-based edits to caller-supplied WireGuard server documents.
|
||||
|
||||
use ipnet::IpNet;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::{Client, ClientAddresses, PublicKey, validate_alias};
|
||||
|
||||
use super::document::{ConfigDocument, PeerInspection, PeerIssue};
|
||||
use super::metadata::{MetadataAttachment, MetadataError, render_block};
|
||||
|
||||
/// Redacted failures from an in-memory configuration operation.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum EditError {
|
||||
#[error("the selected client identity is missing")]
|
||||
MissingIdentity,
|
||||
#[error("the selected client identity is ambiguous")]
|
||||
AmbiguousIdentity,
|
||||
#[error("the configuration contains malformed or unsupported structure")]
|
||||
UnsafeDocument,
|
||||
#[error("the selected peer has malformed or unsupported fields")]
|
||||
UnsafePeer,
|
||||
#[error("the client identity already exists")]
|
||||
DuplicateIdentity,
|
||||
#[error("the client alias is invalid")]
|
||||
InvalidAlias,
|
||||
#[error("the creation timestamp is invalid")]
|
||||
InvalidTimestamp,
|
||||
#[error("the peer does not have a usable alias")]
|
||||
MissingAlias,
|
||||
#[error("the peer routes do not fit the supported client address model")]
|
||||
UnsupportedAddresses,
|
||||
#[error("the edited configuration could not be parsed")]
|
||||
ParseFailure,
|
||||
}
|
||||
|
||||
impl From<MetadataError> for EditError {
|
||||
fn from(error: MetadataError) -> Self {
|
||||
match error {
|
||||
MetadataError::InvalidAlias => Self::InvalidAlias,
|
||||
MetadataError::InvalidTimestamp => Self::InvalidTimestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigDocument {
|
||||
/// Add a peer using a caller-provided identity and validated domain client.
|
||||
/// No key generation or filesystem/runtime access occurs here.
|
||||
pub fn add_client(
|
||||
&self,
|
||||
client: &Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<Self, EditError> {
|
||||
add_client(self, client, created_utc)
|
||||
}
|
||||
|
||||
/// Delete the uniquely selected peer. Its header and directives are owned by
|
||||
/// the peer; blank lines and administrator comments remain in their original order.
|
||||
pub fn delete_client(&self, identity: &PublicKey) -> Result<Self, EditError> {
|
||||
delete_client(self, identity)
|
||||
}
|
||||
|
||||
/// Rename a peer, inserting a standard block when it is a legacy peer.
|
||||
pub fn rename_client(&self, identity: &PublicKey, alias: &str) -> Result<Self, EditError> {
|
||||
rename_client(self, identity, alias)
|
||||
}
|
||||
|
||||
/// Explicitly add managed alias metadata to a legacy peer.
|
||||
pub fn insert_legacy_metadata(
|
||||
&self,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<Self, EditError> {
|
||||
insert_legacy_metadata(self, identity, alias, created_utc)
|
||||
}
|
||||
|
||||
/// Project one compatible peer and its metadata into the narrower M1 model.
|
||||
pub fn project_client(&self, identity: &PublicKey) -> Result<Client, EditError> {
|
||||
project_client(self, identity)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_client(
|
||||
document: &ConfigDocument,
|
||||
client: &Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(&client.alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
if find_peer(document, client.identity).is_ok() {
|
||||
return Err(EditError::DuplicateIdentity);
|
||||
}
|
||||
if !matches!(
|
||||
find_peer(document, client.identity),
|
||||
Err(EditError::MissingIdentity)
|
||||
) {
|
||||
return Err(EditError::AmbiguousIdentity);
|
||||
}
|
||||
let block = render_block(&client.alias, created_utc)?;
|
||||
let newline = line_ending(document.render());
|
||||
let mut source =
|
||||
String::from_utf8(document.render().to_vec()).map_err(|_| EditError::ParseFailure)?;
|
||||
if !source.is_empty() && !source.ends_with('\n') {
|
||||
source.push_str(newline);
|
||||
}
|
||||
let block = block.replace('\n', newline);
|
||||
source.push_str(&block);
|
||||
source.push_str("[Peer]");
|
||||
source.push_str(newline);
|
||||
source.push_str("PublicKey = ");
|
||||
source.push_str(&client.identity.to_string());
|
||||
source.push_str(newline);
|
||||
source.push_str("AllowedIPs = ");
|
||||
source.push_str(&format!("{}/32", client.addresses.ipv4));
|
||||
if let Some(ipv6) = client.addresses.ipv6 {
|
||||
source.push_str(", ");
|
||||
source.push_str(&format!("{ipv6}/128"));
|
||||
}
|
||||
source.push_str(newline);
|
||||
parse_edited(source)
|
||||
}
|
||||
|
||||
pub fn delete_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?;
|
||||
let mut output = String::with_capacity(source.len());
|
||||
let prefix_end = match &peer.metadata {
|
||||
MetadataAttachment::Valid { block, .. } => block.start(),
|
||||
MetadataAttachment::Absent => peer.section.start(),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
output.push_str(&source[..prefix_end]);
|
||||
let section = peer.section;
|
||||
let section_text = &source[section.start()..section.end()];
|
||||
let mut offset = section.start();
|
||||
for raw in section_text.split_inclusive('\n') {
|
||||
let content = raw
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(raw)
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or(raw.strip_suffix('\n').unwrap_or(raw));
|
||||
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
let is_header = offset == section.start();
|
||||
let is_comment_or_blank =
|
||||
trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';');
|
||||
if !is_header && is_comment_or_blank {
|
||||
output.push_str(raw);
|
||||
}
|
||||
offset += raw.len();
|
||||
}
|
||||
output.push_str(&source[section.end()..]);
|
||||
let result = parse_edited(output)?;
|
||||
if result
|
||||
.peers()
|
||||
.iter()
|
||||
.any(|candidate| candidate.public_key_candidates.contains(identity))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn rename_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
let created = match &peer.metadata {
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(),
|
||||
MetadataAttachment::Absent => None,
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
set_metadata(document, identity, alias, created)
|
||||
}
|
||||
|
||||
pub fn insert_legacy_metadata(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
if !matches!(peer.metadata, MetadataAttachment::Absent) {
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
set_metadata(document, identity, alias, created_utc)
|
||||
}
|
||||
|
||||
fn set_metadata(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
let created = match &peer.metadata {
|
||||
MetadataAttachment::Absent => created_utc,
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
let rendered = render_block(alias, created)?;
|
||||
let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?;
|
||||
match &peer.metadata {
|
||||
MetadataAttachment::Absent => {
|
||||
let replacement = rendered.replace('\n', line_ending(document.render()));
|
||||
let mut output = String::with_capacity(source.len() + replacement.len());
|
||||
let at = peer.section.start();
|
||||
output.push_str(&source[..at]);
|
||||
output.push_str(&replacement);
|
||||
output.push_str(&source[at..]);
|
||||
parse_edited(output)
|
||||
}
|
||||
MetadataAttachment::Valid { block, .. } => {
|
||||
let encoded_alias = rendered
|
||||
.lines()
|
||||
.nth(2)
|
||||
.and_then(|line| line.strip_prefix("# wg-admin:alias="))
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let block_source = &source[block.start()..block.end()];
|
||||
let alias_line = block_source
|
||||
.find("# wg-admin:alias=")
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let value_start = block.start() + alias_line + "# wg-admin:alias=".len();
|
||||
let value_end = value_start
|
||||
+ source[value_start..block.end()]
|
||||
.find(['\r', '\n'])
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let mut output = String::with_capacity(
|
||||
source.len() - (value_end - value_start) + encoded_alias.len(),
|
||||
);
|
||||
output.push_str(&source[..value_start]);
|
||||
output.push_str(encoded_alias);
|
||||
output.push_str(&source[value_end..]);
|
||||
parse_edited(output)
|
||||
}
|
||||
MetadataAttachment::Invalid(_) => Err(EditError::UnsafePeer),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
) -> Result<Client, EditError> {
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
if peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| !is_address_shape_issue(issue))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
let alias = match &peer.metadata {
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.alias.clone(),
|
||||
MetadataAttachment::Absent => return Err(EditError::MissingAlias),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
if !peer.issues.is_empty() {
|
||||
return Err(EditError::UnsupportedAddresses);
|
||||
}
|
||||
let mut ipv4 = None;
|
||||
let mut ipv6 = None;
|
||||
for route in &peer.allowed_ips {
|
||||
match route {
|
||||
IpNet::V4(network) if network.prefix_len() == 32 && ipv4.is_none() => {
|
||||
ipv4 = Some(network.addr());
|
||||
}
|
||||
IpNet::V6(network) if network.prefix_len() == 128 && ipv6.is_none() => {
|
||||
ipv6 = Some(network.addr());
|
||||
}
|
||||
_ => return Err(EditError::UnsupportedAddresses),
|
||||
}
|
||||
}
|
||||
let ipv4 = ipv4.ok_or(EditError::UnsupportedAddresses)?;
|
||||
Client::new(*identity, alias, ClientAddresses { ipv4, ipv6 })
|
||||
.map_err(|_| EditError::InvalidAlias)
|
||||
}
|
||||
|
||||
fn ensure_document_safe(document: &ConfigDocument) -> Result<(), EditError> {
|
||||
if !document.issues().is_empty() {
|
||||
return Err(EditError::UnsafeDocument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_peer(
|
||||
document: &ConfigDocument,
|
||||
identity: PublicKey,
|
||||
) -> Result<&PeerInspection, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
find_peer(document, identity)
|
||||
}
|
||||
|
||||
fn find_peer(document: &ConfigDocument, identity: PublicKey) -> Result<&PeerInspection, EditError> {
|
||||
let matches: Vec<_> = document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| peer.public_key_candidates.contains(&identity))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[] => Err(EditError::MissingIdentity),
|
||||
[peer]
|
||||
if peer.identity == Some(identity)
|
||||
&& !peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) =>
|
||||
{
|
||||
Ok(peer)
|
||||
}
|
||||
_ => Err(EditError::AmbiguousIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_peer_safe(peer: &PeerInspection) -> Result<(), EditError> {
|
||||
if peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| !is_address_shape_issue(issue))
|
||||
|| matches!(peer.metadata, MetadataAttachment::Invalid(_))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_address_shape_issue(issue: &PeerIssue) -> bool {
|
||||
matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_edited(source: String) -> Result<ConfigDocument, EditError> {
|
||||
ConfigDocument::parse(source.as_bytes()).map_err(|_| EditError::ParseFailure)
|
||||
}
|
||||
|
||||
fn line_ending(source: &[u8]) -> &'static str {
|
||||
source
|
||||
.windows(2)
|
||||
.find(|window| *window == b"\r\n")
|
||||
.map_or("\n", |_| "\r\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
|
||||
fn key(text: &str) -> PublicKey {
|
||||
PublicKey::from_str(text).unwrap()
|
||||
}
|
||||
fn doc(source: &str) -> ConfigDocument {
|
||||
ConfigDocument::parse(source).unwrap()
|
||||
}
|
||||
fn client(identity: &str, alias: &str) -> Client {
|
||||
Client::new(
|
||||
key(identity),
|
||||
alias,
|
||||
ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 0, 0, 7),
|
||||
ipv6: Some("fd00::7".parse().unwrap()),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_add_emits_metadata_dual_stack_and_round_trips() {
|
||||
let original = doc("[Interface]\nAddress = keep\n");
|
||||
let edited = original
|
||||
.add_client(&client(KEY_A, "same alias"), Some("2026-10-18T19:00:00Z"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(edited.render())
|
||||
.contains("AllowedIPs = 10.0.0.7/32, fd00::7/128")
|
||||
);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"same alias"
|
||||
);
|
||||
assert!(original.peers().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_add_preserves_existing_bytes_and_allows_duplicate_aliases() {
|
||||
let source = format!(
|
||||
"# untouched\r\n[Interface]\r\nPrivateKey = hidden\r\n\r\n[Peer]\r\nPublicKey = {KEY_A}\r\nAllowedIPs = 10.0.0.1/32\r\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let mut other = client(KEY_B, "repeated");
|
||||
other.addresses.ipv4 = Ipv4Addr::new(10, 0, 0, 8);
|
||||
let managed = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "repeated", None)
|
||||
.unwrap();
|
||||
let edited = managed.add_client(&other, None).unwrap();
|
||||
let rendered = String::from_utf8(edited.render().to_vec()).unwrap();
|
||||
let peer_header = source.find("[Peer]").unwrap();
|
||||
assert!(rendered.starts_with(&source[..peer_header]));
|
||||
assert!(rendered.contains(&source[peer_header..]));
|
||||
assert_eq!(edited.peers().len(), 2);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"repeated"
|
||||
);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_B)).unwrap().alias,
|
||||
"repeated"
|
||||
);
|
||||
assert!(rendered.contains("\r\n# wg-admin:begin\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_repeated_renames_reparse_spans_and_preserve_creation_time() {
|
||||
let source = format!(
|
||||
"# outside\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\nPresharedKey = secret\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let first = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "first", Some("2024-02-29T12:00:00Z"))
|
||||
.unwrap();
|
||||
let second = first.rename_client(&key(KEY_A), "second").unwrap();
|
||||
let third = second.rename_client(&key(KEY_A), "third").unwrap();
|
||||
let third_text = String::from_utf8(third.render().to_vec()).unwrap();
|
||||
assert_eq!(third.project_client(&key(KEY_A)).unwrap().alias, "third");
|
||||
assert_eq!(third_text.matches("wg-admin:begin").count(), 1);
|
||||
assert!(third_text.contains("2024-02-29T12:00:00Z"));
|
||||
assert!(third_text.starts_with("# outside\n"));
|
||||
assert!(third_text.ends_with("PresharedKey = secret\n"));
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_rename_and_legacy_insertion_preserve_date_and_unknown_directives() {
|
||||
let source = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\nPresharedKey = secret\n# admin\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let added = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "old", Some("2024-01-02T03:04:05Z"))
|
||||
.unwrap();
|
||||
let renamed = added.rename_client(&key(KEY_A), "new # café").unwrap();
|
||||
let view = renamed.project_client(&key(KEY_A)).unwrap();
|
||||
assert_eq!(view.alias, "new # café");
|
||||
assert!(String::from_utf8_lossy(renamed.render()).contains("2024-01-02T03:04:05Z"));
|
||||
assert!(
|
||||
String::from_utf8_lossy(renamed.render()).contains("PresharedKey = secret\n# admin\n")
|
||||
);
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_delete_preserves_admin_and_neighbor_managed_comments() {
|
||||
let block_a = render_block("one", None).unwrap();
|
||||
let block_b = render_block("two", None).unwrap();
|
||||
let source = format!(
|
||||
"# before\n{block_a}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n# keep note\n{block_b}[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
);
|
||||
let edited = doc(&source).delete_client(&key(KEY_A)).unwrap();
|
||||
let output = String::from_utf8(edited.render().to_vec()).unwrap();
|
||||
assert!(!output.contains(KEY_A));
|
||||
assert!(output.contains("# before\n"));
|
||||
assert!(output.contains("# keep note\n"));
|
||||
assert!(output.contains(&block_b));
|
||||
assert_eq!(edited.project_client(&key(KEY_B)).unwrap().alias, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_rejects_ambiguous_edits_and_unsupported_projection_without_mutation() {
|
||||
let source =
|
||||
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32, 10.0.0.2/32\n");
|
||||
let original = doc(&source);
|
||||
assert_eq!(
|
||||
original.project_client(&key(KEY_A)),
|
||||
Err(EditError::MissingAlias)
|
||||
);
|
||||
assert!(matches!(
|
||||
original.delete_client(&key(KEY_B)),
|
||||
Err(EditError::MissingIdentity)
|
||||
));
|
||||
let duplicate = doc(&format!(
|
||||
"{source}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
|
||||
));
|
||||
assert!(matches!(
|
||||
duplicate.rename_client(&key(KEY_A), "renamed"),
|
||||
Err(EditError::AmbiguousIdentity)
|
||||
));
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes() {
|
||||
let ipv4 = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n",
|
||||
render_block("v4", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
ipv4.project_client(&key(KEY_A)).unwrap().addresses.ipv6,
|
||||
None
|
||||
);
|
||||
let ipv6 = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::1/128\n",
|
||||
render_block("v6", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
ipv6.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
let full = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 0.0.0.0/0\n",
|
||||
render_block("full", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
full.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Versioned, comment-only metadata immediately attached to a WireGuard peer.
|
||||
//!
|
||||
//! The alias is encoded as canonical unpadded base64url of its UTF-8 bytes. This
|
||||
//! keeps arbitrary valid aliases on one comment line without introducing config
|
||||
//! syntax, comment termination, or line injection.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
use crate::domain::validate_alias;
|
||||
|
||||
use super::SourceSpan;
|
||||
|
||||
const BEGIN: &str = "# wg-admin:begin";
|
||||
const END: &str = "# wg-admin:end";
|
||||
const VERSION_PREFIX: &str = "# wg-admin:version=";
|
||||
const ALIAS_PREFIX: &str = "# wg-admin:alias=";
|
||||
const CREATED_PREFIX: &str = "# wg-admin:created-utc=";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedMetadata {
|
||||
pub version: u32,
|
||||
pub alias: String,
|
||||
pub created_utc: Option<String>,
|
||||
}
|
||||
|
||||
/// A safe half-open source span for a validated, uniquely attached block.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MetadataAttachment {
|
||||
Absent,
|
||||
Valid {
|
||||
metadata: ManagedMetadata,
|
||||
block: SourceSpan,
|
||||
},
|
||||
Invalid(MetadataIssue),
|
||||
}
|
||||
|
||||
/// Redacted metadata problems. Variants identify a line, never include its text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MetadataIssue {
|
||||
MalformedBlock { line: usize },
|
||||
UnsupportedVersion { line: usize },
|
||||
InvalidAlias { line: usize },
|
||||
InvalidTimestamp { line: usize },
|
||||
UnknownField { line: usize },
|
||||
MissingField { line: usize },
|
||||
DuplicateBlocks { line: usize, count: usize },
|
||||
Misattached { line: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Line<'a> {
|
||||
content: &'a str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
number: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Candidate {
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
span: SourceSpan,
|
||||
issue: Option<MetadataIssue>,
|
||||
}
|
||||
|
||||
/// Inspect all reserved marker pairs and attach blocks only when their final byte
|
||||
/// is the peer header's first byte. Administrator comments outside this namespace
|
||||
/// are ignored completely.
|
||||
pub(super) fn inspect_document(
|
||||
source: &str,
|
||||
peer_headers: &[usize],
|
||||
) -> (Vec<MetadataAttachment>, Vec<MetadataIssue>) {
|
||||
let lines = lines(source);
|
||||
let mut candidates = Vec::new();
|
||||
let mut issues = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < lines.len() {
|
||||
if marker(lines[index].content) != Some(Marker::Begin) {
|
||||
if marker(lines[index].content) == Some(Marker::End) {
|
||||
issues.push(MetadataIssue::MalformedBlock {
|
||||
line: lines[index].number,
|
||||
});
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index;
|
||||
let mut end = None;
|
||||
let mut malformed = false;
|
||||
let mut cursor = index + 1;
|
||||
while cursor < lines.len() {
|
||||
match marker(lines[cursor].content) {
|
||||
Some(Marker::Begin) => {
|
||||
malformed = true;
|
||||
break;
|
||||
}
|
||||
Some(Marker::End) => {
|
||||
end = Some(cursor);
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
let attached_peer = peer_headers.iter().find(|header| {
|
||||
**header > lines[start].start
|
||||
&& (end.is_none()
|
||||
|| **header <= lines[cursor.min(lines.len().saturating_sub(1))].start)
|
||||
});
|
||||
let block_end_line = end.unwrap_or_else(|| {
|
||||
if malformed {
|
||||
cursor
|
||||
} else if let Some(header) = attached_peer {
|
||||
lines
|
||||
.partition_point(|line| line.start < *header)
|
||||
.saturating_sub(1)
|
||||
} else {
|
||||
lines.len().saturating_sub(1)
|
||||
}
|
||||
});
|
||||
let span_end = if let Some(end_line) = end {
|
||||
lines[end_line].end
|
||||
} else if malformed {
|
||||
lines[block_end_line].start
|
||||
} else if let Some(header) = attached_peer {
|
||||
*header
|
||||
} else {
|
||||
source.len()
|
||||
};
|
||||
let Some(span) = SourceSpan::from_offsets(lines[start].start, span_end) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let issue = if malformed || end.is_none() {
|
||||
Some(MetadataIssue::MalformedBlock {
|
||||
line: lines[start].number,
|
||||
})
|
||||
} else {
|
||||
parse_block(&lines[start..=block_end_line]).err()
|
||||
};
|
||||
if let Some(issue) = &issue {
|
||||
issues.push(issue.clone());
|
||||
}
|
||||
candidates.push(Candidate {
|
||||
start_line: start,
|
||||
end_line: block_end_line,
|
||||
span,
|
||||
issue,
|
||||
});
|
||||
if let Some(end) = end {
|
||||
index = end + 1;
|
||||
} else if malformed {
|
||||
index = cursor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut attachments = Vec::with_capacity(peer_headers.len());
|
||||
for header in peer_headers {
|
||||
let ending_here: Vec<_> = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, candidate)| candidate.span.end() == *header)
|
||||
.map(|(candidate, _)| candidate)
|
||||
.collect();
|
||||
if ending_here.is_empty() {
|
||||
attachments.push(MetadataAttachment::Absent);
|
||||
continue;
|
||||
}
|
||||
let Some(last) = ending_here.last().copied() else {
|
||||
attachments.push(MetadataAttachment::Absent);
|
||||
continue;
|
||||
};
|
||||
let mut contiguous = 1;
|
||||
let mut previous = last;
|
||||
while previous > 0
|
||||
&& candidates[previous - 1].span.end() == candidates[previous].span.start()
|
||||
{
|
||||
contiguous += 1;
|
||||
previous -= 1;
|
||||
}
|
||||
let candidate = &candidates[last];
|
||||
if contiguous > 1 {
|
||||
let issue = MetadataIssue::DuplicateBlocks {
|
||||
line: lines[candidate.start_line].number,
|
||||
count: contiguous,
|
||||
};
|
||||
issues.push(issue.clone());
|
||||
attachments.push(MetadataAttachment::Invalid(issue));
|
||||
} else if let Some(issue) = &candidate.issue {
|
||||
attachments.push(MetadataAttachment::Invalid(issue.clone()));
|
||||
} else {
|
||||
let parsed = parse_block(&lines[candidate.start_line..=candidate.end_line]);
|
||||
match parsed {
|
||||
Ok(metadata) => attachments.push(MetadataAttachment::Valid {
|
||||
metadata,
|
||||
block: candidate.span,
|
||||
}),
|
||||
Err(issue) => {
|
||||
attachments.push(MetadataAttachment::Invalid(issue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in &candidates {
|
||||
if !peer_headers.contains(&candidate.span.end()) {
|
||||
let issue = MetadataIssue::Misattached {
|
||||
line: lines[candidate.start_line].number,
|
||||
};
|
||||
issues.push(issue);
|
||||
}
|
||||
}
|
||||
(attachments, issues)
|
||||
}
|
||||
|
||||
/// Render the canonical LF-terminated v1 block. The caller may adapt line endings
|
||||
/// when inserting it into a document; this function performs no source mutation.
|
||||
pub fn render_block(alias: &str, created_utc: Option<&str>) -> Result<String, MetadataError> {
|
||||
validate_alias(alias).map_err(|_| MetadataError::InvalidAlias)?;
|
||||
if let Some(timestamp) = created_utc
|
||||
&& !valid_utc_timestamp(timestamp)
|
||||
{
|
||||
return Err(MetadataError::InvalidTimestamp);
|
||||
}
|
||||
let encoded = URL_SAFE_NO_PAD.encode(alias.as_bytes());
|
||||
let mut block = format!("{BEGIN}\n{VERSION_PREFIX}1\n{ALIAS_PREFIX}{encoded}\n");
|
||||
if let Some(timestamp) = created_utc {
|
||||
block.push_str(CREATED_PREFIX);
|
||||
block.push_str(timestamp);
|
||||
block.push('\n');
|
||||
}
|
||||
block.push_str(END);
|
||||
block.push('\n');
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum MetadataError {
|
||||
#[error("client alias is invalid")]
|
||||
InvalidAlias,
|
||||
#[error("creation timestamp must be a valid UTC timestamp")]
|
||||
InvalidTimestamp,
|
||||
}
|
||||
|
||||
fn parse_block(lines: &[Line<'_>]) -> Result<ManagedMetadata, MetadataIssue> {
|
||||
let first_line = lines.first().map_or(1, |line| line.number);
|
||||
if lines.len() < 4 || lines[0].content != BEGIN || lines[lines.len() - 1].content != END {
|
||||
return Err(MetadataIssue::MalformedBlock { line: first_line });
|
||||
}
|
||||
let version_line = &lines[1];
|
||||
let Some(version_text) = version_line.content.strip_prefix(VERSION_PREFIX) else {
|
||||
return Err(MetadataIssue::MissingField { line: first_line });
|
||||
};
|
||||
if version_text.is_empty() || !version_text.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(MetadataIssue::MalformedBlock {
|
||||
line: version_line.number,
|
||||
});
|
||||
}
|
||||
let version = version_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| MetadataIssue::MalformedBlock {
|
||||
line: version_line.number,
|
||||
})?;
|
||||
if version != 1 {
|
||||
return Err(MetadataIssue::UnsupportedVersion {
|
||||
line: version_line.number,
|
||||
});
|
||||
}
|
||||
let alias_line = &lines[2];
|
||||
let Some(encoded_alias) = alias_line.content.strip_prefix(ALIAS_PREFIX) else {
|
||||
return Err(MetadataIssue::MissingField {
|
||||
line: alias_line.number,
|
||||
});
|
||||
};
|
||||
let decoded =
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(encoded_alias)
|
||||
.map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
if URL_SAFE_NO_PAD.encode(&decoded) != encoded_alias {
|
||||
return Err(MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
});
|
||||
}
|
||||
let alias = String::from_utf8(decoded).map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
validate_alias(&alias).map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
let created_lines = &lines[3..lines.len() - 1];
|
||||
let created_utc = match created_lines {
|
||||
[] => None,
|
||||
[line] => {
|
||||
let Some(value) = line.content.strip_prefix(CREATED_PREFIX) else {
|
||||
return Err(if line.content.starts_with("# wg-admin:") {
|
||||
MetadataIssue::UnknownField { line: line.number }
|
||||
} else {
|
||||
MetadataIssue::MalformedBlock { line: line.number }
|
||||
});
|
||||
};
|
||||
if !valid_utc_timestamp(value) {
|
||||
return Err(MetadataIssue::InvalidTimestamp { line: line.number });
|
||||
}
|
||||
Some(value.to_owned())
|
||||
}
|
||||
[line, ..] => {
|
||||
return Err(if line.content.starts_with("# wg-admin:") {
|
||||
MetadataIssue::UnknownField { line: line.number }
|
||||
} else {
|
||||
MetadataIssue::MalformedBlock { line: line.number }
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(ManagedMetadata {
|
||||
version,
|
||||
alias,
|
||||
created_utc,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Marker {
|
||||
Begin,
|
||||
End,
|
||||
}
|
||||
|
||||
fn marker(line: &str) -> Option<Marker> {
|
||||
if line == BEGIN
|
||||
|| line.starts_with(&format!("{BEGIN} "))
|
||||
|| line.starts_with(&format!("{BEGIN}\t"))
|
||||
{
|
||||
Some(Marker::Begin)
|
||||
} else if line == END
|
||||
|| line.starts_with(&format!("{END} "))
|
||||
|| line.starts_with(&format!("{END}\t"))
|
||||
{
|
||||
Some(Marker::End)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Vec<Line<'_>> {
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
for (index, raw) in source.split_inclusive('\n').enumerate() {
|
||||
let end = offset + raw.len();
|
||||
let content = raw.strip_suffix('\n').unwrap_or(raw);
|
||||
let content = content.strip_suffix('\r').unwrap_or(content);
|
||||
result.push(Line {
|
||||
content,
|
||||
start: offset,
|
||||
end,
|
||||
number: index + 1,
|
||||
});
|
||||
offset = end;
|
||||
}
|
||||
if source.is_empty() {
|
||||
return result;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn valid_utc_timestamp(value: &str) -> bool {
|
||||
if value.len() != 20
|
||||
|| !value.is_ascii()
|
||||
|| value.as_bytes()[4] != b'-'
|
||||
|| value.as_bytes()[7] != b'-'
|
||||
|| value.as_bytes()[10] != b'T'
|
||||
|| value.as_bytes()[13] != b':'
|
||||
|| value.as_bytes()[16] != b':'
|
||||
|| value.as_bytes()[19] != b'Z'
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let digits = |range: std::ops::Range<usize>| value[range].parse::<u32>().ok();
|
||||
let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = (
|
||||
digits(0..4),
|
||||
digits(5..7),
|
||||
digits(8..10),
|
||||
digits(11..13),
|
||||
digits(14..16),
|
||||
digits(17..19),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if year == 0 || !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
|
||||
return false;
|
||||
}
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let days = match month {
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
};
|
||||
(1..=days).contains(&day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_round_trips_aliases_and_optional_timestamp() {
|
||||
let alias = "# café = \"laptop\" <tag>";
|
||||
for timestamp in [None, Some("2024-02-29T23:59:59Z")] {
|
||||
let rendered = render_block(alias, timestamp).unwrap();
|
||||
let header = rendered.len();
|
||||
let (attachments, issues) = inspect_document(
|
||||
&format!(
|
||||
"{rendered}[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"
|
||||
),
|
||||
&[header],
|
||||
);
|
||||
assert!(issues.is_empty());
|
||||
let MetadataAttachment::Valid { metadata, block } = &attachments[0] else {
|
||||
panic!("canonical metadata should inspect successfully");
|
||||
};
|
||||
assert_eq!(metadata.alias, alias);
|
||||
assert_eq!(metadata.created_utc.as_deref(), timestamp);
|
||||
assert_eq!(block.end(), header);
|
||||
assert_eq!(block.start(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_rejects_control_aliases_and_invalid_utc_dates() {
|
||||
assert_eq!(
|
||||
render_block("bad\n# wg-admin:end", None),
|
||||
Err(MetadataError::InvalidAlias)
|
||||
);
|
||||
for timestamp in [
|
||||
"2023-02-29T12:00:00Z",
|
||||
"2024-13-01T12:00:00Z",
|
||||
"2024-01-01T24:00:00Z",
|
||||
"2024-01-01T12:00:60Z",
|
||||
"2024-01-01T12:00:00+00:00",
|
||||
] {
|
||||
assert_eq!(
|
||||
render_block("ok", Some(timestamp)),
|
||||
Err(MetadataError::InvalidTimestamp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_reports_unsupported_and_misattached_blocks_redacted() {
|
||||
let source = "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2s\n# wg-admin:end\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, issues) = inspect_document(source, &[header]);
|
||||
assert!(matches!(attachments[0], MetadataAttachment::Absent));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, MetadataIssue::Misattached { .. }))
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }))
|
||||
);
|
||||
assert!(!format!("{issues:?}").contains("b2s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_missing_end_marker_still_attaches_as_invalid() {
|
||||
let source = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2s\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, _) = inspect_document(source, &[header]);
|
||||
assert!(matches!(
|
||||
attachments[0],
|
||||
MetadataAttachment::Invalid(MetadataIssue::MalformedBlock { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_ignores_unrelated_admin_comments_and_rejects_duplicate_blocks() {
|
||||
let first = render_block("one", None).unwrap();
|
||||
let second = render_block("two", None).unwrap();
|
||||
let source = format!(
|
||||
"# administrator note: wg-admin is not mentioned as an owner\n{first}{second}[Peer]\n"
|
||||
);
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, _) = inspect_document(&source, &[header]);
|
||||
assert!(matches!(
|
||||
attachments[0],
|
||||
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
|
||||
));
|
||||
let source = "# administrator note: wg-admin is not mentioned as an owner\n[Peer]\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, issues) = inspect_document(source, &[header]);
|
||||
assert!(matches!(attachments[0], MetadataAttachment::Absent));
|
||||
assert!(issues.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use wireguard_admin::domain::{Client, ClientAddresses, PublicKey};
|
||||
use wireguard_admin::wireguard::{
|
||||
ConfigDocument, DocumentIssue, EditError, MetadataAttachment, MetadataIssue, PeerIssue,
|
||||
};
|
||||
|
||||
const FIXTURE: &str = include_str!("fixtures/wireguard/representative.conf");
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
const KEY_C: &str = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=";
|
||||
const KEY_D: &str = "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=";
|
||||
const SECRET_MARKERS: [&str; 3] = [
|
||||
"SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY",
|
||||
"SYNTHETIC-PLACEHOLDER-PEER-PSK-A",
|
||||
"SYNTHETIC-PLACEHOLDER-PEER-PSK-B",
|
||||
];
|
||||
type MetadataCase = (&'static str, &'static str, fn(&MetadataIssue) -> bool);
|
||||
|
||||
fn key(text: &str) -> PublicKey {
|
||||
PublicKey::from_str(text).expect("synthetic public key is valid")
|
||||
}
|
||||
|
||||
fn client(public_key: &str, alias: &str, last_octet: u8) -> Client {
|
||||
Client::new(
|
||||
key(public_key),
|
||||
alias,
|
||||
ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 44, 0, last_octet),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.expect("test alias is valid")
|
||||
}
|
||||
|
||||
fn text(document: &ConfigDocument) -> &str {
|
||||
std::str::from_utf8(document.render()).expect("fixture is UTF-8")
|
||||
}
|
||||
|
||||
fn assert_secret_free_debug<T: std::fmt::Debug>(value: &T) {
|
||||
let debug = format!("{value:?}");
|
||||
for marker in SECRET_MARKERS {
|
||||
assert!(
|
||||
!debug.contains(marker),
|
||||
"Debug leaked a synthetic secret marker"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_lf_crlf_mixed_and_missing_final_newline_inputs() {
|
||||
let lf = FIXTURE.as_bytes().to_vec();
|
||||
let crlf = FIXTURE.replace('\n', "\r\n").into_bytes();
|
||||
let mixed = FIXTURE
|
||||
.lines()
|
||||
.enumerate()
|
||||
.flat_map(|(line, value)| {
|
||||
let ending = if line % 3 == 1 { "\r\n" } else { "\n" };
|
||||
format!("{value}{ending}").into_bytes()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut no_final_newline = lf.clone();
|
||||
while no_final_newline.last() == Some(&b'\n') || no_final_newline.last() == Some(&b'\r') {
|
||||
no_final_newline.pop();
|
||||
}
|
||||
|
||||
for (label, source) in [
|
||||
("LF", lf),
|
||||
("CRLF", crlf),
|
||||
("mixed", mixed),
|
||||
("no final newline", no_final_newline),
|
||||
] {
|
||||
let parsed = ConfigDocument::parse(&source).expect("synthetic UTF-8 parses");
|
||||
assert!(
|
||||
parsed.render() == source.as_slice(),
|
||||
"{label} round trip changed bytes"
|
||||
);
|
||||
assert_eq!(parsed.peers().len(), 2, "{label} lost a peer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed() {
|
||||
let original = ConfigDocument::parse(FIXTURE).unwrap();
|
||||
let original_text = text(&original).to_owned();
|
||||
let interface_end = original_text.find("\n\n# Admin note").unwrap();
|
||||
let expected_interface = &original_text[..interface_end + 1];
|
||||
let with_metadata = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "old", Some("2024-02-29T12:30:00Z"))
|
||||
.unwrap();
|
||||
let renamed = with_metadata
|
||||
.rename_client(&key(KEY_A), "new # = \"café\"")
|
||||
.unwrap();
|
||||
let result = text(&renamed);
|
||||
assert!(
|
||||
result.starts_with(expected_interface),
|
||||
"interface bytes changed"
|
||||
);
|
||||
assert!(result.contains("# Another administrator note.\n"));
|
||||
assert!(result.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A\n"));
|
||||
assert!(result.contains("CustomPeerOption = keep-me\n"));
|
||||
assert!(result.contains("# wg-admin:version=1\n"));
|
||||
assert!(result.contains("# wg-admin:created-utc=2024-02-29T12:30:00Z\n"));
|
||||
assert_eq!(
|
||||
renamed.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"new # = \"café\""
|
||||
);
|
||||
assert_eq!(
|
||||
ConfigDocument::parse(renamed.render()).unwrap().render(),
|
||||
renamed.render()
|
||||
);
|
||||
|
||||
let deleted = renamed.delete_client(&key(KEY_A)).unwrap();
|
||||
let deleted_text = text(&deleted);
|
||||
assert!(
|
||||
deleted_text.starts_with(expected_interface),
|
||||
"delete changed interface bytes"
|
||||
);
|
||||
assert!(!deleted_text.contains(KEY_A));
|
||||
assert!(deleted_text.contains("# Admin note: retained between interface and peer.\n"));
|
||||
assert!(deleted_text.contains("# Another administrator note.\n"));
|
||||
assert!(deleted_text.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B\n"));
|
||||
assert_eq!(deleted.peers().len(), 1);
|
||||
assert_eq!(deleted.peers()[0].identity, Some(key(KEY_B)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_in_mixed_ending_metadata_changes_only_alias_representation() {
|
||||
let source = format!(
|
||||
"# admin preface\r\n# wg-admin:begin\n# wg-admin:version=1\r\n# wg-admin:alias=b2xk\n# wg-admin:created-utc=2024-01-02T03:04:05Z\r\n# wg-admin:end\n[Peer]\r\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\r\nPresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A"
|
||||
);
|
||||
let original = ConfigDocument::parse(&source).unwrap();
|
||||
let renamed = original.rename_client(&key(KEY_A), "new").unwrap();
|
||||
let expected = source.replace("# wg-admin:alias=b2xk", "# wg-admin:alias=bmV3");
|
||||
assert!(
|
||||
text(&renamed) == expected,
|
||||
"rename must replace only alias bytes"
|
||||
);
|
||||
assert_eq!(renamed.project_client(&key(KEY_A)).unwrap().alias, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_delete_and_repeated_edits_recompute_spans_without_touching_neighbors() {
|
||||
let source = "[Interface]\nListenPort = 51820\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.44.0.2/32\n\n# inter-peer note\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.44.0.3/32\n\n[Peer]\nPublicKey = AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\nAllowedIPs = 10.44.0.4/32";
|
||||
let original = ConfigDocument::parse(source).unwrap();
|
||||
let added = original
|
||||
.add_client(&client(KEY_D, "duplicate alias", 9), None)
|
||||
.unwrap();
|
||||
assert!(
|
||||
text(&added).starts_with(source),
|
||||
"add changed existing source bytes"
|
||||
);
|
||||
let with_duplicate_aliases = added
|
||||
.insert_legacy_metadata(&key(KEY_A), "shared alias", None)
|
||||
.unwrap()
|
||||
.insert_legacy_metadata(&key(KEY_B), "shared alias", None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
with_duplicate_aliases
|
||||
.project_client(&key(KEY_A))
|
||||
.unwrap()
|
||||
.alias,
|
||||
with_duplicate_aliases
|
||||
.project_client(&key(KEY_B))
|
||||
.unwrap()
|
||||
.alias
|
||||
);
|
||||
|
||||
let delete_first = original.delete_client(&key(KEY_A)).unwrap();
|
||||
assert!(text(&delete_first).contains("# inter-peer note\n"));
|
||||
assert!(text(&delete_first).contains(KEY_B));
|
||||
assert!(text(&delete_first).contains(KEY_C));
|
||||
let delete_last = delete_first.delete_client(&key(KEY_C)).unwrap();
|
||||
assert!(text(&delete_last).contains(KEY_B));
|
||||
assert!(!text(&delete_last).contains(KEY_C));
|
||||
let delete_only_peer = delete_last.delete_client(&key(KEY_B)).unwrap();
|
||||
assert!(delete_only_peer.peers().is_empty());
|
||||
|
||||
let twice_renamed = added
|
||||
.rename_client(&key(KEY_D), "#marker = \"café\"")
|
||||
.unwrap()
|
||||
.rename_client(&key(KEY_D), "duplicate alias")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
twice_renamed.project_client(&key(KEY_D)).unwrap().alias,
|
||||
"duplicate alias"
|
||||
);
|
||||
assert_eq!(text(&twice_renamed).matches("[Peer]").count(), 4);
|
||||
assert!(text(&twice_renamed).contains("# inter-peer note\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_issues_are_preserved_and_unsafe_edits_fail_without_mutation() {
|
||||
let cases: [MetadataCase; 3] = [
|
||||
(
|
||||
"unknown version",
|
||||
"# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2xk\n# wg-admin:end\n",
|
||||
|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }),
|
||||
),
|
||||
(
|
||||
"unknown field",
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:future=x\n# wg-admin:end\n",
|
||||
|issue| matches!(issue, MetadataIssue::UnknownField { .. }),
|
||||
),
|
||||
(
|
||||
"duplicate field",
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:alias=b3RoZXI\n# wg-admin:end\n",
|
||||
|issue| matches!(issue, MetadataIssue::UnknownField { .. }),
|
||||
),
|
||||
];
|
||||
for (label, block, matches_issue) in cases {
|
||||
let source = format!("{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n");
|
||||
let parsed = ConfigDocument::parse(&source).unwrap();
|
||||
assert!(
|
||||
matches!(&parsed.peers()[0].metadata, MetadataAttachment::Invalid(issue) if matches_issue(issue)),
|
||||
"{label} not reported"
|
||||
);
|
||||
assert!(matches!(
|
||||
parsed.rename_client(&key(KEY_A), "safe"),
|
||||
Err(EditError::UnsafeDocument | EditError::UnsafePeer)
|
||||
));
|
||||
assert!(
|
||||
parsed.render() == source.as_bytes(),
|
||||
"{label} rejection mutated source"
|
||||
);
|
||||
}
|
||||
|
||||
let malformed = format!(
|
||||
"# wg-admin:begin \n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&malformed).unwrap();
|
||||
assert!(!parsed.issues().is_empty());
|
||||
assert!(parsed.rename_client(&key(KEY_A), "safe").is_err());
|
||||
assert!(
|
||||
parsed.render() == malformed.as_bytes(),
|
||||
"malformed input bytes changed"
|
||||
);
|
||||
|
||||
let misattached = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n# admin intervenes\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&misattached).unwrap();
|
||||
assert!(parsed.issues().iter().any(|issue| matches!(
|
||||
issue,
|
||||
DocumentIssue::ManagedMetadata(MetadataIssue::Misattached { .. })
|
||||
)));
|
||||
assert!(parsed.rename_client(&key(KEY_A), "safe").is_err());
|
||||
assert!(
|
||||
parsed.render() == misattached.as_bytes(),
|
||||
"misattached input bytes changed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_adjacent_metadata_blocks_and_ambiguous_peers_remain_visible() {
|
||||
let block = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b25l\n# wg-admin:end\n";
|
||||
let duplicate_blocks =
|
||||
format!("{block}{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n");
|
||||
let parsed = ConfigDocument::parse(&duplicate_blocks).unwrap();
|
||||
assert!(matches!(
|
||||
&parsed.peers()[0].metadata,
|
||||
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
|
||||
));
|
||||
assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err());
|
||||
assert!(
|
||||
parsed.render() == duplicate_blocks.as_bytes(),
|
||||
"duplicate blocks changed"
|
||||
);
|
||||
|
||||
let ambiguous = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\nPublicKey = {KEY_B}\n\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.3/32\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&ambiguous).unwrap();
|
||||
assert!(
|
||||
parsed.peers()[0]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePublicKeyField { .. }))
|
||||
);
|
||||
assert!(
|
||||
parsed.peers()[1]
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
|
||||
);
|
||||
assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err());
|
||||
assert!(
|
||||
parsed.render() == ambiguous.as_bytes(),
|
||||
"ambiguous source bytes changed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_encode_unicode_and_syntax_and_reject_control_injection() {
|
||||
for alias in ["# café = \"laptop\"", "# wg-admin:begin", "x=y # z"] {
|
||||
let document = ConfigDocument::parse(format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
))
|
||||
.unwrap()
|
||||
.insert_legacy_metadata(&key(KEY_A), alias, None)
|
||||
.unwrap();
|
||||
assert_eq!(document.project_client(&key(KEY_A)).unwrap().alias, alias);
|
||||
assert!(text(&document).contains("# wg-admin:alias="));
|
||||
let rendered_alias = text(&document)
|
||||
.lines()
|
||||
.find(|line| line.starts_with("# wg-admin:alias="))
|
||||
.expect("metadata has an alias line");
|
||||
assert!(!rendered_alias.contains(alias));
|
||||
}
|
||||
|
||||
let original = ConfigDocument::parse(format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
||||
))
|
||||
.unwrap();
|
||||
for alias in ["line\nbreak", "carriage\rreturn", "terminal\u{001b}escape"] {
|
||||
assert!(matches!(
|
||||
original.insert_legacy_metadata(&key(KEY_A), alias, None),
|
||||
Err(EditError::InvalidAlias)
|
||||
));
|
||||
assert_eq!(original.render().last(), Some(&b'\n'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection_fails() {
|
||||
let cases = [
|
||||
("multiple host routes", "10.44.0.2/32, 10.44.0.22/32"),
|
||||
("network route", "10.44.0.0/24"),
|
||||
("default route", "0.0.0.0/0"),
|
||||
("IPv6 only", "fd42::2/128"),
|
||||
];
|
||||
for (label, routes) in cases {
|
||||
let source = format!(
|
||||
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = {routes}\n"
|
||||
);
|
||||
let parsed = ConfigDocument::parse(&source).unwrap();
|
||||
let peer = &parsed.peers()[0];
|
||||
assert!(
|
||||
parsed.render() == source.as_bytes(),
|
||||
"{label} round trip changed bytes"
|
||||
);
|
||||
match label {
|
||||
"multiple host routes" => assert!(peer.issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }
|
||||
))),
|
||||
"network route" | "default route" => assert!(peer.issues.iter().any(|issue| matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 0 }
|
||||
))),
|
||||
"IPv6 only" => assert!(peer.issues.contains(&PeerIssue::MissingIpv4HostRoute)),
|
||||
_ => unreachable!("test case has an expected route shape"),
|
||||
}
|
||||
let issue_debug = format!("{:?}", peer.issues);
|
||||
assert!(
|
||||
!issue_debug.contains(routes),
|
||||
"{label} issue exposed route text"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses),
|
||||
"{label} projection"
|
||||
);
|
||||
let renamed = parsed.rename_client(&key(KEY_A), "updated").unwrap();
|
||||
let expected = source.replace("# wg-admin:alias=Y2xpZW50", "# wg-admin:alias=dXBkYXRlZA");
|
||||
assert_eq!(
|
||||
text(&renamed),
|
||||
expected,
|
||||
"{label} rename changed other bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() {
|
||||
let document = ConfigDocument::parse(FIXTURE).unwrap();
|
||||
assert_secret_free_debug(&document);
|
||||
assert_secret_free_debug(&document.peers());
|
||||
let duplicate_identity = document
|
||||
.add_client(&client(KEY_A, "duplicate", 9), None)
|
||||
.unwrap_err();
|
||||
assert_secret_free_debug(&duplicate_identity);
|
||||
let invalid = ConfigDocument::parse(b"[Interface]\nPrivateKey = SYNTHETIC\n\xff").unwrap_err();
|
||||
assert_secret_free_debug(&invalid);
|
||||
assert!(!invalid.to_string().contains("SYNTHETIC"));
|
||||
|
||||
let original_bytes = document.render().to_vec();
|
||||
assert!(document.rename_client(&key(KEY_C), "not present").is_err());
|
||||
assert!(document.delete_client(&key(KEY_C)).is_err());
|
||||
assert!(
|
||||
document.render() == original_bytes.as_slice(),
|
||||
"failed edits changed source bytes"
|
||||
);
|
||||
}
|
||||
+22
@@ -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
|
||||
Reference in New Issue
Block a user