Add runtime observation and systemd restart adapter
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user