Define Rust environment and toolchain

This commit is contained in:
2026-10-18 21:42:33 +03:00
parent 7b475bfcc8
commit c2b899dd1e
15 changed files with 2058 additions and 9 deletions
+4
View File
@@ -16,4 +16,8 @@ target/
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Local deployment data and executable-relative settings; keep config examples tracked.
/Backup/
/wireguard-admin.toml
/.obsidian/ /.obsidian/
+3 -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 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. - 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.
@@ -26,6 +26,8 @@ The project currently contains design/configuration documents, not an implemente
## Development rules and reference map ## 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 | | When working on | Read first |
| --------------------------------------------------------- | ------------------------------------------- | | --------------------------------------------------------- | ------------------------------------------- |
| Module layout and boundaries | [PROJECT_LAYOUT.md](docs/PROJECT_LAYOUT.md) | | Module layout and boundaries | [PROJECT_LAYOUT.md](docs/PROJECT_LAYOUT.md) |
Generated
+244
View File
@@ -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",
]
+22
View File
@@ -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"
+218 -8
View File
@@ -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. 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. 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 | | 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 | | 2 | Server configuration editing with byte preservation | 1 | pending |
| 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 |
@@ -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 ### 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 ### 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 ## Current progress and resume point
- All milestones are pending; application implementation has not started. - Milestone 1 is accepted; milestones 2–9 remain pending.
- The approved dependency list and container testing methodology are recorded in AGENTS.md. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.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. - Milestone 1 is expanded and the Rust environment is pinned.
- No workers have been dispatched by creation of this document; no build, container, or VM results are claimed. - 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 ## Plan maintenance
+41
View File
@@ -1,2 +1,43 @@
# wireguard-admin # 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.
+17
View File
@@ -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_root>/data and archives in <data_root>/archives.
# data_root = "Backup"
+74
View File
@@ -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).
+1
View File
@@ -11,6 +11,7 @@ The following is the intended layout, not a claim that these files already exist
```text ```text
Cargo.toml Cargo.toml
Cargo.lock Cargo.lock
rust-toolchain.toml
AGENTS.md AGENTS.md
CONTEXT.md CONTEXT.md
PLANS.md PLANS.md
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.97.1"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = ["x86_64-unknown-linux-musl"]
+509
View File
@@ -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<String>,
pub systemd_unit: String,
pub restart_timeout_seconds: u64,
#[serde(default)]
pub data_root: Option<PathBuf>,
}
/// 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<IpAddr>,
pub systemd_unit: String,
pub restart_timeout: Duration,
pub data_root: Option<PathBuf>,
}
/// 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<IpAddr>,
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<Self, SettingsError> {
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<ValidatedSettings, SettingsError> {
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::<IpAddr>().map_err(|_| {
invalid(
"dns_addresses",
format!("{address:?} is not an IPv4 or IPv6 address"),
)
})
})
.collect::<Result<Vec<_>, _>>()?;
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<ServerEndpoint, SettingsError> {
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::<Ipv4Addr>() {
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::<u16>()
.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<String>) -> 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")
);
}
}
+791
View File
@@ -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<Self, Self::Err> {
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<Ipv6Addr>,
}
impl ClientAddresses {
pub fn iter(&self) -> impl Iterator<Item = IpAddr> + '_ {
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<String>,
addresses: ClientAddresses,
) -> Result<Self, DomainError> {
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<PublicKey, Client>,
}
impl Snapshot {
pub fn new(clients: impl IntoIterator<Item = Client>) -> Result<Self, DomainError> {
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<Item = &Client> {
self.clients.values()
}
pub fn insert(&mut self, client: Client) -> Result<Option<Client>, DomainError> {
validate_alias(&client.alias)?;
Ok(self.clients.insert(client.identity, client))
}
pub fn remove(&mut self, identity: &PublicKey) -> Option<Client> {
self.clients.remove(identity)
}
fn addresses(&self) -> impl Iterator<Item = IpAddr> + '_ {
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<Snapshot>,
applied: RuntimeObservation,
reserved_addresses: BTreeSet<IpAddr>,
}
impl ClientStates {
/// Start with a saved snapshot and an explicit initial runtime observation.
pub fn new(
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation,
) -> Result<Self, DomainError> {
if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) {
return Err(DomainError::AppliedSnapshotMismatch);
}
let saved_addresses = saved.addresses().collect::<BTreeSet<_>>();
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<IpAddr> {
&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::<BTreeSet<_>>();
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<IpAddr> {
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<Ipv4Addr, DomainError> {
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<Ipv6Addr, DomainError> {
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<Item = IpAddr> {
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<Item = u128>,
) -> Result<u128, DomainError> {
let mut occupied = occupied
.filter(|address| *address >= first && *address <= last)
.collect::<Vec<_>>();
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::<PublicKey>(), Ok(expected));
for invalid in ["", "not a key", &STANDARD.encode([1_u8; 31]), "AQ=="] {
assert_eq!(
invalid.parse::<PublicKey>(),
Err(DomainError::InvalidPublicKey)
);
}
assert_eq!(
format!("{}\n", expected).parse::<PublicKey>(),
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::<Vec<_>>();
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)
);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod config;
pub mod domain;
pub mod startup;
+34
View File
@@ -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<OsString> = 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)
}
}
}
+92
View File
@@ -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<StartupAction, StartupError> {
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));
}
}
}