Add server configuration editing with byte preservation

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