Fix systemd service restart

This commit is contained in:
2026-10-27 21:55:59 +02:00
parent 4bb975f122
commit 86c28a35b5
5 changed files with 516 additions and 45 deletions
+54 -1
View File
@@ -123,6 +123,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
| 11 | Valid subnet routes shown as a capability limitation | 10 | accepted |
| 12 | Aligned, responsive client table | 11 | accepted |
| 13 | Compact terminal QR disclosure | 12 | accepted |
| 14 | Restart verification for valid legacy peers | 13 | accepted |
### Milestone 1 — Rust foundation, settings, and domain states
@@ -3593,8 +3594,60 @@ QR cells; the tested bordered layout with a one-row notice fits at 75×44.
Longer notices increase the required terminal height. Physical terminal and
phone/decoder scanning remain unverified; M9 systemd acceptance remains retired.
### Milestone 14 — Restart verification for valid legacy peers
Status: accepted (2026-10-27T21:55:11+02:00). M13 is accepted; M9 remains retired and
unaccepted. This is a scoped fix for restart preview and durable verification.
#### Outcome and scope
Use the complete saved technical peer set for restart eligibility and runtime
verification, independently of optional managed alias metadata and the narrower
management `Client` shape. Do not fabricate aliases/dates, rewrite saved peers,
omit peers, or record a partial management snapshot as applied. Preserve the
pre-restart backup, explicit choice, configured service/timeout, draft handling,
and address-release boundary. Unsafe saved documents must stop before service
mutation with secret-free identity/section and condition diagnostics.
#### Tasks and acceptance
| Task | Owner | State | Evidence required |
| --- | --- | --- | --- |
| M14.1 | implementer | complete for implementation scope | Restart projection and fixture regressions in `src/app.rs`, `src/domain.rs`, `tests/runtime_restart.rs` |
| M14.2 | independent reviewer; repairer | complete | Independent behavior review found no issue; M14-R1 Clippy repair independently re-reviewed and closed |
| M14.3 | coordinator | complete | Integrated files, pinned Rust gates and source/artifact identity recorded in [M14 evidence](docs/evidence/milestone-14.md) |
- [x] M14-A1: saved managed client plus valid metadata-free legacy peer previews
and restarts after full synthetic runtime match, without config rewrites.
- [x] M14-A2: supported IPv4-only and technically valid route shapes outside
the management model can be verified as a complete saved peer set.
- [x] M14-A3: malformed/ambiguous peers and structural problems block before
backup/service mutation with actionable public identity or section location.
- [x] M14-A4: missing/extra/mismatched or incomplete runtime peers cannot be
recorded as applied or release deleted addresses; successful full verification
persists historical status and safe reservations across reopen.
- [x] M14-A5: independent review closes findings; focused and required Rust
completion gates pass. Fixture results remain separate from VM systemd evidence.
Planned checks: focused `cargo test --locked app::tests::` and runtime restart
tests, `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`,
`cargo test --locked`, `cargo build --locked --release`, `git diff --check`.
Synthetic tests use fake service/runtime adapters; no Docker or VM is required.
Detailed evidence will be recorded in `docs/evidence/milestone-14.md`.
#### Outcome and handoff
Accepted for the synthetic application/runtime-adapter scope. Complete
`SavedPeerProjection` verification covers valid legacy and broader-route peers;
no partial `Client` snapshot is recorded as applied. M14-R1 (P3, closed):
Clippy's collapsible diagnostic guard was repaired, independently re-reviewed,
and all integrated gates passed. [M14 evidence](docs/evidence/milestone-14.md)
records source identity and tests. No disposable-VM systemd acceptance or
production access is claimed; M9 remains retired and unaccepted.
## Current progress and resume point
- Milestone 14 is accepted (2026-10-27) for fixture restart orchestration: complete saved-peer projection, legacy/IPv4-only/broader-route eligibility, safe blockers and durable verified history passed independent review and pinned Rust gates. [M14 evidence](docs/evidence/milestone-14.md) records source/artifact identity. No disposable-VM systemd acceptance or production result is claimed.
- Milestone 13 is accepted (2026-10-27): compact, explicitly colored QR disclosure, complete quiet zone and dimensioned fallback passed synthetic rendering tests, independent R5 closure and pinned Rust gates. [M13 evidence](docs/evidence/milestone-13.md) records the reviewed source and release artifact. No manual scan, Alpine rerun, VM or production validation is claimed.
- Bounded TUI maintenance TUI-TS-001 (2026-10-27): new-client form creation timestamps now have whole-second UTC precision before strict metadata rendering. A fixed fractional-second instant and a synthetic blank-IP form-to-draft workflow passed; saved bytes stayed unchanged. Independent review found no actionable issue, and pinned-toolchain Rust completion gates passed. [Timestamp repair evidence](docs/evidence/tui-creation-timestamp.md) identifies the source and release artifact. This repairs M7 behavior without claiming new Alpine or VM validation; earlier artifact identities remain historical after this Rust change.
- Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change.
@@ -3614,7 +3667,7 @@ phone/decoder scanning remain unverified; M9 systemd acceptance remains retired.
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
- Next action: await user direction after M13. Do not restart the VM, access production, commit, or deploy.
- Next action: await user direction after M14. Do not restart the VM, access production, commit, or deploy.
## Plan maintenance
+68
View File
@@ -0,0 +1,68 @@
# Milestone 14 acceptance evidence: restart with valid legacy peers
Validated 2026-10-27T21:55:11+02:00 on the native
`x86_64-unknown-linux-gnu` development host. Results below use synthetic
saved files, fake service/runtime adapters, and existing pure runtime comparison
tests. They do not establish a real systemd restart.
## Behavior and review
Restart preview and confirmation now use the complete `SavedPeerProjection`
derived from the authoritative saved configuration. It includes every valid
peer's public key and complete normalized `AllowedIPs`, independently of
optional managed alias metadata and the narrower management `Client` shape.
The existing runtime comparator still requires a complete observed peer set
and checks all saved peers and routes before verification can be recorded.
The management applied snapshot remains absent when it cannot represent the
whole saved peer set; no partial `Client` snapshot is marked applied.
Synthetic fixtures cover a saved managed client alongside metadata-free legacy
peers, IPv4-only host routes, and valid subnet routes outside the management
model. Preview preserves server bytes and creates no backup. Fake successful
service completion plus a full runtime match creates the required pre-restart
backup, records verified historical status, and releases deleted-address
reservations only after durable installation. Reopening retains that history,
starts with runtime `Unknown`, and does not regain a partial applied snapshot.
A missing legacy runtime peer leaves reservations durable and runtime unknown.
Malformed AllowedIPs, duplicate public identity, and malformed section headers
block preview before backup/service mutation with a secret-free public identity
or section line and condition. Existing tests also cover backup failure,
service timeout/failure, stale saved generation, runtime mismatch, persistence
failure, draft cancellation and `SaveConfig` rejection.
An independent GPT-6-Luna/high reviewer found no actionable behavior finding.
The first coordinator Clippy gate found M14-R1 (P3), a collapsible diagnostic
guard in `src/app.rs`. A separate GPT-6-Luna/high repairer fixed it; the
reviewer confirmed equivalent logic and closed R1. The final integrated gates
below ran after that repair.
## Commands and source identity
Pinned rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified.
The following commands ran from the repository root and exited 0 on the
integrated revision:
- `cargo test --locked app::tests::`: 57 passed.
- `cargo test --locked --test runtime_restart`: 3 passed.
- `cargo fmt --all -- --check`.
- `cargo clippy --locked --all-targets -- -D warnings`.
- `cargo test --locked`: 357 passed, 3 explicitly ignored, 0 failed,
including 2 documentation tests.
- `cargo build --locked --release`.
- `git diff --check`.
Base commit: `4bb975f122abfdeabbd04fe6b737eec5706596f5`. Dirty Rust inputs
at validation were `src/app.rs`, `src/domain.rs`, and `tests/runtime_restart.rs`;
`PLANS.md` was dirty with this milestone's execution record. SHA256 inputs:
- `src/app.rs`: `01cf281f5b8ae62877dcf63ece7d51b5cca5522f6bc5c9e99dbc0e718a4121f4`
- `src/domain.rs`: `179ecbe66ad7b6e022233a6da3b62a6ce57d92d26d32b0ae6c750ccd2edc5f01`
- `tests/runtime_restart.rs`: `4cff0de16a18cccc846ace86808840567165fc110d121fd7bdc459481379e05f`
Native release artifact `target/release/wireguard-admin` SHA256:
`a7c946297d2a0140ddcca1c15bb3f651fe6ddc494c2eb7e8e713fdfc700e6b5a`.
No Alpine rerun, disposable systemd VM, or production operation was performed.
The accepted M8 Alpine artifact identity predates this revision; it does not
validate this build. M9's real systemd/D-Bus acceptance remains retired and
unverified.
+354 -44
View File
@@ -86,6 +86,7 @@ pub struct Application {
vault: Option<VaultBundle>,
draft_unsupported_reservations: BTreeSet<IpAddr>,
historical_apply: HistoricalApplyStatus,
verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>,
next_restart_attempt: u64,
next_issue_authorization: u64,
@@ -245,7 +246,7 @@ pub struct RestartPreview {
unit: String,
timeout: Duration,
projection: SavedPeerProjection,
expected_clients: Snapshot,
expected_clients: Option<Snapshot>,
has_draft: bool,
}
@@ -301,6 +302,15 @@ pub enum RestartFailureKind {
VerificationPersistenceFailed,
}
/// Secret-free explanation for saved configuration that cannot be used as a
/// restart verification target. The identity is a public WireGuard key.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartDiagnostic {
pub identity: Option<PublicKey>,
pub section_line: Option<usize>,
pub reason: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartReport {
pub stage: RestartStage,
@@ -523,6 +533,7 @@ impl Application {
vault,
draft_unsupported_reservations: BTreeSet::new(),
historical_apply,
verified_saved_projection: None,
active_restart_attempt: None,
next_restart_attempt: 1,
next_issue_authorization: 1,
@@ -964,15 +975,14 @@ impl Application {
installed: &SavedBytes,
document: &ConfigDocument,
) -> bool {
if !same_saved_bytes(&self.saved, installed) {
if !same_saved_bytes(&self.saved, installed) || self.verified_saved_projection.is_none() {
return false;
}
let Ok(snapshot) = checked_snapshot_from_document(document) else {
let Ok((projection, _)) = restart_projection_from_document(document) else {
return false;
};
self.states.applied() == &RuntimeObservation::Verified
&& self.states.saved() == &snapshot
&& self.states.applied_snapshot() == Some(&snapshot)
&& self.verified_saved_projection.as_ref() == Some(&projection)
}
/// Recheck both the one-use token and installed saved generation as the
@@ -1339,7 +1349,11 @@ impl Application {
/// Drop all uncommitted edits and restore the in-memory drafts from the last
/// successfully installed session snapshot. No files are written.
pub fn discard_draft(&mut self) -> Result<(), AppError> {
self.saved = self.store.read_saved()?;
let installed = self.store.read_saved()?;
if !same_saved_bytes(&self.saved, &installed) {
self.verified_saved_projection = None;
}
self.saved = installed;
self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.vault = self
.saved
@@ -1422,8 +1436,9 @@ impl Application {
if document.has_unsafe_save_config() {
return Err(AppError::UnsafeSaveConfig);
}
let projection = SavedPeerProjection::from_document(&document);
let expected_clients = checked_snapshot_from_document(&document)?;
let (projection, expected_clients) =
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
Ok(RestartPreview {
saved: clone_saved(&installed),
interface: settings.interface_name,
@@ -1606,8 +1621,8 @@ impl Application {
false,
);
}
let expected_clients = match checked_snapshot_from_document(&document) {
Ok(snapshot) => snapshot,
let (projection, expected_clients) = match restart_projection_from_document(&document) {
Ok(projection) => projection,
Err(_) => {
return restart_report(
RestartStage::Preparation,
@@ -1619,7 +1634,7 @@ impl Application {
);
}
};
let projection = SavedPeerProjection::from_document(&document);
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
if settings.interface_name != preview.interface
|| settings.systemd_unit != preview.unit
|| settings.restart_timeout != preview.timeout
@@ -1635,20 +1650,6 @@ impl Application {
false,
);
}
if projection.document_unverifiable
|| !projection.unverifiable_peer_indices.is_empty()
|| !projection.duplicate_public_keys.is_empty()
{
return restart_report(
RestartStage::Preparation,
Some(RestartFailureKind::UnsupportedSavedShape),
None,
false,
false,
false,
);
}
let historical_apply = match historical_apply_from_saved(&installed) {
Ok(status) => status,
Err(_) => {
@@ -1947,6 +1948,7 @@ impl Application {
self.initial_issues.clear();
self.draft_unsupported_reservations.clear();
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
self.active_restart_attempt = None;
Ok(archive)
}
@@ -1956,7 +1958,7 @@ impl Application {
fn mark_verified_with_attempt(
&mut self,
attempt: RestartAttempt,
observed: Snapshot,
observed_management_snapshot: Option<Snapshot>,
verified_at_utc: String,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<(), AppError> {
@@ -1970,7 +1972,7 @@ impl Application {
return Err(AppError::SavedGenerationChanged);
}
let mut next_states = self.states.clone();
next_states.mark_applied_after_verification(observed)?;
next_states.mark_technically_applied_after_verification(observed_management_snapshot)?;
let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?,
None => PersistedState::new(
@@ -2019,6 +2021,7 @@ impl Application {
}
self.saved = proposed;
self.states = next_states;
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
self.historical_apply = historical_apply;
self.active_restart_attempt = None;
Ok(())
@@ -2131,6 +2134,7 @@ impl Application {
self.vault.as_ref(),
);
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
self.active_restart_attempt = None;
Ok(archive)
}
@@ -2304,20 +2308,96 @@ fn document_host_addresses(document: &ConfigDocument) -> Vec<IpAddr> {
.collect()
}
fn checked_snapshot_from_document(document: &ConfigDocument) -> Result<Snapshot, AppError> {
if !document.issues().is_empty() {
return Err(AppError::UnsupportedSavedShape);
fn restart_projection_from_document(
document: &ConfigDocument,
) -> Result<(SavedPeerProjection, Option<Snapshot>), Vec<RestartDiagnostic>> {
use crate::wireguard::{DocumentIssue, PeerIssue};
let mut diagnostics = Vec::new();
for issue in document.issues() {
let (line, reason) = match issue {
DocumentIssue::MalformedSectionHeader { line } => (*line, "malformed section header"),
DocumentIssue::DirectiveOutsideSection { line } => {
(*line, "directive appears outside a section")
}
DocumentIssue::MalformedDirective { line } => (*line, "malformed directive"),
// Managed metadata is descriptive and does not affect the technical
// peer set used for runtime verification.
DocumentIssue::ManagedMetadata(_) => continue,
};
diagnostics.push(RestartDiagnostic {
identity: None,
section_line: Some(line),
reason,
});
}
for peer in document.peers() {
let identity = peer
.identity
.or_else(|| peer.public_key_candidates.first().copied());
for issue in &peer.issues {
let reason = match issue {
PeerIssue::MissingPublicKey => Some("peer is missing a public key"),
PeerIssue::MalformedPublicKey { .. } => Some("peer has a malformed public key"),
PeerIssue::DuplicatePublicKeyField { .. } => {
Some("peer has duplicate public key fields")
}
PeerIssue::DuplicatePeerIdentity { .. } => {
Some("public key is used by multiple peer sections")
}
PeerIssue::MalformedAllowedIp { .. } => {
Some("peer has a malformed AllowedIPs item")
}
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
None
}
};
if let Some(reason) = reason {
diagnostics.push(RestartDiagnostic {
identity,
section_line: Some(peer.section_line),
reason,
});
}
}
}
let projection = SavedPeerProjection::from_document(document);
if (projection.document_unverifiable
|| !projection.unverifiable_peer_indices.is_empty()
|| !projection.duplicate_public_keys.is_empty())
&& diagnostics.is_empty()
{
diagnostics.push(RestartDiagnostic {
identity: None,
section_line: None,
reason: "saved peer set is not uniquely verifiable",
});
}
if !diagnostics.is_empty() {
return Err(diagnostics);
}
let mut clients = Vec::with_capacity(document.peers().len());
for peer in document.peers() {
let identity = peer.identity.ok_or(AppError::UnsupportedSavedShape)?;
clients.push(
document
.project_client(&identity)
.map_err(|_| AppError::UnsupportedSavedShape)?,
);
let Some(identity) = peer.identity else {
return Err(vec![RestartDiagnostic {
identity: None,
section_line: Some(peer.section_line),
reason: "peer identity is unavailable",
}]);
};
let Ok(client) = document.project_client(&identity) else {
clients.clear();
break;
};
clients.push(client);
}
Snapshot::new(clients).map_err(|_| AppError::UnsupportedSavedShape)
let management_snapshot = (clients.len() == document.peers().len())
.then(|| Snapshot::new(clients).ok())
.flatten();
Ok((projection, management_snapshot))
}
fn restart_report(
@@ -2375,10 +2455,10 @@ pub enum AppError {
PreRestartBackupRequired,
#[error("wg-quick SaveConfig may overwrite the saved server configuration during restart")]
UnsafeSaveConfig,
#[error(
"the complete saved peer set cannot be represented for durable applied-state recording"
)]
#[error("saved peer cannot be represented by the client management model")]
UnsupportedSavedShape,
#[error("saved server configuration cannot be verified safely: {0:?}")]
UnsafeSavedPeers(Vec<RestartDiagnostic>),
#[error("installed saved bytes changed after this session opened")]
SavedGenerationChanged,
#[error(transparent)]
@@ -2515,9 +2595,12 @@ restart_timeout_seconds = 30
id,
saved: clone_saved(&app.saved),
};
app.mark_verified_with_attempt(attempt, observed, verified_at, |store, saved, backup| {
store.install(saved, backup)
})
app.mark_verified_with_attempt(
attempt,
Some(observed),
verified_at,
|store, saved, backup| store.install(saved, backup),
)
}
fn prepare_restartable(root: &Path) -> (Application, PublicKey) {
@@ -4191,6 +4274,233 @@ restart_timeout_seconds = 30
);
}
#[test]
fn restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot() {
let root = tempfile::tempdir().expect("temp directory");
let (mut app, _) = prepare_restartable(root.path());
let deleted_key = WireGuardPrivateKey::generate()
.expect("synthetic deleted-client key")
.public_key();
app.add_client(
Client::new(
deleted_key,
"deleted reservation fixture",
ClientAddresses {
ipv4: "10.20.0.4".parse().expect("synthetic IPv4"),
ipv6: Some("fd20::4".parse().expect("synthetic IPv6")),
},
)
.expect("synthetic managed client"),
None,
)
.expect("stage managed client");
app.commit().expect("save second managed client");
app.delete_client(deleted_key)
.expect("delete managed peer in draft");
app.commit()
.expect("save deletion and reserve its addresses");
let mut saved = clone_saved(app.saved_bytes());
saved.server_config.extend_from_slice(
format!(
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 192.0.2.0/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.99/32\n",
PublicKey::from_bytes([19; 32]),
PublicKey::from_bytes([20; 32]),
)
.as_bytes(),
);
app.store
.install(&saved, BackupComplete(()))
.expect("install synthetic legacy saved generation");
let mut app = Application::open_from_saved_store(app.store.clone())
.expect("open metadata-free legacy peers");
assert!(!app.states().reserved_addresses().is_empty());
let original_config = app.saved_bytes().server_config.clone();
let preview = app
.preview_restart()
.expect("valid legacy peers are eligible");
let observation = observed_runtime(&app);
let report = app.restart_with_adapters(
preview,
RestartChoice::RestartSavedState,
|unit, _| {
assert_eq!(unit, "wg-quick@wg0.service");
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
}
},
|_, _| Ok(observation),
|store, saved, backup| store.install(saved, backup),
);
assert_eq!(report.stage, RestartStage::Complete);
assert_eq!(app.states().applied(), &RuntimeObservation::Verified);
assert!(app.states().applied_snapshot().is_none());
assert!(app.states().reserved_addresses().is_empty());
assert_eq!(app.saved_bytes().server_config, original_config);
assert!(app.saved_generation_is_verified_applied(app.saved_bytes(), app.document()));
let mut different_generation = clone_saved(app.saved_bytes());
different_generation.settings.push(b'\n');
assert!(!app.saved_generation_is_verified_applied(&different_generation, app.document()));
let historical_apply = app.historical_apply.clone();
let reopened = Application::open_from_saved_store(app.store.clone())
.expect("reopen historically verified saved state");
assert_eq!(reopened.historical_apply, historical_apply);
assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown);
assert!(reopened.states().applied_snapshot().is_none());
assert!(reopened.states().reserved_addresses().is_empty());
assert!(
!reopened
.saved_generation_is_verified_applied(reopened.saved_bytes(), reopened.document())
);
}
#[test]
fn unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location() {
let root = tempfile::tempdir().expect("temp directory");
let (app, _) = prepare_restartable(root.path());
let key = PublicKey::from_bytes([21; 32]);
let mut saved = clone_saved(app.saved_bytes());
saved.server_config.extend_from_slice(
format!(
"\n[Peer]\nPublicKey = {key}\nPresharedKey = synthetic-peer-secret\nAllowedIPs = malformed-route\n"
)
.as_bytes(),
);
app.store
.install(&saved, BackupComplete(()))
.expect("install malformed synthetic peer");
let app = Application::open_from_saved_store(app.store.clone())
.expect("open malformed peer for diagnostics");
let backup_count = app.list_backups().expect("list existing archives").len();
let error = match app.preview_restart() {
Ok(_) => panic!("unsafe peer must block preview"),
Err(error) => error,
};
let AppError::UnsafeSavedPeers(diagnostics) = error else {
panic!("expected actionable unsafe peer diagnostics");
};
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic.identity == Some(key)
&& diagnostic.section_line.is_some()
&& diagnostic.reason == "peer has a malformed AllowedIPs item"
}));
let rendered = format!("{diagnostics:?}");
assert!(!rendered.contains("synthetic-peer-secret"));
assert!(!rendered.contains("malformed-route"));
assert_eq!(
app.list_backups().expect("backup listing").len(),
backup_count,
"unsafe preview must not create a restart archive"
);
}
#[test]
fn incomplete_legacy_runtime_match_keeps_deleted_addresses_reserved() {
let root = tempfile::tempdir().expect("temp directory");
let (mut app, _) = prepare_restartable(root.path());
let deleted_key = WireGuardPrivateKey::generate()
.expect("synthetic deleted-client key")
.public_key();
app.add_client(
Client::new(
deleted_key,
"deleted reservation fixture",
ClientAddresses {
ipv4: "10.20.0.4".parse().expect("synthetic IPv4"),
ipv6: Some("fd20::4".parse().expect("synthetic IPv6")),
},
)
.expect("synthetic managed client"),
None,
)
.expect("stage managed client");
app.commit().expect("save second managed client");
app.delete_client(deleted_key)
.expect("delete managed peer in draft");
app.commit()
.expect("save deletion and reserve its addresses");
let reserved_before = app.states().reserved_addresses().clone();
let mut saved = clone_saved(app.saved_bytes());
let legacy_key = PublicKey::from_bytes([22; 32]);
saved.server_config.extend_from_slice(
format!("\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.99/32\n").as_bytes(),
);
app.store
.install(&saved, BackupComplete(()))
.expect("install synthetic legacy peer");
let mut app =
Application::open_from_saved_store(app.store.clone()).expect("reopen with legacy peer");
let preview = app.preview_restart().expect("legacy peer preview succeeds");
let mut incomplete = observed_runtime(&app);
let InterfaceObservation::Present { peers, .. } = &mut incomplete.interface else {
panic!("fixture interface is present");
};
peers.peers.remove(&legacy_key);
let report = app.restart_with_adapters(
preview,
RestartChoice::RestartSavedState,
|_, _| RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
},
|_, _| Ok(incomplete),
|store, saved, backup| store.install(saved, backup),
);
assert_eq!(report.stage, RestartStage::Verification);
assert_eq!(report.failure, Some(RestartFailureKind::RuntimeMismatch));
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
assert_eq!(app.states().reserved_addresses(), &reserved_before);
let reopened = Application::open_from_saved_store(app.store.clone())
.expect("failed verification leaves saved reservations durable");
assert_eq!(reopened.states().reserved_addresses(), &reserved_before);
assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown);
}
#[test]
fn ambiguous_identity_and_malformed_structure_block_restart_preview() {
for malformed in [false, true] {
let root = tempfile::tempdir().expect("temp directory");
let (app, managed_identity) = prepare_restartable(root.path());
let mut saved = clone_saved(app.saved_bytes());
if malformed {
saved.server_config.extend_from_slice(
b"\n[Peer\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n",
);
} else {
saved.server_config.extend_from_slice(
format!(
"\n[Peer]\nPublicKey = {managed_identity}\nAllowedIPs = 10.20.0.8/32\n"
)
.as_bytes(),
);
}
app.store
.install(&saved, BackupComplete(()))
.expect("install unsafe synthetic saved bytes");
let app = Application::open_from_saved_store(app.store.clone())
.expect("open unsafe saved bytes for preview");
let error = match app.preview_restart() {
Ok(_) => panic!("unsafe saved bytes must block restart preview"),
Err(error) => error,
};
let AppError::UnsafeSavedPeers(diagnostics) = error else {
panic!("expected safe structured diagnostics");
};
let expected_reason = if malformed {
"malformed section header"
} else {
"public key is used by multiple peer sections"
};
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic.reason == expected_reason && diagnostic.section_line.is_some()
}));
}
}
#[test]
fn service_timeout_reports_submission_and_may_still_complete() {
let root = tempfile::tempdir().expect("temp directory");
@@ -4553,7 +4863,7 @@ restart_timeout_seconds = 30
};
let result = app.mark_verified_with_attempt(
attempt,
observed,
Some(observed),
"2026-10-20T08:30:00Z".to_owned(),
|store, saved, _backup| {
store.install_with_hook(saved, |point| {
+17
View File
@@ -270,6 +270,23 @@ impl ClientStates {
Ok(())
}
/// Record full technical verification when the complete peer set cannot
/// be represented by the narrower management snapshot. `None` deliberately
/// keeps `applied_snapshot` absent instead of treating a partial snapshot as
/// evidence that every saved peer was applied.
pub(crate) fn mark_technically_applied_after_verification(
&mut self,
observed_management_snapshot: Option<Snapshot>,
) -> Result<(), DomainError> {
if let Some(observed) = observed_management_snapshot {
return self.mark_applied_after_verification(observed);
}
self.applied_snapshot = None;
self.applied = RuntimeObservation::Verified;
self.reserved_addresses.clear();
Ok(())
}
pub fn mark_runtime_unavailable(&mut self) {
self.applied = RuntimeObservation::Unavailable;
}
+23
View File
@@ -124,6 +124,29 @@ fn public_restart_preview_and_cancel_preserve_draft_and_saved_files() {
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
}
#[test]
fn public_restart_preview_accepts_metadata_free_ipv4_and_broader_legacy_routes() {
let fixture = fixture("wg-quick@wg0.service", 30);
let server_path = fixture._root.path().join("server.conf");
let mut server = fs::read(&server_path).expect("synthetic server config reads");
server.extend_from_slice(
b"\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.20.0.19/32\n\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 192.0.2.0/24, 2001:db8::/48\n",
);
fs::write(&server_path, &server).expect("install synthetic legacy peers");
let app = Application::open_from_saved_store(fixture.store.clone())
.expect("open metadata-free legacy peers");
let preview = app.preview_restart().expect("valid routes are restartable");
assert_eq!(preview.interface(), "wg0");
assert_eq!(preview.systemd_unit(), "wg-quick@wg0.service");
assert_eq!(
fs::read(server_path).expect("server config remains readable"),
server
);
assert!(app.list_backups().expect("preview is read-only").is_empty());
}
#[test]
fn restore_changes_the_settings_used_by_the_next_restart_preview() {
let fixture = fixture("wg-quick@wg0.service", 30);