From c2b899dd1e1788ffadea4a4c80ecd8affa063908 Mon Sep 17 00:00:00 2001 From: sasa Date: Sun, 18 Oct 2026 21:42:33 +0300 Subject: [PATCH] Define Rust environment and toolchain --- .gitignore | 4 + AGENTS.md | 4 +- Cargo.lock | 244 +++++++++ Cargo.toml | 22 + PLANS.md | 226 +++++++- README.md | 41 ++ config/wireguard-admin.example.toml | 17 + docs/ENVIRONMENT.md | 74 +++ docs/PROJECT_LAYOUT.md | 1 + rust-toolchain.toml | 5 + src/config.rs | 509 ++++++++++++++++++ src/domain.rs | 791 ++++++++++++++++++++++++++++ src/lib.rs | 3 + src/main.rs | 34 ++ src/startup.rs | 92 ++++ 15 files changed, 2058 insertions(+), 9 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 config/wireguard-admin.example.toml create mode 100644 docs/ENVIRONMENT.md create mode 100644 rust-toolchain.toml create mode 100644 src/config.rs create mode 100644 src/domain.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/startup.rs diff --git a/.gitignore b/.gitignore index 893d2a0..e0d736d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,8 @@ target/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Local deployment data and executable-relative settings; keep config examples tracked. +/Backup/ +/wireguard-admin.toml /.obsidian/ diff --git a/AGENTS.md b/AGENTS.md index 640bf32..61abca0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation. -The project currently contains design/configuration documents, not an implemented Rust application. Update this statement when scaffolding is created. Do not report planned code, tests, or commands as already working. +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. - 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. @@ -26,6 +26,8 @@ The project currently contains design/configuration documents, not an implemente ## Development rules and reference map +Use the pinned Rust toolchain and environment checks in [ENVIRONMENT.md](docs/ENVIRONMENT.md). System-packaged tools do not automatically honor rust-toolchain.toml. + | When working on | Read first | | --------------------------------------------------------- | ------------------------------------------- | | Module layout and boundaries | [PROJECT_LAYOUT.md](docs/PROJECT_LAYOUT.md) | diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6aaa344 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,244 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wireguard-admin" +version = "0.1.0" +dependencies = [ + "base64", + "ipnet", + "rustix", + "serde", + "thiserror", + "toml", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d0b3353 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wireguard-admin" +version = "0.1.0" +edition = "2024" +rust-version = "1.97.1" +description = "A native terminal application for managing one existing WireGuard interface" + +[lib] +name = "wireguard_admin" +path = "src/lib.rs" + +[[bin]] +name = "wireguard-admin" +path = "src/main.rs" + +[dependencies] +base64 = "=0.22.1" +ipnet = "=2.11.0" +rustix = { version = "=1.1.5", default-features = false, features = ["process"] } +serde = { version = "=1.0.229", features = ["derive"] } +thiserror = "=2.0.20" +toml = "=1.1.8" diff --git a/PLANS.md b/PLANS.md index e112402..f51e411 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ This is the living execution plan for WireGuard Admin version 1. It records milestone scope, progress, review findings, and evidence so a new agent can resume from repository files without relying on chat history. -Read [AGENTS.md](AGENTS.md), [the specification](docs/SPEC.md), [architectural decisions](docs/adr/), [implementation order](docs/IMPLEMENTATION.md), and [the glossary](CONTEXT.md) before execution. The specification defines behavior; this plan schedules its delivery. Approved dependencies and Alpine/VM testing rules are in AGENTS.md. Do not change requirements to make a milestone pass. +Read [AGENTS.md](AGENTS.md), [the specification](docs/SPEC.md), [architectural decisions](docs/adr/), [implementation order](docs/IMPLEMENTATION.md), and [the glossary](CONTEXT.md) before execution. The specification defines behavior; this plan schedules its delivery. Approved dependencies are in [DEPENDENCIES.md](docs/DEPENDENCIES.md); Alpine/VM testing rules are in [TESTING.md](docs/TESTING.md). Do not change requirements to make a milestone pass. Creating this plan does not start implementation or change the active model. All milestones below are initially pending, and no validation results are claimed. @@ -110,7 +110,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | ID | Milestone | Prerequisites | State | | --- | -------------------------------------------------------- | ----------------------------------------- | ------- | -| 1 | Rust foundation, settings, and domain states | None | pending | +| 1 | Rust foundation, settings, and domain states | None | accepted | | 2 | Server configuration editing with byte preservation | 1 | pending | | 3 | Client keys, encrypted vault, and existing-client import | 2 | pending | | 4 | Backups, safe Commit, and full restoration | 3 | pending | @@ -122,9 +122,218 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo ### Milestone 1 — Rust foundation, settings, and domain states -Create one Cargo package with thin startup/library separation, reproducible lockfile, validated TOML settings, executable-relative paths, public-key identities, and explicit draft/saved/applied states. Implement dual-stack first-free allocation and deferred address release. Provide help/version without root or vault access; management startup requires root. Do not create a nonfunctional full module tree. +Status: accepted (2026-10-18T18:59:35Z; independent R2 re-review and coordinator gates passed). +Prerequisites: no previous milestone; verified GPT-6-Sol/medium coordinator and +GPT-6-Luna/high workers, the pinned native Rust environment, and registry access +or cached approved dependencies. Docker, root, a running VPN, and musl linking +are not required for this milestone. -Acceptance: settings and allocation tests cover invalid input, exhaustion, IPv4-only existing clients, and reserved deleted addresses; path resolution does not depend on the working directory. Build, default tests, formatting, and Clippy pass without touching live services. +#### Outcome and scope + +Deliver a compilable single-package foundation that can read validated settings +relative to the executable, represent client identities and draft/saved/applied +states, and allocate IPv4/IPv6 addresses without touching a real server. +Startup supports `--help` and `--version` without root, settings, or vault access. +Management startup checks root before attempting privileged operations. + +This is a foundation, not a working VPN manager. Until later milestones wire in +the TUI, management startup must explain that the management interface is not +implemented and exit nonzero without reading a real server config or changing +anything. Do not display a successful management session for a placeholder. + +Read SPEC.md sections Purpose, Data locations, Data sources, New client parameters, +and Operations and states; all three ADRs; and docs/ENVIRONMENT.md, +docs/PROJECT_LAYOUT.md, docs/DEPENDENCIES.md, and docs/DEVELOPMENT.md. +No WireGuard document edits, key generation, vault/import, backups/Commit writes, +systemd/runtime adapters, QR, TUI, or container infrastructure are implemented here. + +#### Context and implementation steps + +At preparation, the repository contained design/Codex documents and +rust-toolchain.toml, but no Cargo.toml, Cargo.lock, or source tree. The +toolchain pin is 1.97.1 and the created manifest uses edition 2024. Local +system tools match; rustup is absent. +Musl standard library/linker readiness remains deferred to milestone 8. + +Execute the tasks below sequentially with one implementation writer. For each +task dispatch, the coordinator supplies exact document sections and the interface +contract established by the prior task. Workers do not edit PLANS.md or accept work. + +| Task | Owner | Files and deliverable | +| ---- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| M1.1 | implementer | Cargo.toml, Cargo.lock, src/main.rs, src/lib.rs, narrow startup/error modules as needed, README.md and .gitignore: package scaffolding, arguments, root boundary, safe incomplete-management behavior | +| M1.2 | implementer | src/config.rs, config/wireguard-admin.example.toml, settings tests: typed settings, validation, and executable-relative paths | +| M1.3 | implementer | src/domain.rs and child modules only if needed, domain tests: public-key identity, change states, address allocation and reservation rules | +| M1.4 | reviewer, then repairer as needed | Independently inspect the integrated M1 changes and acceptance evidence; coordinator assigns bounded repair files and finding IDs | +| M1.5 | coordinator | Run integrated gates, update outdated scaffolding statements, record evidence and acceptance in this plan | + +M1.1 creates package `wireguard-admin`, binary `wireguard-admin`, library +`wireguard_admin`, edition 2024, and rust-version 1.97.1. Keep main thin, parse +only the small agreed argument surface with std, and test the effective-UID +boundary through an injectable/pure decision rather than requiring root tests. +Unknown arguments fail clearly. Preserve existing documentation/configuration. +Add narrow ignores for local deployment data without hiding config examples or +synthetic fixtures. Do not create placeholder files for all future modules. + +M1.2 uses TOML with explicit required fields for interface name, server config +path, client source directory, server endpoint, IPv4 and IPv6 allocation ranges, +DNS addresses, systemd unit, and nonzero restart timeout. Data root is optional +and defaults to executable-directory/Backup with data and archives beneath it. +Choose and document a fixed settings filename alongside the executable; do not +add a new CLI settings override. Resolve relative configured paths consistently +against that directory, never the process working directory. Separate parsing, +validation, and resolution so tests do not read the host environment. Do not +require referenced production files or systemd to exist to validate settings. + +Validate address families, ranges, endpoint syntax, usable service/interface +names, DNS values, and timeout. Do not resolve DNS over the network in a settings +test. Endpoint validation must support a hostname or IP with a port and bracketed +IPv6. Document the resulting settings schema in the example and README. + +M1.3 represents a public key as a validated 32-byte identity with standard +WireGuard Base64 parsing; duplicate aliases remain valid. Keep applied snapshots +separate from saved and draft state. Model unavailable runtime state explicitly. +Pass existing/server/reserved addresses as inputs; do not query real interfaces. +Deletion from draft alone must not reserve an address as if saved; committed +removal retains the address until an explicit successful-apply transition. +Discarded drafts restore saved allocation availability. This milestone tests +these transitions as pure model behavior, not an actual Commit implementation. + +Allocate the first usable free address with bounded work proportional to the +occupied set, not subnet size. Exclude supplied server/occupied/reserved addresses +and IPv4 network/broadcast addresses where applicable. Handle /31, /32 and narrow +IPv6 ranges explicitly, avoid arithmetic overflow, and return clear exhaustion +errors. Manual addresses must match the configured family/range and be available. +Do not silently add IPv6 to imported IPv4-only model records. + +Approved direct dependencies for this milestone: serde (derive), toml, thiserror, +ipnet, time when timestamps are needed, base64 for public-key representations, +and rustix for the effective-UID query. Use tempfile as a dev-dependency for +filesystem/subprocess fixtures if needed. Select compatible versions/features; +no unapproved crate or premature crypto/TUI/container dependency. Generate the +initial Cargo.lock once dependency choices are resolved; then use --locked. + +#### Acceptance and validation + +- [x] M1-A1: package/library/binary compile under the pinned native toolchain; + Cargo.lock is tracked and no unrelated dependency or file scaffolding is added. +- [x] M1-A2: help/version work without root or settings, unknown arguments fail, + and management startup rejects non-root before privileged access. Root-path + behavior is covered by injection, without running the application as root. +- [x] M1-A3: valid TOML yields typed settings; missing/invalid fields produce + actionable errors without opening a real server config, vault, or service. +- [x] M1-A4: executable-relative paths and defaults remain identical from two + different working directories; test via pure path inputs or subprocesses, + not parallel process-wide current-directory mutation. +- [x] M1-A5: valid/invalid public keys, repeated aliases, explicit unknown runtime + state, draft discard, and saved/applied divergence have behavioral coverage. +- [x] M1-A6: IPv4/IPv6 first-free selection, manual selection, occupied/server + exclusions, tiny ranges, exhaustion, and deferred release are verified. A large + IPv6 range test demonstrates that allocation does not scan the entire subnet. +- [x] M1-A7: all standard gates pass without root/Docker/production access; + a fresh independent reviewer has no unresolved actionable findings. +- [x] M1-A8: README describes the limited executable and setup honestly; commands, + example settings, and source paths match actual implementation. Update the + design-only statement in AGENTS.md when source scaffolding really exists. + +Planned commands from the repository root after Cargo.toml and lockfile exist: + +```sh +cargo metadata --locked --no-deps --format-version 1 +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 +``` + +Expected: zero exits for these commands, successful help/version without settings, +and all ordinary tests pass as an unprivileged user. Argument/root rejection and +working-directory independence are exercised by tests. Record actual focused test +commands when test names exist. Do not execute a bare management launch as root. +No Alpine build, container tunnel, or VM/systemd check is claimed by M1 acceptance. + +#### Progress + +- [x] Preparation: toolchain configuration and environment observations recorded; + this is not application implementation or milestone acceptance. +- [x] M1.1 — implementer (`/root/implementer_m1_1`), 2026-10-18T18:47:43Z; package and startup boundary integrated. +- [x] M1.2 — implementer (`/root/implementer_m1_2`), 2026-10-18T18:50:19Z; typed settings, validation, paths, example, and tests integrated. +- [x] M1.3 — implementer (`/root/implementer_m1_3`), 2026-10-18T18:54:21Z; domain model, allocation, and tests integrated. +- [x] M1.4 — fresh reviewer (`/root/reviewer_m1_r1`) R1 and R2, repairer (`/root/repairer_m1_r1`) for domain finding; all findings closed 2026-10-18T18:59:35Z. +- [x] M1.5 — coordinator integrated validation and acceptance completed 2026-10-18T18:59:35Z. + +#### Review and repair + +| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State | +| --- | --- | --- | --- | --- | --- | +| M1-R1-01 | R1/R2 | `src/domain.rs` constructor can claim verified without a matching applied snapshot; misleading state | repairer | constructor rejects inconsistent Verified; Commit/mismatch demote stale Verified; 13 focused, 22 total tests passed; R2 confirmed | closed | +| M1-R1-02 | R1/R2 | `AGENTS.md` still claims no Rust application exists | coordinator | opening statement updated to limited scaffold; R2 confirmed | closed | +| M1-R1-03 | R1/R2 | `Cargo.lock` existed outside Git index | coordinator | `git add -N Cargo.lock`; `git ls-files --error-unmatch Cargo.lock` succeeds; R2 confirmed; no commit/content staging | closed | + +#### Evidence and handoff + +Preparation verified local rustc/Cargo 1.97.1, formatter and Clippy availability; +rustup/musl readiness limitations are in docs/ENVIRONMENT.md. This execution +verified the active coordinator turn as `gpt-6-sol`/`medium` from session +turn context; project agent files and `[agents]` defaults specify +`gpt-6-luna`/`high`. The repository had no Cargo manifest/source and a clean +worktree at 2026-10-18T18:45:43Z. Current rustc/Cargo are 1.97.1, with +rustfmt 1.9.0 and Clippy 0.1.97. No prior milestone exists. +M1.1 added `Cargo.toml`, `Cargo.lock`, `src/{main,lib,startup}.rs`, and updated +README/.gitignore. The coordinator inspected those files and the working tree. +The worker reported passing locked metadata, fmt, Clippy, four startup tests, +release build, release help/version, and diff check; integrated M1 gates remain +pending. Cargo registry search failed, but approved `rustix` 1.1.5 resolved +from local cache. +M1.2 added `src/config.rs`, the example settings TOML, approved +`serde`/`toml`/`thiserror`/`ipnet` dependencies, and README settings guidance. +The coordinator inspected the files. The worker reported five focused settings +tests and nine total tests passing, along with locked metadata, fmt, Clippy, +release build, and diff check. Cargo resolution required an approved +network-enabled retry after sandbox DNS failure. Independent review remains +pending. M1.3 added `src/domain.rs`, its library declaration, and approved +`base64` dependency. The coordinator inspected the files. The worker reported +12 focused domain tests, 21 total tests, locked metadata, fmt, Clippy, and +release build passing. The three implementer turns were independently verified +from their session turn contexts as `gpt-6-luna`/`high`. Next: fresh review of +the integrated milestone. +R1 reviewer turn context was also verified as `gpt-6-luna`/`high`. R1 ran +locked metadata, fmt, Clippy, 21 tests, release build, and help/version; all +passed. It reported findings M1-R1-01 through M1-R1-03 above. No production, +container, VM, or root management launch was used. +Repairer turn context was verified as `gpt-6-luna`/`high`. Its domain repair +passed 13 focused tests, 22 total tests, fmt, Clippy, and release build. +The coordinator updated AGENTS.md and marked Cargo.lock intent-to-add in the +Git index after a sandbox write denial and approved escalation. `git ls-files` +now recognizes it; no Git commit was created. R2 re-review closed all three findings with no new +actionable issues. Its turn context was verified as `gpt-6-luna`/`high`. + +Coordinator integrated gates on 2026-10-18: `cargo metadata --locked +--no-deps --format-version 1`, `cargo fmt --all -- --check`, `cargo clippy +--locked --all-targets -- -D warnings`, `cargo test --locked` (22 passed), +`cargo build --locked --release`, release `--help`, release `--version`, and +`git diff --check` all exited 0. Unknown argument and a bare management launch +as UID 1000 exited 2 with clear diagnostics, as expected. `git ls-files +--error-unmatch Cargo.lock` exited 0; the file is intent-to-add in the Git +index and its contents are not staged or committed. Unit/fixture evidence only; +Alpine WireGuard and disposable systemd VM checks are later milestones and +were not run. No production configuration, key, service, or VPN was accessed. + +#### Decisions and discoveries + +Use the already installed native compiler release as the reproducible baseline, +not an unpinned stable channel. The toolchain file is consumed by rustup proxies; +the current system-packaged binaries require explicit version checks. This is an +environment constraint, not permission to install packages or alter global tools. + +#### Outcome + +Accepted 2026-10-18T18:59:35Z after independent review and integrated gates. +The executable currently exposes help/version and safe startup errors; settings +and domain operations are library logic. Management/TUI behavior remains for +later milestones. Stop here; next milestone is 2 and has not started. ### Milestone 2 — Server configuration editing with byte preservation @@ -176,10 +385,11 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele ## Current progress and resume point -- All milestones are pending; application implementation has not started. -- The approved dependency list and container testing methodology are recorded in AGENTS.md. -- Next action when execution is requested: verify the coordinator model/effort, inspect the repository, expand milestone 1, and dispatch its first bounded worker task. -- No workers have been dispatched by creation of this document; no build, container, or VM results are claimed. +- Milestone 1 is accepted; milestones 2–9 remain pending. +- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). +- Milestone 1 is expanded and the Rust environment is pinned. +- Next action, only on a later request: prepare Milestone 2; no work on it has started. +- M1 integrated gates and independent re-review passed. No container or VM results are claimed. ## Plan maintenance diff --git a/README.md b/README.md index a3460c1..572e0bd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ # wireguard-admin +This repository now contains the initial Rust package, startup boundary, and +validated settings loader for WireGuard Admin. Client management and the VPN +management interface have not been implemented yet. + +The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From +the repository root, run: + +```sh +cargo run -- --help +cargo run -- --version +``` + +`--help` and `--version` work without root. Starting with no arguments requires +root; until the management interface is implemented, startup exits with an +error and does not read server configuration or vault files. + +## Settings + +Settings use TOML in the fixed file `wireguard-admin.toml` beside the +executable. Start from [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml). +The required fields are: + +- `interface_name`: existing WireGuard interface name (1–15 ASCII characters). +- `server_config_path`: path to the existing server configuration. +- `client_source_directory`: directory containing existing client files. +- `server_endpoint`: hostname or IPv4 address followed by `:port`, or IPv6 in + brackets followed by `:port` (for example, `[2001:db8::2]:51820`). +- `ipv4_allocation_range` and `ipv6_allocation_range`: CIDR ranges of the + matching address family. +- `dns_addresses`: non-empty list of IPv4 and/or IPv6 literal addresses. +- `systemd_unit`: service unit name ending in `.service`. +- `restart_timeout_seconds`: positive integer. + +`data_root` is optional. It defaults to `Backup` beside the executable; +current internal data and the encrypted vault are under `data`, and archives +are under `archives`. Relative configured paths, including an explicit +`data_root`, resolve from the executable directory. Absolute paths remain +absolute. Settings validation checks syntax and values only; it does not +require the referenced files, directories, or service to exist and does not +perform DNS lookups. + diff --git a/config/wireguard-admin.example.toml b/config/wireguard-admin.example.toml new file mode 100644 index 0000000..9ef562e --- /dev/null +++ b/config/wireguard-admin.example.toml @@ -0,0 +1,17 @@ +# Copy this file to wireguard-admin.toml beside the wireguard-admin executable. +# Relative paths are resolved from that executable directory, never from the +# process working directory. This example uses documentation-only addresses. + +interface_name = "wg0" +server_config_path = "etc/wireguard/wg0.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example.invalid:51820" +ipv4_allocation_range = "192.0.2.0/24" +ipv6_allocation_range = "2001:db8:1::/64" +dns_addresses = ["192.0.2.1", "2001:db8:1::1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 + +# Optional. Defaults to Backup beside the executable. The application stores +# current data in /data and archives in /archives. +# data_root = "Backup" diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md new file mode 100644 index 0000000..c71a63c --- /dev/null +++ b/docs/ENVIRONMENT.md @@ -0,0 +1,74 @@ +# Rust Development Environment + +## Pinned baseline + +[rust-toolchain.toml](../rust-toolchain.toml) pins Rust **1.97.1**, the minimal +profile, and `rustfmt`/`clippy`. The native development host is +`x86_64-unknown-linux-gnu`; the additional `x86_64-unknown-linux-musl` target is +for executables running in Alpine integration containers. The host target is +implicitly included by rustup. Do not force all Cargo builds to musl. + +Use Rust edition 2024 when creating Cargo.toml. Set its `rust-version` to +`1.97.1` as the initial supported compiler floor; this is distinct from the +exact toolchain pin. Do not claim compatibility with older compilers without +testing it. Keep Cargo.lock tracked once the package exists. + +## Toolchain selection + +With rustup-managed Cargo/rustc proxies, entering the repository selects the +toolchain file and may download missing components. Check `rustup show` and +`rustup target list --installed`; explicit overrides may take precedence. +Do not automatically install a global toolchain or modify the user's PATH. + +This host currently uses system-packaged tools under `/usr/bin`, without +`rustup`. Such binaries do not enforce rust-toolchain.toml. Before execution, +verify the installed compiler and Cargo match the pin, and that formatter and +Clippy are available. If versions differ, stop the affected build and report +the mismatch rather than silently updating the pin. + +Checks available before Cargo scaffolding: + +```sh +rustc -Vv +cargo -V +rustfmt --version +cargo clippy --version +``` + +After scaffolding, use the completion commands in [AGENTS.md](../AGENTS.md). +The first dependency resolution generates Cargo.lock; subsequent checks use +`--locked`. Registry access or a populated cache is required for dependencies. + +## Alpine target + +The toolchain file requests the musl Rust standard library; it does not supply +all native compilers/linkers needed by a cross build. Establish the musl linker +and any C dependency requirements before milestone 8, or build in a dedicated +Alpine/musl builder. Keep build tools out of the small runtime test image. + +Once prerequisites and Cargo scaffolding exist, the planned cross-build check is: + +```sh +cargo build --locked --release --target x86_64-unknown-linux-musl +``` + +Then run the built executable in Alpine and inspect actual dynamic-library +requirements. A target name alone is not evidence that the binary runs there. +Musl readiness is a milestone 8 prerequisite, not a milestone 1 acceptance gate. + +## Observed preparation state + +During preparation, local commands reported: + +- rustc: `1.97.1`, host `x86_64-unknown-linux-gnu`. +- Cargo: `1.97.1`. +- rustfmt: `1.9.0-stable`. +- Clippy: `0.1.97`. +- `rustup` and `musl-gcc`: not found in PATH. +- The musl standard library directory reported by rustc does not exist. + +No toolchain, component, target, or native package installation was performed. +These observations establish tool availability, not a successful project build. +Recheck the environment at execution time. + +Reference: [rustup toolchain files and overrides](https://rust-lang.github.io/rustup/overrides.html#the-toolchain-file). diff --git a/docs/PROJECT_LAYOUT.md b/docs/PROJECT_LAYOUT.md index c984cb7..7a55104 100644 --- a/docs/PROJECT_LAYOUT.md +++ b/docs/PROJECT_LAYOUT.md @@ -11,6 +11,7 @@ The following is the intended layout, not a claim that these files already exist ```text Cargo.toml Cargo.lock +rust-toolchain.toml AGENTS.md CONTEXT.md PLANS.md diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..362cd95 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["rustfmt", "clippy"] +targets = ["x86_64-unknown-linux-musl"] diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dc25edd --- /dev/null +++ b/src/config.rs @@ -0,0 +1,509 @@ +//! Typed application settings, validation, and executable-relative paths. + +use std::net::{IpAddr, Ipv4Addr}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration; + +use serde::Deserialize; +use thiserror::Error; + +/// Fixed settings filename, located beside the executable. +pub const SETTINGS_FILE_NAME: &str = "wireguard-admin.toml"; + +/// TOML representation. Parsing is separate from semantic validation and path resolution. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SettingsFile { + pub interface_name: String, + pub server_config_path: PathBuf, + pub client_source_directory: PathBuf, + pub server_endpoint: String, + pub ipv4_allocation_range: String, + pub ipv6_allocation_range: String, + pub dns_addresses: Vec, + pub systemd_unit: String, + pub restart_timeout_seconds: u64, + #[serde(default)] + pub data_root: Option, +} + +/// Settings with validated values but paths not yet anchored to the executable. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedSettings { + pub interface_name: String, + pub server_config_path: PathBuf, + pub client_source_directory: PathBuf, + pub server_endpoint: ServerEndpoint, + pub ipv4_allocation_range: ipnet::Ipv4Net, + pub ipv6_allocation_range: ipnet::Ipv6Net, + pub dns_addresses: Vec, + pub systemd_unit: String, + pub restart_timeout: Duration, + pub data_root: Option, +} + +/// Fully resolved application paths. No configured path is checked for existence here. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedSettings { + pub interface_name: String, + pub server_config_path: PathBuf, + pub client_source_directory: PathBuf, + pub server_endpoint: ServerEndpoint, + pub ipv4_allocation_range: ipnet::Ipv4Net, + pub ipv6_allocation_range: ipnet::Ipv6Net, + pub dns_addresses: Vec, + pub systemd_unit: String, + pub restart_timeout: Duration, + pub data_root: PathBuf, + pub data_directory: PathBuf, + pub archives_directory: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServerEndpoint { + pub host: EndpointHost, + pub port: u16, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EndpointHost { + Hostname(String), + Ip(IpAddr), +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SettingsError { + #[error("invalid TOML settings: {0}")] + Parse(String), + #[error("invalid {field}: {reason}")] + InvalidField { field: &'static str, reason: String }, +} + +impl SettingsFile { + /// Parse TOML only; semantic constraints are checked by [`Self::validate`]. + pub fn parse_toml(input: &str) -> Result { + toml::from_str(input).map_err(|error| SettingsError::Parse(error.to_string())) + } + + /// Validate names, network values, endpoint syntax, and timeout without filesystem or network access. + pub fn validate(self) -> Result { + validate_interface_name(&self.interface_name)?; + validate_systemd_unit(&self.systemd_unit)?; + validate_path("server_config_path", &self.server_config_path)?; + validate_path("client_source_directory", &self.client_source_directory)?; + if let Some(data_root) = &self.data_root { + validate_path("data_root", data_root)?; + } + let server_endpoint = parse_endpoint(&self.server_endpoint)?; + let ipv4_allocation_range = self + .ipv4_allocation_range + .parse() + .map_err(|_| invalid("ipv4_allocation_range", "must be a valid IPv4 CIDR range"))?; + let ipv6_allocation_range = self + .ipv6_allocation_range + .parse() + .map_err(|_| invalid("ipv6_allocation_range", "must be a valid IPv6 CIDR range"))?; + if self.restart_timeout_seconds == 0 { + return Err(invalid( + "restart_timeout_seconds", + "must be greater than zero", + )); + } + if self.dns_addresses.is_empty() { + return Err(invalid( + "dns_addresses", + "must contain at least one IP address", + )); + } + let dns_addresses = self + .dns_addresses + .iter() + .map(|address| { + address.parse::().map_err(|_| { + invalid( + "dns_addresses", + format!("{address:?} is not an IPv4 or IPv6 address"), + ) + }) + }) + .collect::, _>>()?; + + Ok(ValidatedSettings { + interface_name: self.interface_name, + server_config_path: self.server_config_path, + client_source_directory: self.client_source_directory, + server_endpoint, + ipv4_allocation_range, + ipv6_allocation_range, + dns_addresses, + systemd_unit: self.systemd_unit, + restart_timeout: Duration::from_secs(self.restart_timeout_seconds), + data_root: self.data_root, + }) + } +} + +impl ValidatedSettings { + /// Resolve relative configured paths from the executable directory, independent of cwd. + pub fn resolve_paths(self, executable_directory: &Path) -> ResolvedSettings { + let resolve = |path: PathBuf| resolve_from(executable_directory, path); + let data_root = self + .data_root + .map(resolve) + .unwrap_or_else(|| executable_directory.join("Backup")); + let server_config_path = resolve(self.server_config_path); + let client_source_directory = resolve(self.client_source_directory); + + ResolvedSettings { + interface_name: self.interface_name, + server_config_path, + client_source_directory, + server_endpoint: self.server_endpoint, + ipv4_allocation_range: self.ipv4_allocation_range, + ipv6_allocation_range: self.ipv6_allocation_range, + dns_addresses: self.dns_addresses, + systemd_unit: self.systemd_unit, + restart_timeout: self.restart_timeout, + data_directory: data_root.join("data"), + archives_directory: data_root.join("archives"), + data_root, + } + } + + /// Return the fixed settings file path beside the executable directory. + pub fn settings_path(executable_directory: &Path) -> PathBuf { + executable_directory.join(SETTINGS_FILE_NAME) + } +} + +impl ResolvedSettings { + pub fn settings_path(executable_directory: &Path) -> PathBuf { + ValidatedSettings::settings_path(executable_directory) + } +} + +fn resolve_from(executable_directory: &Path, path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + executable_directory.join(path) + } +} + +fn validate_interface_name(name: &str) -> Result<(), SettingsError> { + if name.is_empty() + || name.len() > 15 + || name == "." + || name == ".." + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + { + return Err(invalid( + "interface_name", + "must be 1–15 ASCII letters, digits, '.', '_' or '-', and not '.' or '..'", + )); + } + Ok(()) +} + +fn validate_systemd_unit(unit: &str) -> Result<(), SettingsError> { + if unit.is_empty() + || unit.len() > 255 + || !unit.ends_with(".service") + || !unit.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'@' | b':') + }) + { + return Err(invalid( + "systemd_unit", + "must be a service unit name ending in .service, without a path or whitespace", + )); + } + Ok(()) +} + +fn validate_path(field: &'static str, path: &Path) -> Result<(), SettingsError> { + if path.as_os_str().is_empty() { + return Err(invalid(field, "must not be empty")); + } + Ok(()) +} + +fn parse_endpoint(value: &str) -> Result { + let (host_text, port_text) = if let Some(bracketed) = value.strip_prefix('[') { + let (host, suffix) = bracketed.split_once(']').ok_or_else(|| { + invalid( + "server_endpoint", + "bracketed IPv6 address is missing ']' or a port", + ) + })?; + let port = suffix + .strip_prefix(':') + .ok_or_else(|| invalid("server_endpoint", "expected a port after bracketed host"))?; + let address = IpAddr::from_str(host).map_err(|_| { + invalid( + "server_endpoint", + "brackets are only valid around an IPv6 address", + ) + })?; + if !address.is_ipv6() { + return Err(invalid( + "server_endpoint", + "brackets are only valid around IPv6", + )); + } + (EndpointHost::Ip(address), port) + } else { + let (host, port) = value.rsplit_once(':').ok_or_else(|| { + invalid( + "server_endpoint", + "must include a port; bracket IPv6 addresses", + ) + })?; + if host.contains(':') { + return Err(invalid( + "server_endpoint", + "IPv6 addresses must be enclosed in brackets", + )); + } + let parsed_host = if let Ok(address) = host.parse::() { + EndpointHost::Ip(IpAddr::V4(address)) + } else if valid_hostname(host) { + EndpointHost::Hostname(host.to_owned()) + } else { + return Err(invalid( + "server_endpoint", + "host must be a valid hostname or IP address", + )); + }; + (parsed_host, port) + }; + + let port = port_text + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| { + invalid( + "server_endpoint", + "port must be an integer from 1 through 65535", + ) + })?; + Ok(ServerEndpoint { + host: host_text, + port, + }) +} + +fn valid_hostname(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn invalid(field: &'static str, reason: impl Into) -> SettingsError { + SettingsError::InvalidField { + field, + reason: reason.into(), + } +} + +#[cfg(test)] +mod tests { + use super::{EndpointHost, SETTINGS_FILE_NAME, SettingsError, SettingsFile}; + use std::net::{IpAddr, Ipv4Addr}; + use std::path::Path; + use std::time::Duration; + + const VALID: &str = r#" +interface_name = "wg0" +server_config_path = "etc/wireguard/wg0.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example.org:51820" +ipv4_allocation_range = "10.80.0.0/24" +ipv6_allocation_range = "fd80::/64" +dns_addresses = ["10.80.0.1", "fd80::1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 +"#; + + fn validated(input: &str) -> super::ValidatedSettings { + SettingsFile::parse_toml(input) + .and_then(SettingsFile::validate) + .unwrap_or_else(|error| panic!("fixture settings should validate: {error}")) + } + + #[test] + fn parses_required_values_and_resolves_defaults_relative_to_executable() { + let settings = validated(VALID); + assert_eq!(settings.server_endpoint.port, 51820); + assert_eq!( + settings.dns_addresses[0], + IpAddr::V4(Ipv4Addr::new(10, 80, 0, 1)) + ); + assert_eq!(settings.restart_timeout, Duration::from_secs(30)); + + let resolved = settings.resolve_paths(Path::new("/opt/wireguard-admin")); + assert_eq!( + resolved.server_config_path, + Path::new("/opt/wireguard-admin/etc/wireguard/wg0.conf") + ); + assert_eq!( + resolved.client_source_directory, + Path::new("/opt/wireguard-admin/clients") + ); + assert_eq!(resolved.data_root, Path::new("/opt/wireguard-admin/Backup")); + assert_eq!( + resolved.data_directory, + Path::new("/opt/wireguard-admin/Backup/data") + ); + assert_eq!( + resolved.archives_directory, + Path::new("/opt/wireguard-admin/Backup/archives") + ); + assert_eq!( + super::ResolvedSettings::settings_path(Path::new("/opt/wireguard-admin")), + Path::new("/opt/wireguard-admin").join(SETTINGS_FILE_NAME) + ); + } + + #[test] + fn explicit_data_root_and_absolute_paths_are_preserved() { + let input = VALID.replace( + "server_config_path = \"etc/wireguard/wg0.conf\"", + "server_config_path = \"/etc/wireguard/wg0.conf\"", + ) + "data_root = \"state\"\n"; + let resolved = validated(&input).resolve_paths(Path::new("/opt/app")); + assert_eq!( + resolved.server_config_path, + Path::new("/etc/wireguard/wg0.conf") + ); + assert_eq!(resolved.data_root, Path::new("/opt/app/state")); + } + + #[test] + fn parses_hostname_ipv4_and_bracketed_ipv6_endpoints() { + assert!(matches!( + validated(VALID).server_endpoint.host, + EndpointHost::Hostname(_) + )); + let ipv4 = VALID.replace("vpn.example.org:51820", "192.0.2.2:51820"); + assert_eq!( + validated(&ipv4).server_endpoint.host, + EndpointHost::Ip("192.0.2.2".parse().unwrap()) + ); + let ipv6 = VALID.replace("vpn.example.org:51820", "[2001:db8::2]:51820"); + assert_eq!( + validated(&ipv6).server_endpoint.host, + EndpointHost::Ip("2001:db8::2".parse().unwrap()) + ); + } + + #[test] + fn rejects_missing_or_malformed_values_with_field_context() { + let missing = VALID.replace("systemd_unit = \"wg-quick@wg0.service\"\n", ""); + assert!(matches!( + SettingsFile::parse_toml(&missing), + Err(SettingsError::Parse(_)) + )); + + for (input, field) in [ + ( + VALID.replace( + "interface_name = \"wg0\"", + "interface_name = \"interface/name\"", + ), + "interface_name", + ), + ( + VALID.replace( + "server_config_path = \"etc/wireguard/wg0.conf\"", + "server_config_path = \"\"", + ), + "server_config_path", + ), + ( + VALID.replace( + "server_endpoint = \"vpn.example.org:51820\"", + "server_endpoint = \"2001:db8::2:51820\"", + ), + "server_endpoint", + ), + ( + VALID.replace( + "ipv4_allocation_range = \"10.80.0.0/24\"", + "ipv4_allocation_range = \"fd80::/64\"", + ), + "ipv4_allocation_range", + ), + ( + VALID.replace( + "ipv6_allocation_range = \"fd80::/64\"", + "ipv6_allocation_range = \"10.80.0.0/24\"", + ), + "ipv6_allocation_range", + ), + ( + VALID.replace( + "dns_addresses = [\"10.80.0.1\", \"fd80::1\"]", + "dns_addresses = [\"vpn.example.org\"]", + ), + "dns_addresses", + ), + ( + VALID.replace( + "systemd_unit = \"wg-quick@wg0.service\"", + "systemd_unit = \"../wg0.service\"", + ), + "systemd_unit", + ), + ( + VALID.replace( + "restart_timeout_seconds = 30", + "restart_timeout_seconds = 0", + ), + "restart_timeout_seconds", + ), + ( + VALID.replace( + "server_endpoint = \"vpn.example.org:51820\"", + "server_endpoint = \"vpn.example.org:0\"", + ), + "server_endpoint", + ), + ] { + let error = SettingsFile::parse_toml(&input) + .and_then(SettingsFile::validate) + .expect_err("invalid fixture must fail validation"); + assert!(error.to_string().contains(field), "{error}"); + } + } + + #[test] + fn paths_are_only_resolved_and_not_checked_for_existence() { + let mut input = VALID.replace( + "server_config_path = \"etc/wireguard/wg0.conf\"", + "server_config_path = \"does-not-exist/server.conf\"", + ); + input = input.replace( + "client_source_directory = \"clients\"", + "client_source_directory = \"also-absent\"", + ); + assert!( + validated(&input) + .resolve_paths(Path::new("/fixture/bin")) + .server_config_path + .ends_with("does-not-exist/server.conf") + ); + } +} diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..7c9eeeb --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,791 @@ +//! Pure client identity, configuration-state, and address-allocation logic. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use ipnet::{Ipv4Net, Ipv6Net}; +use thiserror::Error; + +/// A WireGuard public key, decoded and validated as exactly 32 bytes. +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PublicKey([u8; 32]); + +impl PublicKey { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl FromStr for PublicKey { + type Err = DomainError; + + fn from_str(value: &str) -> Result { + let decoded = STANDARD + .decode(value) + .map_err(|_| DomainError::InvalidPublicKey)?; + let bytes: [u8; 32] = decoded + .try_into() + .map_err(|_| DomainError::InvalidPublicKey)?; + // Reject alternate or noncanonical encodings, including whitespace and missing padding. + if STANDARD.encode(bytes) != value { + return Err(DomainError::InvalidPublicKey); + } + Ok(Self(bytes)) + } +} + +impl fmt::Display for PublicKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&STANDARD.encode(self.0)) + } +} + +impl fmt::Debug for PublicKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PublicKey") + .field(&self.to_string()) + .finish() + } +} + +/// Client addresses. Existing imported IPv4-only records keep `ipv6` absent. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientAddresses { + pub ipv4: Ipv4Addr, + pub ipv6: Option, +} + +impl ClientAddresses { + pub fn iter(&self) -> impl Iterator + '_ { + std::iter::once(IpAddr::V4(self.ipv4)).chain(self.ipv6.map(IpAddr::V6)) + } +} + +/// Public client data only; private keys and connection secrets belong elsewhere. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Client { + pub identity: PublicKey, + pub alias: String, + pub addresses: ClientAddresses, +} + +impl Client { + pub fn new( + identity: PublicKey, + alias: impl Into, + addresses: ClientAddresses, + ) -> Result { + let alias = alias.into(); + validate_alias(&alias)?; + Ok(Self { + identity, + alias, + addresses, + }) + } +} + +pub fn validate_alias(alias: &str) -> Result<(), DomainError> { + if alias.is_empty() { + return Err(DomainError::EmptyAlias); + } + if alias.chars().any(char::is_control) { + return Err(DomainError::InvalidAlias); + } + Ok(()) +} + +/// A client snapshot keyed by public key, so aliases can repeat without ambiguity. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Snapshot { + clients: BTreeMap, +} + +impl Snapshot { + pub fn new(clients: impl IntoIterator) -> Result { + let mut snapshot = Self::default(); + for client in clients { + let identity = client.identity; + if snapshot.insert(client)?.is_some() { + return Err(DomainError::DuplicatePublicKey(identity)); + } + } + Ok(snapshot) + } + + pub fn get(&self, identity: &PublicKey) -> Option<&Client> { + self.clients.get(identity) + } + + pub fn clients(&self) -> impl Iterator { + self.clients.values() + } + + pub fn insert(&mut self, client: Client) -> Result, DomainError> { + validate_alias(&client.alias)?; + Ok(self.clients.insert(client.identity, client)) + } + + pub fn remove(&mut self, identity: &PublicKey) -> Option { + self.clients.remove(identity) + } + + fn addresses(&self) -> impl Iterator + '_ { + self.clients + .values() + .flat_map(|client| client.addresses.iter()) + } +} + +/// Runtime knowledge is not inferred from saved state or missing data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuntimeObservation { + Unknown, + Unavailable, + Verified, +} + +/// Draft, saved, and last verified applied data remain independent snapshots. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientStates { + draft: Snapshot, + saved: Snapshot, + applied_snapshot: Option, + applied: RuntimeObservation, + reserved_addresses: BTreeSet, +} + +impl ClientStates { + /// Start with a saved snapshot and an explicit initial runtime observation. + pub fn new( + saved: Snapshot, + applied_snapshot: Option, + applied: RuntimeObservation, + ) -> Result { + if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) { + return Err(DomainError::AppliedSnapshotMismatch); + } + let saved_addresses = saved.addresses().collect::>(); + let reserved_addresses = match &applied_snapshot { + Some(snapshot) => snapshot + .addresses() + .filter(|address| !saved_addresses.contains(address)) + .collect(), + None => BTreeSet::new(), + }; + Ok(Self { + draft: saved.clone(), + saved, + applied_snapshot, + applied, + reserved_addresses, + }) + } + + pub fn draft(&self) -> &Snapshot { + &self.draft + } + + pub fn saved(&self) -> &Snapshot { + &self.saved + } + + pub fn applied(&self) -> &RuntimeObservation { + &self.applied + } + + pub fn applied_snapshot(&self) -> Option<&Snapshot> { + self.applied_snapshot.as_ref() + } + + pub fn reserved_addresses(&self) -> &BTreeSet { + &self.reserved_addresses + } + + pub fn draft_mut(&mut self) -> &mut Snapshot { + &mut self.draft + } + + /// Save the draft in the model only. This performs no persistence or runtime operation. + pub fn commit_draft(&mut self) { + let next_addresses = self.draft.addresses().collect::>(); + self.reserved_addresses.extend( + self.saved + .addresses() + .filter(|address| !next_addresses.contains(address)), + ); + self.saved = self.draft.clone(); + if self.applied == RuntimeObservation::Verified + && self.applied_snapshot.as_ref() != Some(&self.saved) + { + self.applied = RuntimeObservation::Unknown; + } + } + + /// Discard draft edits by returning the draft to the saved snapshot. + pub fn discard_draft(&mut self) { + self.draft = self.saved.clone(); + } + + /// Record an applied state only when the verified observation matches saved clients. + pub fn mark_applied_after_verification( + &mut self, + observed: Snapshot, + ) -> Result<(), DomainError> { + if observed != self.saved { + self.applied = RuntimeObservation::Unknown; + return Err(DomainError::AppliedSnapshotMismatch); + } + self.applied_snapshot = Some(observed); + self.applied = RuntimeObservation::Verified; + self.reserved_addresses.clear(); + Ok(()) + } + + pub fn mark_runtime_unavailable(&mut self) { + self.applied = RuntimeObservation::Unavailable; + } + + pub fn mark_runtime_unknown(&mut self) { + self.applied = RuntimeObservation::Unknown; + } + + /// Addresses in draft, saved, or last verified applied data cannot be reused. + /// The applied snapshot preserves reservations for committed deletions until verification. + pub fn addresses_in_use(&self) -> BTreeSet { + self.draft + .addresses() + .chain(self.saved.addresses()) + .chain(self.reserved_addresses.iter().copied()) + .chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses)) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AddressFamily { + Ipv4, + Ipv6, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum DomainError { + #[error("public key must be canonical standard Base64 encoding of exactly 32 bytes")] + InvalidPublicKey, + #[error("client alias must not be empty")] + EmptyAlias, + #[error("client alias must not contain control characters")] + InvalidAlias, + #[error("snapshot contains a duplicate public key: {0}")] + DuplicatePublicKey(PublicKey), + #[error("address range is exhausted for {0:?}")] + AddressExhausted(AddressFamily), + #[error("address family does not match the configured range")] + AddressFamilyMismatch, + #[error("address is outside the configured allocation range")] + AddressOutsideRange, + #[error("address is unavailable or not usable in the configured range")] + AddressUnavailable, + #[error("verified applied clients do not match the saved snapshot")] + AppliedSnapshotMismatch, +} + +/// Return the lowest available host in `range` without walking the subnet. +pub fn allocate_ipv4( + range: Ipv4Net, + availability: &AddressAvailability<'_>, +) -> Result { + let network = u32::from(range.network()); + let broadcast = u32::from(range.broadcast()); + let (first, last) = match range.prefix_len() { + 0..=30 => (network + 1, broadcast - 1), + 31 => (network, broadcast), + 32 => (network, network), + _ => return Err(DomainError::AddressExhausted(AddressFamily::Ipv4)), + }; + first_gap( + AddressFamily::Ipv4, + u128::from(first), + u128::from(last), + availability + .addresses() + .filter_map(|address| match address { + IpAddr::V4(address) => Some(u128::from(u32::from(address))), + IpAddr::V6(_) => None, + }), + ) + .and_then(|address| { + u32::try_from(address) + .map(Ipv4Addr::from) + .map_err(|_| DomainError::AddressExhausted(AddressFamily::Ipv4)) + }) +} + +/// Return the lowest available address in an IPv6 range without walking the subnet. +pub fn allocate_ipv6( + range: Ipv6Net, + availability: &AddressAvailability<'_>, +) -> Result { + let first = u128::from(range.network()); + let last = u128::from(range.broadcast()); + first_gap( + AddressFamily::Ipv6, + first, + last, + availability + .addresses() + .filter_map(|address| match address { + IpAddr::V4(_) => None, + IpAddr::V6(address) => Some(u128::from(address)), + }), + ) + .map(Ipv6Addr::from) +} + +/// The categories are separate inputs to allocation and manual-address validation. +#[derive(Clone, Copy, Debug, Default)] +pub struct AddressAvailability<'a> { + pub server: &'a [IpAddr], + pub occupied: &'a [IpAddr], + pub reserved: &'a [IpAddr], +} + +impl AddressAvailability<'_> { + fn addresses(self) -> impl Iterator { + self.server + .iter() + .chain(self.occupied) + .chain(self.reserved) + .copied() + } +} + +pub fn validate_manual_ipv4( + address: Ipv4Addr, + range: Ipv4Net, + availability: &AddressAvailability<'_>, +) -> Result<(), DomainError> { + if !range.contains(&address) { + return Err(DomainError::AddressOutsideRange); + } + let value = u32::from(address); + let network = u32::from(range.network()); + let broadcast = u32::from(range.broadcast()); + let usable = match range.prefix_len() { + 0..=30 => value > network && value < broadcast, + 31 | 32 => true, + _ => false, + }; + if !usable + || availability + .addresses() + .any(|used| used == IpAddr::V4(address)) + { + return Err(DomainError::AddressUnavailable); + } + Ok(()) +} + +pub fn validate_manual_ipv6( + address: Ipv6Addr, + range: Ipv6Net, + availability: &AddressAvailability<'_>, +) -> Result<(), DomainError> { + if !range.contains(&address) { + return Err(DomainError::AddressOutsideRange); + } + if availability + .addresses() + .any(|used| used == IpAddr::V6(address)) + { + return Err(DomainError::AddressUnavailable); + } + Ok(()) +} + +pub fn validate_manual_address( + address: IpAddr, + ipv4_range: Ipv4Net, + ipv6_range: Ipv6Net, + availability: &AddressAvailability<'_>, +) -> Result<(), DomainError> { + match address { + IpAddr::V4(address) => validate_manual_ipv4(address, ipv4_range, availability), + IpAddr::V6(address) => validate_manual_ipv6(address, ipv6_range, availability), + } +} + +fn first_gap( + family: AddressFamily, + first: u128, + last: u128, + occupied: impl Iterator, +) -> Result { + let mut occupied = occupied + .filter(|address| *address >= first && *address <= last) + .collect::>(); + occupied.sort_unstable(); + let mut candidate = first; + for address in occupied { + if address < candidate { + continue; + } + if address > candidate { + return Ok(candidate); + } + if candidate == last { + return Err(DomainError::AddressExhausted(family)); + } + candidate = candidate + .checked_add(1) + .ok_or(DomainError::AddressExhausted(family))?; + } + Ok(candidate) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(byte: u8) -> PublicKey { + PublicKey::from_bytes([byte; 32]) + } + + fn client(byte: u8, alias: &str, ipv4: &str, ipv6: Option<&str>) -> Client { + Client::new( + key(byte), + alias, + ClientAddresses { + ipv4: ipv4.parse().expect("valid fixture IPv4"), + ipv6: ipv6.map(|address| address.parse().expect("valid fixture IPv6")), + }, + ) + .expect("fixture client should validate") + } + + fn empty_availability<'a>() -> AddressAvailability<'a> { + AddressAvailability::default() + } + + #[test] + fn public_key_accepts_canonical_wireguard_base64_and_rejects_invalid_values() { + let expected = key(7); + let encoded = expected.to_string(); + assert_eq!(encoded.len(), 44); + assert_eq!(encoded.parse::(), Ok(expected)); + + for invalid in ["", "not a key", &STANDARD.encode([1_u8; 31]), "AQ=="] { + assert_eq!( + invalid.parse::(), + Err(DomainError::InvalidPublicKey) + ); + } + assert_eq!( + format!("{}\n", expected).parse::(), + Err(DomainError::InvalidPublicKey) + ); + } + + #[test] + fn aliases_may_repeat_but_control_characters_are_rejected() { + let snapshot = Snapshot::new([ + client(1, "laptop", "10.0.0.2", None), + client(2, "laptop", "10.0.0.3", None), + ]) + .expect("duplicate aliases are valid"); + assert_eq!(snapshot.clients().count(), 2); + for alias in ["bad\nname", "bad\rname", "bad\u{1b}name", "bad\u{7f}name"] { + assert_eq!( + Client::new( + key(3), + alias, + ClientAddresses { + ipv4: "10.0.0.4".parse().expect("valid fixture IPv4"), + ipv6: None, + }, + ) + .err(), + Some(DomainError::InvalidAlias) + ); + } + } + + #[test] + fn unknown_and_unavailable_runtime_observations_are_explicit() { + let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); + let unknown = ClientStates::new(saved.clone(), None, RuntimeObservation::Unknown).unwrap(); + assert_eq!(unknown.applied(), &RuntimeObservation::Unknown); + let unavailable = ClientStates::new(saved, None, RuntimeObservation::Unavailable).unwrap(); + assert_eq!(unavailable.applied(), &RuntimeObservation::Unavailable); + } + + #[test] + fn verified_state_requires_a_matching_applied_snapshot() { + let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); + let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap(); + + assert_eq!( + ClientStates::new(saved.clone(), None, RuntimeObservation::Verified).err(), + Some(DomainError::AppliedSnapshotMismatch) + ); + assert_eq!( + ClientStates::new(saved.clone(), Some(other), RuntimeObservation::Verified).err(), + Some(DomainError::AppliedSnapshotMismatch) + ); + let verified = + ClientStates::new(saved.clone(), Some(saved), RuntimeObservation::Verified).unwrap(); + assert_eq!(verified.applied(), &RuntimeObservation::Verified); + } + + #[test] + fn discard_restores_draft_and_commit_keeps_applied_snapshot_separate() { + let saved = Snapshot::new([ + client(1, "one", "10.0.0.2", None), + client(2, "two", "10.0.0.3", None), + ]) + .unwrap(); + let mut states = ClientStates::new( + saved.clone(), + Some(saved.clone()), + RuntimeObservation::Verified, + ) + .unwrap(); + states.draft_mut().remove(&key(2)); + states.discard_draft(); + assert_eq!(states.draft(), &saved); + + states.draft_mut().remove(&key(2)); + states.commit_draft(); + assert_eq!(states.saved().clients().count(), 1); + assert_eq!(states.applied(), &RuntimeObservation::Unknown); + assert_eq!(states.applied_snapshot(), Some(&saved)); + states.mark_runtime_unavailable(); + assert_eq!(states.applied(), &RuntimeObservation::Unavailable); + assert!(states.applied_snapshot().is_some()); + assert!( + states + .addresses_in_use() + .contains(&IpAddr::V4("10.0.0.3".parse().unwrap())) + ); + assert_eq!( + states.reserved_addresses(), + &[IpAddr::V4("10.0.0.3".parse().unwrap())].into() + ); + assert!( + states + .addresses_in_use() + .contains(&IpAddr::V4("10.0.0.3".parse().unwrap())) + ); + } + + #[test] + fn a_draft_only_deletion_does_not_create_a_committed_reservation() { + let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); + let mut states = ClientStates::new(saved, None, RuntimeObservation::Unknown).unwrap(); + states.draft_mut().remove(&key(1)); + assert!(states.reserved_addresses().is_empty()); + assert!( + states + .addresses_in_use() + .contains(&IpAddr::V4("10.0.0.2".parse().unwrap())) + ); + } + + #[test] + fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() { + let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); + let mut states = ClientStates::new( + initial.clone(), + Some(initial.clone()), + RuntimeObservation::Verified, + ) + .unwrap(); + let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap(); + assert_eq!( + states.mark_applied_after_verification(wrong), + Err(DomainError::AppliedSnapshotMismatch) + ); + assert_eq!(states.applied(), &RuntimeObservation::Unknown); + assert_eq!(states.applied_snapshot(), Some(&initial)); + + states.draft_mut().remove(&key(1)); + states.commit_draft(); + assert!(!states.reserved_addresses().is_empty()); + let saved = states.saved().clone(); + states + .mark_applied_after_verification(saved.clone()) + .unwrap(); + assert_eq!(states.applied(), &RuntimeObservation::Verified); + assert!(states.reserved_addresses().is_empty()); + assert!( + !states + .addresses_in_use() + .contains(&IpAddr::V4("10.0.0.2".parse().unwrap())) + ); + } + + #[test] + fn existing_ipv4_only_clients_remain_ipv4_only() { + let ipv4_only = client(1, "legacy", "10.0.0.2", None); + assert_eq!(ipv4_only.addresses.ipv6, None); + assert_eq!(ipv4_only.addresses.iter().count(), 1); + } + + #[test] + fn ipv4_allocation_skips_server_occupied_reserved_network_and_broadcast() { + let server = [IpAddr::V4("10.0.0.1".parse().unwrap())]; + let occupied = [IpAddr::V4("10.0.0.2".parse().unwrap())]; + let reserved = [IpAddr::V4("10.0.0.3".parse().unwrap())]; + let availability = AddressAvailability { + server: &server, + occupied: &occupied, + reserved: &reserved, + }; + assert_eq!( + allocate_ipv4("10.0.0.0/29".parse().unwrap(), &availability), + Ok("10.0.0.4".parse().unwrap()) + ); + } + + #[test] + fn ipv4_tiny_ranges_and_exhaustion_follow_rfc3021_and_host_rules() { + let none = empty_availability(); + assert_eq!( + allocate_ipv4("192.0.2.0/31".parse().unwrap(), &none), + Ok("192.0.2.0".parse().unwrap()) + ); + assert_eq!( + allocate_ipv4("192.0.2.8/32".parse().unwrap(), &none), + Ok("192.0.2.8".parse().unwrap()) + ); + let occupied = [IpAddr::V4("192.0.2.8".parse().unwrap())]; + let availability = AddressAvailability { + occupied: &occupied, + ..none + }; + assert_eq!( + allocate_ipv4("192.0.2.8/31".parse().unwrap(), &availability), + Ok("192.0.2.9".parse().unwrap()) + ); + let both = [ + IpAddr::V4("192.0.2.8".parse().unwrap()), + IpAddr::V4("192.0.2.9".parse().unwrap()), + ]; + assert_eq!( + allocate_ipv4( + "192.0.2.8/31".parse().unwrap(), + &AddressAvailability { + occupied: &both, + ..none + } + ), + Err(DomainError::AddressExhausted(AddressFamily::Ipv4)) + ); + assert_eq!( + allocate_ipv4( + "192.0.2.8/32".parse().unwrap(), + &AddressAvailability { + occupied: &occupied, + ..none + } + ), + Err(DomainError::AddressExhausted(AddressFamily::Ipv4)) + ); + } + + #[test] + fn ipv6_allocates_first_free_address_from_a_very_large_range() { + // /32 has almost 2^96 addresses; work here depends on the small occupied set. + let occupied = (0..20_000_u32) + .map(|offset| { + IpAddr::V6(Ipv6Addr::from( + 0x2001_0db8_0000_0000_0000_0000_0000_0000_u128 + u128::from(offset), + )) + }) + .collect::>(); + let availability = AddressAvailability { + occupied: &occupied, + ..AddressAvailability::default() + }; + assert_eq!( + allocate_ipv6("2001:db8::/32".parse().unwrap(), &availability), + Ok(Ipv6Addr::from( + 0x2001_0db8_0000_0000_0000_0000_0000_4e20_u128 + )) + ); + } + + #[test] + fn ipv6_narrow_ranges_manual_selection_and_family_validation_work() { + let none = empty_availability(); + assert_eq!( + allocate_ipv6("2001:db8::/127".parse().unwrap(), &none), + Ok("2001:db8::".parse().unwrap()) + ); + let server = [IpAddr::V6("2001:db8::".parse().unwrap())]; + let availability = AddressAvailability { + server: &server, + ..none + }; + assert_eq!( + allocate_ipv6("2001:db8::/128".parse().unwrap(), &availability), + Err(DomainError::AddressExhausted(AddressFamily::Ipv6)) + ); + assert_eq!( + validate_manual_ipv6( + "2001:db8::2".parse().unwrap(), + "2001:db8::/126".parse().unwrap(), + &none + ), + Ok(()) + ); + assert_eq!( + validate_manual_ipv6( + "2001:db8::".parse().unwrap(), + "2001:db8::/126".parse().unwrap(), + &availability + ), + Err(DomainError::AddressUnavailable) + ); + assert_eq!( + validate_manual_address( + IpAddr::V6("2001:db8::3".parse().unwrap()), + "10.0.0.0/24".parse().unwrap(), + "2001:db8::/126".parse().unwrap(), + &none, + ), + Ok(()) + ); + } + + #[test] + fn manual_ipv4_requires_range_membership_usability_and_availability() { + let occupied = [IpAddr::V4("10.0.0.3".parse().unwrap())]; + let availability = AddressAvailability { + occupied: &occupied, + ..AddressAvailability::default() + }; + let range = "10.0.0.0/29".parse().unwrap(); + assert_eq!( + validate_manual_ipv4("10.0.0.3".parse().unwrap(), range, &availability), + Err(DomainError::AddressUnavailable) + ); + assert_eq!( + validate_manual_ipv4("10.0.0.0".parse().unwrap(), range, &availability), + Err(DomainError::AddressUnavailable) + ); + assert_eq!( + validate_manual_ipv4("10.0.1.1".parse().unwrap(), range, &availability), + Err(DomainError::AddressOutsideRange) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..83f5c9e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod domain; +pub mod startup; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f6a5329 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,34 @@ +use std::env; +use std::ffi::OsString; +use std::process::ExitCode; +use wireguard_admin::startup::{self, StartupAction}; + +const USAGE: &str = "Usage: wireguard-admin [--help | --version]\n\ +With no arguments, starts the root-only management interface.\n\ +The management interface is not implemented yet."; + +fn main() -> ExitCode { + let arguments: Vec = env::args_os().skip(1).collect(); + let result = startup::start(&arguments, || { + rustix::process::geteuid() == rustix::process::Uid::ROOT + }); + + match result { + Ok(StartupAction::Help) => { + println!("{USAGE}"); + ExitCode::SUCCESS + } + Ok(StartupAction::Version) => { + println!("wireguard-admin {}", env!("CARGO_PKG_VERSION")); + ExitCode::SUCCESS + } + Ok(StartupAction::ManagementUnavailable) => { + eprintln!("The management interface is not implemented yet."); + ExitCode::from(1) + } + Err(error) => { + eprintln!("Error: {error}.\n{USAGE}"); + ExitCode::from(2) + } + } +} diff --git a/src/startup.rs b/src/startup.rs new file mode 100644 index 0000000..27a6085 --- /dev/null +++ b/src/startup.rs @@ -0,0 +1,92 @@ +use std::ffi::OsString; +use std::fmt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StartupAction { + Help, + Version, + ManagementUnavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StartupError { + UnknownArguments, + RootRequired, +} + +impl fmt::Display for StartupError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownArguments => write!(formatter, "unknown or unsupported arguments"), + Self::RootRequired => write!(formatter, "management operations require root"), + } + } +} + +impl std::error::Error for StartupError {} + +/// Resolves the supported command line and checks management privilege lazily. +/// +/// The injected root check keeps this boundary testable without running tests as +/// root. Help, version, and invalid arguments do not query process privileges. +pub fn start( + arguments: &[OsString], + effective_uid_is_root: impl FnOnce() -> bool, +) -> Result { + match arguments { + [] => { + if effective_uid_is_root() { + Ok(StartupAction::ManagementUnavailable) + } else { + Err(StartupError::RootRequired) + } + } + [argument] if argument == "--help" => Ok(StartupAction::Help), + [argument] if argument == "--version" => Ok(StartupAction::Version), + _ => Err(StartupError::UnknownArguments), + } +} + +#[cfg(test)] +mod tests { + use super::{StartupAction, StartupError, start}; + use std::ffi::OsString; + + #[test] + fn help_and_version_do_not_query_root() { + for (argument, expected) in [ + ("--help", StartupAction::Help), + ("--version", StartupAction::Version), + ] { + let result = start(&[OsString::from(argument)], || { + panic!("root check must not run for {argument}") + }); + assert_eq!(result, Ok(expected)); + } + } + + #[test] + fn non_root_is_rejected_before_management_startup() { + let result = start(&[], || false); + assert_eq!(result, Err(StartupError::RootRequired)); + } + + #[test] + fn root_management_startup_reports_unimplemented_without_side_effects() { + let result = start(&[], || true); + assert_eq!(result, Ok(StartupAction::ManagementUnavailable)); + } + + #[test] + fn unknown_or_extra_arguments_fail_without_root_check() { + for arguments in [ + vec![OsString::from("--settings")], + vec![OsString::from("--help"), OsString::from("unexpected")], + ] { + let result = start(&arguments, || { + panic!("root check must not run for invalid arguments") + }); + assert_eq!(result, Err(StartupError::UnknownArguments)); + } + } +}