Add runtime observation and systemd restart adapter

This commit is contained in:
2026-10-20 23:01:43 +03:00
parent 5418841b92
commit 6ec593c783
18 changed files with 6001 additions and 66 deletions
+5 -3
View File
@@ -16,18 +16,20 @@ The user has approved the following choices. They may be introduced when their i
| QR encoding | `qrcode` | Additional terminal delivery of client configurations |
| Backup archives | `tar` | Archives with manifests; initially without compression |
| Temporary files | `tempfile` | Staged file replacement and isolated test directories |
| Linux operations | `rustix` | User checks and controlled low-level filesystem operations |
| Linux operations | `rustix` | User checks, controlled filesystem operations, and bounded Generic Netlink runtime reads |
| Encrypted vault | `age` | Recipient encryption and password protection of the vault secret key |
| WireGuard key pairs | `x25519-dalek` | Key generation and public/private key matching |
| Secure randomness | `getrandom` | Operating-system randomness for key generation |
| WireGuard key encoding | `base64` | Reading and writing WireGuard key representations |
| Secret handling | `secrecy`, `zeroize` | Explicit secret exposure and supported memory cleanup |
| WireGuard runtime | `wireguard-uapi` | Direct Linux interface, peer, handshake, and traffic queries |
| Service management | `zbus` | Direct systemd D-Bus requests and job completion tracking |
| Service management | `zbus` 5.19.0 | Async systemd D-Bus requests and job completion tracking |
| Container integration tests | `testcontainers` | Alpine server/client lifecycle; development dependency only |
- Use a separate vault key pair from all WireGuard client/server key pairs. Encrypt each client secret to the vault public key; protect the vault secret key with password-based encryption using the established `age` format. Do not replace this with password-only encryption of every new client record, which would require a password on creation.
- Validate `wireguard-uapi` against the isolated integration environment before relying on it. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application.
- `wireguard-uapi` remains an approved option but is not selected for the bounded runtime reader: its public read API does not expose a receive deadline or a complete-dump guarantee. A systemd D-Bus request being accepted is not proof of job completion or successful VPN application.
- Runtime observation uses the already approved `rustix` 1.1.5 safe `net` and `event` APIs for a nonblocking Linux Generic Netlink socket, polling and datagram I/O. This keeps one monotonic deadline and exact truncation/sender checks around the checked WireGuard decoder. It does not add a direct dependency or use a subprocess.
- The systemd adapter pins `zbus` 5.19.0 with default features disabled and only `async-io` enabled. It uses zbus's asynchronous signal and method APIs, driven by a small standard-library wake/park loop so every call shares the caller's monotonic deadline. zbus's async-io backend owns internal I/O executor thread(s); callers must allow the connection to be dropped after each operation. Signal polling uses zbus 5.19.0's doc-hidden `export::futures_core` re-export to avoid another direct dependency; keep zbus pinned until that implementation is deliberately migrated.
- Avoid incompatible duplicate versions of secret-handling types where library APIs exchange them. Use supported re-exports where appropriate rather than adding redundant direct dependencies.
- Additional direct dependencies still require the comparison and prior agreement required by [AGENTS.md](../AGENTS.md), including any test/build helpers. Transitive dependencies of approved crates are resolved through Cargo; review their compatibility and runtime requirements. Do not add a direct `tokio` dependency merely because an approved library uses an async runtime internally.
- `clap`, `chrono`, SQLite, a generic INI writer, a mocking framework, and archive compression libraries are not part of the approved initial set. Use the standard library for the small startup argument surface and background threads/channels where sufficient.
+13
View File
@@ -103,3 +103,16 @@ WireGuard or systemd operation, does not provide concurrent-editor locking,
and is not automatic rollback after a failed runtime restart. The separate
pre-operation archive remains the recovery source if local journal data is
unusable; the adapter does not silently discard an invalid journal.
For an explicit restart, the application creates the pre-restart archive before
calling the service adapter. After a successful terminal service job, it checks
the complete observed peer set against the saved server document. A match is
then recorded by installing the updated `internal-state.toml` through this same
journal, with reservations removed only from the proposed state as appropriate.
The in-memory saved/applied state advances only after installation succeeds. If
that write or journal recovery fails after the VPN may have changed, the
application reports the persistence stage, retains in-memory reservations, and
requires manual recovery; it does not roll back or restart again. Default tests
exercise this orchestration with private fake adapters and synthetic files.
Actual interface and systemd behavior remain deferred to the isolated Alpine
and disposable VM acceptance environments described in `RUNTIME.md`.
+168
View File
@@ -0,0 +1,168 @@
# Runtime observation and verification boundary
`runtime` defines public, read-only observation values, a bounded Linux
WireGuard Generic Netlink reader, and a pure comparison between an observed
interface and the authoritative saved server document. The reader accesses
only the configured interface and never changes the running interface.
## Observation values
An observation records an optional UTC sample time and distinguishes an unknown
interface, an unavailable read, a missing interface, and a present interface.
A present interface carries its observed interface name and peers keyed by
`PublicKey`; duplicate keys are retained as an explicit ambiguity signal. The
comparison requires the currently configured interface name and rejects a
different observed interface, even when its peer set happens to match. The peer
set states whether it is unknown, unavailable, partial, or complete. Only a
complete set can prove that a saved peer is absent or that an extra runtime peer
exists.
Each peer has AllowedIPs and statistics with their own availability. A valid
zero byte counter is `Available(0)`. A peer with no recorded handshake is
`Available(None)`. Unavailable statistics do not become zero or a disconnected
state. Counters are current values from the server interface's perspective and
may reset after restart; they are not history and do not establish online or
offline status.
## Saved technical projection
`SavedPeerProjection::from_document` includes every `[Peer]` section from the
byte-preserving `ConfigDocument`, including valid routes unsupported by the
narrow `domain::Client` address model and legacy IPv4-only peers. Routes are
normalized and compared as complete sets, independent of ordering and duplicate
entries. Aliases and managed creation dates are not technical runtime fields;
metadata anomalies alone do not prevent comparison. Missing or ambiguous keys,
malformed routes, structural document issues, and duplicate identities remain
unverifiable and cannot be omitted to obtain a matching reduced set.
`compare_peers(saved, expected_interface, runtime)` checks interface presence
and identity, the complete public-key set, and each peer's complete AllowedIPs
set. It reports a wrong interface, missing, extra, route-mismatched, duplicate,
partial, and unverifiable cases. An empty saved peer set can match only a
present expected interface with an explicitly complete empty runtime peer set.
Handshake values, counters (including resets), endpoint changes, ordering, and
sample time do not affect technical equivalence. The comparison returns only a
technical result; it does not create online status, applied state, or history.
This result is separate from M4's `domain::Snapshot` and applied marker. A
successful technical comparison is necessary evidence for applying state, but
it is not itself a durable M4 transition. Application restart orchestration
joins saved aliases and representable addresses by public key only after full
technical equivalence succeeds. If any authoritative peer cannot be safely
represented for that narrower durable view, verification remains unverifiable
and reservations are retained instead of supplying a fabricated subset.
## WireGuard Generic Netlink decoder
`runtime::netlink::GetDeviceDecoder` is a pure decoder for one WireGuard
`GET_DEVICE` dump. It consumes caller supplied datagrams and expected request
sequence, local netlink port ID, family ID, and interface name. It has no socket
or host access. The transport rejects truncated datagrams, enforces its
monotonic receive deadline, and wipes its receive buffer when its lifetime ends.
The decoder checks every netlink and attribute length/alignment, message type,
sequence and port ID, Generic Netlink command/version, configured interface,
nested peer and AllowedIPs records, duplicate fields and aggregate limits. It
returns data only after an explicit `NLMSG_DONE` with an embedded zero error
code. It rejects `NLMSG_ERROR`, interrupted/overrun dumps, malformed or
incomplete data, and limits exceeded. Peer fragments are coalesced only when a
repeated public key is adjacent across multipart messages and the fragment
contains only that key and AllowedIPs; duplicate routes remain invalid. A
complete empty peer list is valid.
Server private keys and peer preshared keys are length-checked through borrowed
input slices and are neither copied into decoder-owned structures nor returned.
The result contains only public keys, AllowedIPs, last-handshake timestamps and
current received/transmitted counters. Errors carry stable categories without
raw message data. The decoder uses configurable aggregate limits with bounded
defaults; future transport code must set request-specific limits explicitly.
Protocol details follow the [WireGuard Generic Netlink specification](https://docs.kernel.org/netlink/specs/wireguard.html)
and the [Linux Netlink message specification](https://docs.kernel.org/6.1/userspace-api/netlink/intro.html).
## Systemd restart job tracking boundary
`service::RestartJobTracker` is the pure event state machine for one resolved
primary systemd unit ID. The bounded zbus 5.19.0 adapter is internal and
crate-private; public runtime mutation is exposed only through
`Application::restart`, after an explicit restart choice. The adapter installs
`JobRemoved` and systemd name-owner observation, calls Manager `Subscribe`, and
resolves the configured target with read-only Manager `GetUnit` followed by the
unit's `Id` property. It then submits `RestartUnit` using the original
configured target and tracks the returned job object path together with the
resolved primary ID, which is the unit name reported by `JobRemoved`. If
`GetUnit` cannot resolve a loaded unit, or reading its ID fails, the operation
stops with a redacted resolution failure; it does not load a unit or guess its
canonical name. The signal stream is installed before resolution and restart,
so a fast completion queued before the method reply remains available once the
reply supplies the path. The pure tracker also supports a fixed bound of early
events for transports that deliver events before the method reply; overflow
fails closed.
Only systemd's `done` job result is successful. Failed, canceled, timed-out,
skipped, dependency, or unknown job results remain failures. Bus disconnection,
manager owner change, subscription failure, and permission/request failures are
separate redacted outcomes. `RestartDeadline` carries the operation's monotonic
start and timeout so connection, match registration, Subscribe, unit
resolution, RestartUnit, job wait, and later runtime verification can share one
budget. The adapter drives zbus async futures with a standard-library thread
waker and parks only for the remaining budget. zbus's `async-io` backend
continues socket I/O on its own executor; its connection is short lived and is
dropped after the adapter returns. Deadline expiry stops the application's
wait; a submitted job may still complete. The adapter never cancels or retries
the job, checks unit active state, or verifies the WireGuard interface.
Ordinary adapter tests inject a fake event transport and never connect to the
system bus. Real D-Bus behavior remains for the disposable systemd VM
acceptance at M9. Protocol
references: [systemd Manager D-Bus API](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html)
and [zbus 5.19.0 API](https://docs.rs/zbus/5.19.0/zbus/).
## Application restart orchestration
`Application::preview_restart` binds a read-only preview to the current saved
generation and resolves the configured interface, systemd unit, and timeout
from that generation. `Application::restart` accepts an explicit choice. Cancel
returns before backup, service, runtime, or persistence adapters run. A
confirmed operation completes a `pre-restart` backup, submits a systemd restart,
waits for a successful terminal job, reads and compares the complete runtime
peer set within the shared monotonic deadline, then writes verification state
through the saved-file journal. Only after that write succeeds does in-memory
applied state advance and deleted-client reservations release.
Failure reports identify the stage, whether a restart was submitted, whether it
may still complete, the backup path when available, and whether manual recovery
is required. A timeout does not cancel a submitted systemd job. A runtime mismatch
or verification-state write failure does not roll back the VPN or release
reservations. Commit and restore remain saved-file operations and never invoke
the runtime or service adapters.
Application unit tests use private fake adapters and an injected private
monotonic clock. Public integration tests exercise cancellation, preview,
restore, and saved-setting selection without confirming a host restart. These
are synthetic fixture results. The Linux transport has not been validated
against a real interface; that check belongs to the isolated Alpine suite at
M8. The real zbus/systemd path and end-to-end restart acceptance require the
disposable systemd VM at M9. The TUI and management startup wiring are not
implemented.
## Bounded Linux transport
`runtime::transport::observe_interface` resolves the WireGuard Generic Netlink
family through `nlctrl`, then sends only `WG_CMD_GET_DEVICE` for the supplied
interface. It uses rustix 1.1.5's safe nonblocking socket, `poll`, and datagram
APIs; no `SET_DEVICE`, subprocess, unsafe block, or host socket is used by the
default tests. Family lookup and the complete multipart dump share one
monotonic deadline. The datagram size is capped, `MSG_TRUNC` is detected using
the returned full datagram length, the sender socket address must be kernel
port 0, and decoder aggregate limits apply across the dump.
Linux's Generic Netlink reply header `nlmsg_pid` carries the requester port ID
(the destination); the actual kernel sender port ID is 0 in the datagram
address. The transport validates both identities. A complete read is returned
only after the decoder consumes successful `NLMSG_DONE`; kernel errno values
map to distinct no-device and permission outcomes. Malformed data, transport
errors, timeouts, and limit excess remain separate, redacted categories.
Receive buffers are zeroized on every return path because device replies may
include private and preshared key attributes, although the decoder borrows and
does not retain those attributes. Runtime observation does not itself perform
saved-peer comparison or mark state applied. Real-interface behavior remains
subject to the isolated Alpine integration milestone.
+1 -1
View File
@@ -40,7 +40,7 @@ Production access is limited and experimentation on the production VPN is prohib
- Build and cache the fixture image ahead of repeated test runs. Pin the Alpine base/version or digest and document the image recipe; avoid package installation and image rebuilding for every test. Reuse image layers, not mutable containers or secret-bearing volumes between tests.
- The Docker host must provide Linux kernel WireGuard support. Containers share that kernel; installing userspace tools in Alpine does not supply it. Probe prerequisites in the disposable environment and report missing capabilities clearly. Do not automatically load host modules or change host settings.
- Grant `NET_ADMIN` in the test containers for their own network namespaces. Do not default to `--privileged`, `SYS_MODULE`, host networking, or shared host PID namespaces. Configure IPv6 for the dedicated test network as needed; exercise both address families.
- Run the application operations or a test driver linked to the real library inside the server container, including the real `wireguard-uapi` adapter. A host-side test harness may orchestrate containers, but must not query or modify the host's WireGuard interface as a shortcut.
- Run the application operations or a test driver linked to the real library inside the server container, including the approved `rustix` Generic Netlink runtime adapter. A host-side test harness may orchestrate containers, but must not query or modify the host's WireGuard interface as a shortcut.
- Build the in-container executable for Alpine compatibility, preferably the matching musl target. Do not assume a glibc-linked host binary runs in Alpine. Keep compiler/toolchain layers out of the small runtime fixture image where practical.
- Generate synthetic server/client keys and configurations for each test. Copy fixtures into container-local filesystems. Never mount production files, the host's `/etc/wireguard`, host system D-Bus sockets, or the Docker socket inside fixture containers.
- Poll explicit readiness and handshake/traffic conditions with deadlines rather than fixed sleeps. Always clean up containers, dedicated networks, and temporary resources after success or failure. Keep diagnostic artifacts redacted.