Fix systemd service restart
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user