Files

7.1 KiB

Saved state persistence

The persistence adapter reads and installs the application's saved files at the paths in config::ResolvedSettings. Settings remain beside the executable, the server configuration stays at its configured invariant path, and internal state and the opaque encrypted vault live in data_directory. No path is resolved from the process working directory. Archive member names never become filesystem paths.

Internal state v1

internal-state.toml is optional. Its absence is valid only during initial setup; once an installed state file exists, malformed or unsupported data is an error and cannot be treated as an empty reservation set. Version 1 contains no alias registry and does not duplicate server client metadata:

version = 1
reserved_addresses = ["10.0.0.8", "fd00::8"]
last_verified_at_utc = "2026-10-19T12:30:00Z"

reserved_addresses records addresses retained after saved deletion until a later explicit runtime verification. It is canonically sorted and duplicate free. last_verified_at_utc records when this saved generation was last verified against the runtime. It is historical bookkeeping only: loading it always produces RuntimeObservation::Unknown and never establishes that the current interface matches saved files. The timestamp is stored in the same journaled internal-state component as the saved set it describes. Successful Commit and restore write a new generation with this timestamp cleared; failed operations do not advance or clear it. M5 records verification only after a completed pre-restart backup, successful runtime verification, and successful journaled state write. If that write fails, the in-memory applied state remains unchanged. This binding follows the application's single-administrator save boundary; it is not a digest that detects manual edits made outside the application. Unknown fields, duplicate addresses, invalid addresses/timestamps, and unsupported versions are rejected.

Settings bytes are parsed and semantically validated, server bytes are parsed with the byte-preserving ConfigDocument, and a present vault is parsed by VaultBundle::from_toml without unlocking it. Saved reads are bounded and return exact bytes; their Debug representations redact content. The server parser reports its existing anomalies without rewriting or normalizing bytes. The server configuration and a present vault must already have mode 0600 or stricter when read. A broader mode fails closed with an actionable permission error; the application does not silently chmod an existing secret-bearing file. The operator must restrict its mode and retry. Newly installed replacements are created as 0600. During restore, operational settings such as endpoint, DNS, systemd unit, and timeout may come from the archive. The configured interface name remains fixed for this installation, along with the resolved server configuration, client source, and data-root paths (including their derived data/archive directories). This preserves the single-interface mapping while allowing an archive to restore other settings without redirecting writes.

Destination and path safety

The adapter accepts only the resolved settings path, configured server path, data_directory/internal-state.toml, and data_directory/vault.toml. These paths are checked for overlap/aliasing before a write. Every existing path component is checked without following symlinks; saved files must be regular files. Destination directories are private (0700), and replacement and journal files are created 0600 from their creation. Symlinks, non-regular files, unsafe directory components, and path aliasing fail closed. No archive metadata controls destination names, permissions, or ownership.

Multi-file installation and recovery

Installation is not a cross-directory atomic operation. The caller must first complete the mandatory operation backup and supply the adapter's BackupComplete capability. The adapter then validates the complete proposed saved set and checks that no earlier transaction needs recovery. It creates a fixed-name private transaction directory under the trusted data directory and writes the prior bytes (including explicit absence) and proposed bytes under fixed component names. Each payload and the transaction directory are synced, then the journal metadata and a prepared marker are atomically published and the journal and parent data directory are synced. Only then may destination files be changed. An interrupted journal without a valid prepared marker is safe to clean because the adapter cannot have started replacements yet.

Replacement order is settings, server configuration, internal state, then vault. A deletion of an optional state or vault is part of that order. Each new file is written completely to a same-directory 0600 temporary file, flushed and synced, atomically renamed, and followed by a parent-directory sync. After all replacements, the adapter syncs all affected parents, records the transaction as committed, syncs that marker and its parent, then removes the transaction directory and syncs the data directory. Only a fully completed sequence returns success.

On reopen, an uncommitted journal means rollback: prior bytes are reinstalled in reverse replacement order, previously absent optional files are removed, and all affected parent directories are synced. If rollback or cleanup fails, the adapter returns RecoveryRequired and rejects every new mutation. Recovery can be retried after correcting the filesystem problem. A committed journal means all replacements finished; reopening performs cleanup only and never rolls back the completed set. If writing/syncing the committed marker or cleaning the journal fails during installation, the result is explicitly RecoveryRequired, not success. The next open determines rollback versus cleanup from the durable marker.

This journal repairs interrupted saved-file writes only. It never invokes a 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.