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
+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| {