Define Rust environment and toolchain
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Generated
+244
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
profile = "minimal"
|
||||
components = ["rustfmt", "clippy"]
|
||||
targets = ["x86_64-unknown-linux-musl"]
|
||||
+509
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod startup;
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user