diff --git a/PLANS.md b/PLANS.md index d79b5f0..8d747a4 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 diff --git a/docs/evidence/milestone-14.md b/docs/evidence/milestone-14.md new file mode 100644 index 0000000..c70b857 --- /dev/null +++ b/docs/evidence/milestone-14.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 488a1e8..af2dbb9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -86,6 +86,7 @@ pub struct Application { vault: Option, draft_unsupported_reservations: BTreeSet, historical_apply: HistoricalApplyStatus, + verified_saved_projection: Option, active_restart_attempt: Option, 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, 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, + pub section_line: Option, + 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, 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 { .collect() } -fn checked_snapshot_from_document(document: &ConfigDocument) -> Result { - if !document.issues().is_empty() { - return Err(AppError::UnsupportedSavedShape); +fn restart_projection_from_document( + document: &ConfigDocument, +) -> Result<(SavedPeerProjection, Option), Vec> { + 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), #[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| { diff --git a/src/domain.rs b/src/domain.rs index 88f2afe..2c15626 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -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, + ) -> 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; } diff --git a/tests/runtime_restart.rs b/tests/runtime_restart.rs index 52ed31c..ce90803 100644 --- a/tests/runtime_restart.rs +++ b/tests/runtime_restart.rs @@ -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);