Implement readable saved-state Commit and restore scenarios
This commit is contained in:
@@ -132,7 +132,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
||||
| 20 | Explicit vocabulary and named operation contexts | 19 | accepted |
|
||||
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
|
||||
| 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
|
||||
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending |
|
||||
| 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
|
||||
| 24 | Session-owned first delivery and password reissue scenarios | 23 | pending |
|
||||
| 25 | Explicit restart verification scenario | 24 | pending |
|
||||
| 26 | Application read models and thin TUI command adapter | 25 | pending |
|
||||
@@ -4015,7 +4015,7 @@ M22.1 added the internal owner and staged replacement API; M22.2 migrated assign
|
||||
|
||||
### Milestone 23 — Readable saved-state Commit and restore scenarios
|
||||
|
||||
Status: pending. Prerequisite: M22 accepted.
|
||||
Status: accepted (2026-10-29T23:33:11+02:00; independent M23-R1 re-review, M23-R2 evidence verification and integrated Rust gates passed). Prerequisite: M22 accepted (2026-10-29T23:14:00+02:00). The user previously confirmed this session's coordinator GPT-6-Sol/medium setting; project configuration and explicit worker dispatch selected GPT-6-Luna/high. Initial tree clean at `c1d4f93`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 verified. M9 is retired/unaccepted by user direction and is not an M23 prerequisite.
|
||||
|
||||
#### Outcome and scope
|
||||
|
||||
@@ -4023,9 +4023,9 @@ Extract explicit saved-state scenarios with visible ordering and honest failure
|
||||
|
||||
| Task | Owner | State | Bounded deliverable |
|
||||
| --- | --- | --- | --- |
|
||||
| M23.1 | implementer | pending | Commit preparation, backup/install, in-memory publication |
|
||||
| M23.2 | implementer | pending | Backup listing/creation and preview-confirm restore scenario |
|
||||
| M23.3 | reviewer; repairer; coordinator | pending | Ordering/recovery/compatibility review, repair and RUST acceptance |
|
||||
| M23.1 | implementer `/root/m23_commit` | complete | Commit prepare/validate -> backup -> install -> publish; focused checks passed |
|
||||
| M23.2 | implementer `/root/m23_commit` | complete | Backup listing/creation and preview-confirm restore extraction; exact-host reservation gap test/repair; focused checks passed |
|
||||
| M23.3 | reviewer `/root/m23_review`; repairer `/root/m23_repair`; coordinator | complete | M23-R1 closed on independent re-review; M23-R2 evidence verified; integrated RUST passed |
|
||||
|
||||
1. Extract Commit as readable preparation -> validate proposed saved bytes -> completed pre-Commit backup of installed state -> journaled install -> publish new session state. Keep a concrete prepared result if it clarifies which fallible work precedes mutation. Do not turn backup confirmation into a public forgeable permission or allow callers to skip it.
|
||||
2. Preserve failure behavior: draft remains retryable, readback after failed install conservatively retains/revokes first-issue rights, saved/application history is not advanced on failure, and recovery on reopen follows the existing journal. Avoid an apparently infallible final publication that hides parsing or state-install errors.
|
||||
@@ -4035,11 +4035,11 @@ Extract explicit saved-state scenarios with visible ordering and honest failure
|
||||
|
||||
#### Acceptance and validation
|
||||
|
||||
- [ ] M23-A1: completed backup precedes protected mutation for Commit/restore; failures and cancel retain documented draft/saved/runtime behavior.
|
||||
- [ ] M23-A2: restore compatibility, path restrictions, absent components, recovery and historical status tests retain their assertions.
|
||||
- [ ] M23-A3: fixture old-state backups and state/vault/archive compatibility checks pass; no runtime/service call in saved-only paths; independent review and RUST pass.
|
||||
- [x] M23-A1: completed backup precedes protected mutation for Commit/restore; failures and cancel retain documented draft/saved/runtime behavior.
|
||||
- [x] M23-A2: restore compatibility, path restrictions, absent components, recovery and historical status tests retain their assertions.
|
||||
- [x] M23-A3: fixture old-state backups and state/vault/archive compatibility checks pass; no runtime/service call in saved-only paths; independent review and RUST pass.
|
||||
|
||||
Focused commands: `cargo test --locked app::`, `cargo test --locked persistence::`, `cargo test --locked backup::`, `cargo test --locked --test commit_restore`, `cargo test --locked --test client_delivery`. Evidence: docs/evidence/milestone-23.md. Outcome: not accepted. Handoff: M24 after acceptance.
|
||||
M23.1 and M23.2 focused checks passed. Fresh reviewer found M23-R1: over-reservation of exact routes retained by an unprojectable restored peer. Repair separated archived from newly carried reservations; independent re-review closed M23-R1. M23-R2, the missing evidence artifact, was closed after the reviewer verified the report and source/artifact hashes. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (404 passed, 3 ignored) and `cargo build --locked --release` exited 0; `git diff --check` passed. [M23 evidence](docs/evidence/milestone-23.md) records focused runs, final source, review and limits. Outcome: accepted for host/synthetic scope; stop after M23. Next action only on a separate request is M24.1; no fresh Alpine/VM result is claimed.
|
||||
|
||||
### Milestone 24 — Session-owned first delivery and password reissue scenarios
|
||||
|
||||
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
||||
|
||||
## Current progress and resume point
|
||||
|
||||
- M22 is accepted for host/synthetic scope: coordinated draft ownership, edit/setup/import migration, failure tests, fresh independent review and required Rust gates passed. [M22 evidence](docs/evidence/milestone-22.md) records final inputs and limits. M23–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
||||
- M23 is accepted for host/synthetic scope: saved-only Commit/backup/restore extraction, exact-host reservation repair, independent M23-R1/R2 closure and required Rust gates passed. [M23 evidence](docs/evidence/milestone-23.md) records final inputs and limits. M24–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
||||
|
||||
- M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
|
||||
|
||||
@@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
||||
|
||||
- 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.
|
||||
- M18–M22 handoffs remain complete. Stop after M22; begin M23.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
|
||||
- M18–M23 handoffs remain complete. Stop after M23; begin M24.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
|
||||
|
||||
## Plan maintenance
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ src/
|
||||
app.rs # Application operations and orchestration
|
||||
app/
|
||||
draft.rs # Coordinated in-memory document, client-state, and vault drafts
|
||||
commit.rs # Prepare, backup, install, and publish saved-state Commit
|
||||
restore.rs # Bound preview and confirmed saved-state replacement
|
||||
backups.rs # Manual/pre-operation backup and archive-list scenarios
|
||||
wireguard.rs # Server configuration module boundary
|
||||
wireguard/
|
||||
document.rs # Parsing and edits preserving original text
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Milestone 23 evidence — saved-state Commit, backup, and restore scenarios
|
||||
|
||||
Run: 2026-10-29T23:32:25+02:00. Base commit: `c1d4f93` (clean at start). Final dirty source inputs are `src/app.rs`, new `src/app/commit.rs`, `src/app/restore.rs`, `src/app/backups.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce identity with `sha256sum`:
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| `src/app.rs` | `9fe74372a68182dc24d7061b4409d3dd6970bc77e3528560da2cacd9d70876b2` |
|
||||
| `src/app/commit.rs` | `e8af86c36613d91605c81c3dca82fb3d1b3b0dd9b41fdbfeaba9e84984fc091b` |
|
||||
| `src/app/restore.rs` | `5dd96ee9650ce66e17378eebea2adf18da746264814f0f0d6258a6ebbda651e4` |
|
||||
| `src/app/backups.rs` | `d837fb0dd29cd3affd9ae12233ecb0337e9a308d730b24a4c892c7bc2605e6ef` |
|
||||
| `docs/PROJECT_LAYOUT.md` | `c9d873caf3c46d48392e6352b5aed036ce3fdaa471c66b4df8d92994c1a2f5ef` |
|
||||
| `target/release/wireguard-admin` | `44444967d552bbb7bac2d36423cc943cde2ced783190946aae67f12e0283541e` |
|
||||
|
||||
Native host toolchain: rustc/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, `src/persistence.rs`, and `src/backup.rs` have no diff.
|
||||
|
||||
## Implementation and review
|
||||
|
||||
- M23.1 extracted Commit into a private scenario: prepare and validate proposed bytes, parsed document, and next client state; complete a pre-Commit archive of the installed generation; perform journaled install; then publish the prepared in-memory state. A failed install reads back conservatively to retain or revoke session-first-delivery rights. The public `Application` facade and narrow test callback remain. No service or runtime call is made by Commit.
|
||||
- M23.2 extracted manual/pre-restart backup listing/creation and previewed restore. Preview is read-only and holds the exact installed generation and selected archive bytes. Confirmation rejects a stale generation before backup, prepares path-compatible settings/document/vault/state, completes a pre-restore archive, performs journaled full replacement, then publishes. An install failure clears session-only delivery rights and reports recovery uncertainty. Absent optional components and archive/historical labels retain their documented semantics. No vault password or runtime call is used.
|
||||
- The M19 exact-host restore gap is repaired: removed unprojectable peers' exact IPv4 `/32` and IPv6 `/128` routes are retained as reservations through reopen and allocation. Broad routes are not expanded into guessed addresses.
|
||||
- Fresh GPT-6-Luna/high reviewer `/root/m23_review`, separate from implementer `/root/m23_commit`, found M23-R1: newly carried routes were reserved even when still present in the restored document. GPT-6-Luna/high repairer `/root/m23_repair` separated archived reservations from carried current/pending addresses and excluded carried exact routes still owned by the restored document. Focused tests cover retained, removed, pending-deletion and archived-reservation cases. Independent re-review closed M23-R1 with no remaining code finding. M23-R2 identified that this evidence file was pending during the first review; it is now present for final verification.
|
||||
|
||||
## Integrated validation
|
||||
|
||||
| Exact command | Result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all -- --check` | exit 0 |
|
||||
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
|
||||
| `cargo test --locked` | exit 0; 404 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
|
||||
| `cargo build --locked --release` | exit 0 |
|
||||
| `git diff --check` | exit 0 |
|
||||
|
||||
Focused implementer/repairer/reviewer runs covered `cargo test --locked app::` (final 67), `cargo test --locked persistence::` (15), `cargo test --locked backup::` (8), `cargo test --locked --test commit_restore` (9), and `cargo test --locked --test client_delivery` (5), all passed on the final code. The full test command supersedes those focused runs as integrated host evidence. The independent review inspected backup-before-install order, saved-only dependencies, path/generation binding, optional components, secret handling, recovery and preserved formats.
|
||||
|
||||
## Acceptance scope and limits
|
||||
|
||||
M23-A1 is supported by reviewed operation ordering and backup/install/cancel failure tests. M23-A2 is supported by restored settings/path, optional-component, recovery, historical-status and old-archive tests. M23-A3 is supported by fixture old-state backups, compatibility tests, the saved-only dependency audit, independent re-review and required Rust gates. This is host/synthetic evidence. No opt-in Alpine or disposable systemd VM run was made for M23; no real systemd, live VPN, production or IPv6 integration behavior is claimed. M9 remains retired/unaccepted.
|
||||
+241
-200
@@ -4,7 +4,10 @@
|
||||
//! encrypted vault data, backups, the recoverable saved-file adapter, and the
|
||||
//! explicit runtime-restart confirmation boundary.
|
||||
|
||||
mod backups;
|
||||
mod commit;
|
||||
mod draft;
|
||||
mod restore;
|
||||
|
||||
use draft::DraftChanges;
|
||||
|
||||
@@ -334,6 +337,7 @@ pub struct RestorePreview {
|
||||
created_at_utc: String,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
installed_generation: SavedBytes,
|
||||
saved_bytes: SavedBytes,
|
||||
}
|
||||
|
||||
@@ -1424,14 +1428,7 @@ impl Application {
|
||||
/// Create the mandatory archive from current installed bytes without
|
||||
/// changing the current session or saved files.
|
||||
pub fn create_backup(&self, reason: BackupReason) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = self.store.read_saved()?;
|
||||
let historical_apply = historical_apply_from_saved(&installed)?;
|
||||
Ok(backup::create_archive(
|
||||
self.store.archives_directory(),
|
||||
reason,
|
||||
historical_apply,
|
||||
&installed,
|
||||
)?)
|
||||
backups::create_backup(self, reason)
|
||||
}
|
||||
|
||||
pub fn create_manual_backup(&self) -> Result<std::path::PathBuf, AppError> {
|
||||
@@ -1439,20 +1436,13 @@ impl Application {
|
||||
}
|
||||
|
||||
pub fn list_backups(&self) -> Result<Vec<backup::ArchiveSummary>, AppError> {
|
||||
match backup::list_archives(self.store.archives_directory()) {
|
||||
Ok(archives) => Ok(archives),
|
||||
Err(BackupError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
backups::list_backups(self)
|
||||
}
|
||||
|
||||
/// Reusable mandatory backup boundary for M5 before it invokes restart.
|
||||
/// This method only archives saved bytes; it never calls a runtime adapter.
|
||||
pub fn create_pre_restart_backup(&mut self) -> Result<std::path::PathBuf, AppError> {
|
||||
self.active_restart_attempt = None;
|
||||
self.create_backup(BackupReason::PreRestart)
|
||||
backups::create_pre_restart_backup(self)
|
||||
}
|
||||
|
||||
/// Prepare a read-only preview bound to the exact currently installed generation.
|
||||
@@ -1895,61 +1885,7 @@ impl Application {
|
||||
/// Validate a selected archive without changing saved files or the draft.
|
||||
/// The returned value carries the exact validated bytes used at confirmation.
|
||||
pub fn preview_restore(&self, file_name: &str) -> Result<RestorePreview, AppError> {
|
||||
if !safe_archive_name(file_name) {
|
||||
return Err(AppError::InvalidArchiveSelection);
|
||||
}
|
||||
let path = self.store.archives_directory().join(file_name);
|
||||
let preview = backup::preview_archive(&path)?;
|
||||
self.store.validate_saved(&preview.saved_bytes)?;
|
||||
let mut proposed = preview.saved_bytes;
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
let snapshot = snapshot_from_document(&document);
|
||||
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
||||
let mut archived_reservations = BTreeSet::new();
|
||||
let mut reservations = self.draft.states.reserved_addresses().clone();
|
||||
// A restore can remove clients that are currently saved even when their
|
||||
// addresses were not yet in the persisted reservation set. Runtime state
|
||||
// remains unchanged, so carry those addresses across the replacement.
|
||||
reservations.extend(
|
||||
self.draft
|
||||
.states
|
||||
.saved()
|
||||
.clients()
|
||||
.flat_map(|client| client.addresses.iter()),
|
||||
);
|
||||
if let Some(state_bytes) = &proposed.internal_state {
|
||||
let archived = PersistedState::from_toml(state_bytes)?;
|
||||
archived_reservations = archived
|
||||
.reserved_addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
address
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::InvalidSavedState)
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
PersistedState::new(archived_reservations.iter().map(ToString::to_string), None)?
|
||||
.apply_to(&mut restored_states)?;
|
||||
}
|
||||
reservations.extend(archived_reservations);
|
||||
reservations.retain(|address| {
|
||||
!restored_states
|
||||
.saved()
|
||||
.clients()
|
||||
.any(|client| client.addresses.iter().any(|saved| saved == *address))
|
||||
});
|
||||
if !reservations.is_empty() || proposed.internal_state.is_some() {
|
||||
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
|
||||
state.apply_to(&mut restored_states)?;
|
||||
proposed.internal_state = Some(state.to_toml()?);
|
||||
}
|
||||
self.store.validate_saved(&proposed)?;
|
||||
Ok(RestorePreview {
|
||||
created_at_utc: preview.created_at_utc,
|
||||
reason: preview.reason,
|
||||
historical_apply: preview.historical_apply,
|
||||
saved_bytes: proposed,
|
||||
})
|
||||
restore::preview_restore(self, file_name)
|
||||
}
|
||||
|
||||
/// Explicitly confirm a previously previewed restore. The pre-restore
|
||||
@@ -1958,54 +1894,18 @@ impl Application {
|
||||
&mut self,
|
||||
preview: RestorePreview,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
self.confirm_restore_with_install(preview, |store, saved, _backup| {
|
||||
restore::confirm_restore(self, preview, |store, saved, _backup| {
|
||||
store.install(saved, BackupComplete(()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn confirm_restore_with_install(
|
||||
&mut self,
|
||||
preview: RestorePreview,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
self.store.validate_saved(&preview.saved_bytes)?;
|
||||
let current = self.store.read_saved()?;
|
||||
let archive = backup::create_archive(
|
||||
self.store.archives_directory(),
|
||||
BackupReason::PreRestore,
|
||||
historical_apply_from_saved(¤t)?,
|
||||
¤t,
|
||||
)?;
|
||||
let resolved = self.store.resolved_settings_for(&preview.saved_bytes)?;
|
||||
if let Err(error) = install(&self.store, &preview.saved_bytes, BackupComplete(())) {
|
||||
self.initial_issues.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
let document = ConfigDocument::parse(&preview.saved_bytes.server_config)?;
|
||||
let vault = preview
|
||||
.saved_bytes
|
||||
.vault
|
||||
.as_deref()
|
||||
.map(std::str::from_utf8)
|
||||
.transpose()
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
let snapshot = snapshot_from_document(&document);
|
||||
let mut states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
||||
if let Some(bytes) = &preview.saved_bytes.internal_state {
|
||||
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
|
||||
}
|
||||
self.saved = preview.saved_bytes;
|
||||
self.store.set_resolved_settings(resolved);
|
||||
self.draft.document = document;
|
||||
self.draft.vault = vault;
|
||||
self.draft.states = states;
|
||||
self.initial_issues.clear();
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
restore::confirm_restore(self, preview, install)
|
||||
}
|
||||
|
||||
/// Persist applied state only with the one-use authorization produced by
|
||||
@@ -2102,87 +2002,7 @@ impl Application {
|
||||
) -> Result<std::path::PathBuf, BackupError>,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = self.store.read_saved()?;
|
||||
if let Some(existing_bytes) = &installed.vault {
|
||||
let existing_text =
|
||||
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
||||
let existing = VaultBundle::from_toml(existing_text)?;
|
||||
if self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
||||
{
|
||||
return Err(AppError::VaultIdentityChange);
|
||||
}
|
||||
}
|
||||
|
||||
let mut next_states = self.draft.states.clone();
|
||||
next_states.commit_draft();
|
||||
let reservations = next_states
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.map(IpAddr::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
||||
let vault_bytes = self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.map(VaultBundle::to_toml)
|
||||
.transpose()?
|
||||
.map(String::into_bytes);
|
||||
let proposed = SavedBytes {
|
||||
settings: installed.settings.clone(),
|
||||
server_config: self.draft.document.render().to_vec(),
|
||||
internal_state,
|
||||
vault: vault_bytes,
|
||||
};
|
||||
self.store.validate_saved(&proposed)?;
|
||||
|
||||
let archive = create_backup(
|
||||
self.store.archives_directory(),
|
||||
BackupReason::PreCommit,
|
||||
historical_apply_from_saved(&installed)?,
|
||||
&installed,
|
||||
)?;
|
||||
if let Err(error) = install(&self.store, &proposed, BackupComplete(())) {
|
||||
match self.store.read_saved() {
|
||||
Ok(current) => {
|
||||
let document = ConfigDocument::parse(¤t.server_config).ok();
|
||||
let vault = current
|
||||
.vault
|
||||
.as_deref()
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
retain_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&document,
|
||||
vault.as_ref(),
|
||||
);
|
||||
} else {
|
||||
self.initial_issues.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => self.initial_issues.clear(),
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
self.saved = proposed;
|
||||
self.draft.states = next_states;
|
||||
self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||
self.invalidate_active_issue_authorizations();
|
||||
promote_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&self.draft.document,
|
||||
self.draft.vault.as_ref(),
|
||||
);
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
commit::commit(self, create_backup, install)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2555,7 +2375,10 @@ mod tests {
|
||||
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
|
||||
use crate::config::SettingsFile;
|
||||
use crate::delivery::qr::QrError;
|
||||
use crate::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot};
|
||||
use crate::domain::{
|
||||
AddressAvailability, AddressFamily, ApplyStatus, Client, ClientAddresses, ClientStates,
|
||||
DomainError, PublicKey, Snapshot, allocate_ipv4, allocate_ipv6,
|
||||
};
|
||||
use crate::persistence::{
|
||||
BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore,
|
||||
};
|
||||
@@ -4202,6 +4025,212 @@ restart_timeout_seconds = 30
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_restore_preview_is_rejected_before_backup_and_keeps_the_draft() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (store, states, key, _) = setup(root.path(), false);
|
||||
let mut app = Application::open(store.clone(), states).expect("application opens");
|
||||
let saved = store.read_saved().expect("saved bytes");
|
||||
let selected = create_archive(
|
||||
store.archives_directory(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("restore archive");
|
||||
let preview = app
|
||||
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
||||
.expect("preview is prepared");
|
||||
|
||||
app.rename_client(key, "committed after preview")
|
||||
.expect("stage new saved generation");
|
||||
app.commit().expect("save new generation");
|
||||
app.rename_client(key, "draft retained after stale preview")
|
||||
.expect("stage follow-up draft");
|
||||
let backup_count = app.list_backups().expect("list backups").len();
|
||||
|
||||
assert!(matches!(
|
||||
app.confirm_restore(preview),
|
||||
Err(AppError::SavedGenerationChanged)
|
||||
));
|
||||
assert_eq!(
|
||||
app.list_backups().expect("no precautionary backup").len(),
|
||||
backup_count
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.draft()
|
||||
.get(&key)
|
||||
.expect("draft survives")
|
||||
.alias,
|
||||
"draft retained after stale preview"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.read_saved()
|
||||
.expect("current generation remains installed")
|
||||
.server_config,
|
||||
app.saved_bytes().server_config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_retains_removed_unprojectable_exact_hosts_through_reopen_and_allocation() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (store, states, _, _) = setup(root.path(), false);
|
||||
let original = store.read_saved().expect("initial saved bytes");
|
||||
let selected = create_archive(
|
||||
store.archives_directory(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&original,
|
||||
)
|
||||
.expect("archive without unsupported peers");
|
||||
|
||||
let broad_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic broad-route peer key")
|
||||
.public_key();
|
||||
let pending_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic exact-route peer key")
|
||||
.public_key();
|
||||
let mut current = clone_saved(&original);
|
||||
current.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {broad_key}\nAllowedIPs = 10.20.0.77/32, 10.20.0.0/24, fd20::77/128\n\n[Peer]\nPublicKey = {pending_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
store
|
||||
.install(¤t, BackupComplete(()))
|
||||
.expect("install synthetic current generation");
|
||||
let mut app = Application::open(store.clone(), states).expect("open with peer routes");
|
||||
|
||||
// Missing managed metadata makes this peer unprojectable. Deleting it
|
||||
// stages its exact addresses without requiring a client snapshot.
|
||||
assert!(
|
||||
!app.delete_client(pending_key)
|
||||
.expect("stage exact-host deletion")
|
||||
);
|
||||
let preview = app
|
||||
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
||||
.expect("preview combines current and pending reservations");
|
||||
app.confirm_restore(preview).expect("restore succeeds");
|
||||
|
||||
let reopened = Application::open_from_saved_store(store.clone())
|
||||
.expect("reservations survive a fresh application session");
|
||||
let reserved = reopened
|
||||
.states()
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
for address in ["10.20.0.77", "fd20::77", "10.20.0.79", "fd20::79"] {
|
||||
assert!(reserved.contains(&address.parse().unwrap()), "{address}");
|
||||
}
|
||||
assert!(
|
||||
!reserved.contains(&"10.20.0.1".parse().unwrap()),
|
||||
"broad subnet routes must not be expanded into guessed reservations"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
allocate_ipv4(
|
||||
"10.20.0.77/32".parse().unwrap(),
|
||||
&AddressAvailability {
|
||||
server: &[],
|
||||
occupied: &[],
|
||||
reserved: &reserved,
|
||||
},
|
||||
),
|
||||
Err(DomainError::AddressExhausted(AddressFamily::Ipv4))
|
||||
);
|
||||
assert_eq!(
|
||||
allocate_ipv6(
|
||||
"fd20::77/128".parse().unwrap(),
|
||||
&AddressAvailability {
|
||||
server: &[],
|
||||
occupied: &[],
|
||||
reserved: &reserved,
|
||||
},
|
||||
),
|
||||
Err(DomainError::AddressExhausted(AddressFamily::Ipv6))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (store, _, _, _) = setup(root.path(), false);
|
||||
let mut current = store.read_saved().expect("initial saved bytes");
|
||||
let retained_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic retained-peer key")
|
||||
.public_key();
|
||||
let deleted_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic pending-deletion key")
|
||||
.public_key();
|
||||
current.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {retained_key}\nAllowedIPs = 10.20.0.77/32, fd20::77/128\n\n[Peer]\nPublicKey = {deleted_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
current.internal_state = Some(
|
||||
PersistedState::new(["10.20.0.88".to_owned()], None)
|
||||
.expect("synthetic archived reservation")
|
||||
.to_toml()
|
||||
.expect("archived reservation serializes"),
|
||||
);
|
||||
store
|
||||
.install(¤t, BackupComplete(()))
|
||||
.expect("install synthetic unprojectable peers");
|
||||
let archive = create_archive(
|
||||
store.archives_directory(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
¤t,
|
||||
)
|
||||
.expect("archive restored peer routes");
|
||||
let mut app = Application::open_from_saved_store(store.clone())
|
||||
.expect("application opens with unprojectable peers");
|
||||
|
||||
assert!(
|
||||
!app.delete_client(deleted_key)
|
||||
.expect("stage pending peer deletion")
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.address_reservations()
|
||||
.pending_host_routes()
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let preview = app
|
||||
.preview_restore(archive.file_name().unwrap().to_str().unwrap())
|
||||
.expect("preview archive restoring exact routes");
|
||||
app.confirm_restore(preview).expect("restore succeeds");
|
||||
|
||||
assert_eq!(app.states().reserved_addresses().len(), 1);
|
||||
assert!(
|
||||
app.states()
|
||||
.reserved_addresses()
|
||||
.contains(&"10.20.0.88".parse().expect("archived reservation"))
|
||||
);
|
||||
assert!(
|
||||
app.states()
|
||||
.address_reservations()
|
||||
.pending_host_routes()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
let restored_state = PersistedState::from_toml(
|
||||
app.saved_bytes()
|
||||
.internal_state
|
||||
.as_deref()
|
||||
.expect("archived reservation remains persisted"),
|
||||
)
|
||||
.expect("restored state remains valid");
|
||||
assert_eq!(restored_state.reserved_addresses, ["10.20.0.88"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_absent_optional_components_removes_installed_vault_and_state() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
@@ -4219,6 +4248,19 @@ restart_timeout_seconds = 30
|
||||
.expect("vault staged");
|
||||
app.commit().expect("vault installed");
|
||||
assert!(store.read_saved().expect("installed").vault.is_some());
|
||||
let mut current = store.read_saved().expect("current saved bytes");
|
||||
let removed_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic removed-peer key")
|
||||
.public_key();
|
||||
current.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {removed_key}\nAllowedIPs = 10.20.0.9/32, fd20::9/128\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
store
|
||||
.install(¤t, BackupComplete(()))
|
||||
.expect("install current peer removed by archive");
|
||||
|
||||
let preview = app
|
||||
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
||||
@@ -4230,10 +4272,11 @@ restart_timeout_seconds = 30
|
||||
restored
|
||||
.internal_state
|
||||
.as_deref()
|
||||
.expect("pending current address is retained"),
|
||||
.expect("removed current routes are retained"),
|
||||
)
|
||||
.expect("restored reservation state parses");
|
||||
assert!(state.reserved_addresses.contains(&"fd20::2".to_owned()));
|
||||
assert!(state.reserved_addresses.contains(&"10.20.0.9".to_owned()));
|
||||
assert!(state.reserved_addresses.contains(&"fd20::9".to_owned()));
|
||||
assert!(app.vault().is_none());
|
||||
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
||||
}
|
||||
@@ -5277,11 +5320,9 @@ restart_timeout_seconds = 30
|
||||
}
|
||||
);
|
||||
let restored = store.read_saved().expect("restored saved bytes parse");
|
||||
assert_eq!(
|
||||
PersistedState::from_toml(restored.internal_state.as_deref().unwrap())
|
||||
.unwrap()
|
||||
.last_verified_at_utc,
|
||||
None
|
||||
assert!(
|
||||
restored.internal_state.is_none(),
|
||||
"the selected archive had no state and restored routes need no reservation"
|
||||
);
|
||||
let restored_document = ConfigDocument::parse(&restored.server_config).unwrap();
|
||||
let reopened_states = ClientStates::new(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Application-owned backup listing and creation scenarios.
|
||||
|
||||
use crate::backup::{self, ArchiveSummary, BackupReason};
|
||||
|
||||
use super::{AppError, Application, historical_apply_from_saved};
|
||||
|
||||
pub(super) fn create_backup(
|
||||
application: &Application,
|
||||
reason: BackupReason,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = application.store.read_saved()?;
|
||||
let historical_apply = historical_apply_from_saved(&installed)?;
|
||||
Ok(backup::create_archive(
|
||||
application.store.archives_directory(),
|
||||
reason,
|
||||
historical_apply,
|
||||
&installed,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(super) fn list_backups(application: &Application) -> Result<Vec<ArchiveSummary>, AppError> {
|
||||
match backup::list_archives(application.store.archives_directory()) {
|
||||
Ok(archives) => Ok(archives),
|
||||
Err(crate::backup::BackupError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::NotFound =>
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_pre_restart_backup(
|
||||
application: &mut Application,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
application.active_restart_attempt = None;
|
||||
create_backup(application, BackupReason::PreRestart)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Saved-state Commit scenario.
|
||||
//!
|
||||
//! The ordering is deliberately explicit: prepare and validate the complete
|
||||
//! candidate, archive the installed generation, install it through the
|
||||
//! journaled store, then publish only already-prepared in-memory values.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::backup::{BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
|
||||
use crate::domain::ClientStates;
|
||||
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::{
|
||||
AppError, Application, historical_apply_from_saved, promote_matching_issue_map,
|
||||
retain_matching_issue_map,
|
||||
};
|
||||
|
||||
struct PreparedCommit {
|
||||
proposed: SavedBytes,
|
||||
document: ConfigDocument,
|
||||
states: ClientStates,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
/// Prepare a candidate without changing the live session or installed
|
||||
/// files. All parsing, serialization and store validation finish here.
|
||||
fn prepare_commit(
|
||||
&self,
|
||||
installed: &SavedBytes,
|
||||
) -> Result<(SavedBytes, ConfigDocument, ClientStates), AppError> {
|
||||
if let Some(existing_bytes) = &installed.vault {
|
||||
let existing_text =
|
||||
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
||||
let existing = VaultBundle::from_toml(existing_text)?;
|
||||
if self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
||||
{
|
||||
return Err(AppError::VaultIdentityChange);
|
||||
}
|
||||
}
|
||||
|
||||
let mut states = self.draft.states.clone();
|
||||
states.commit_draft();
|
||||
let reservations = states
|
||||
.reserved_addresses()
|
||||
.iter()
|
||||
.map(IpAddr::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
||||
let vault = self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.map(VaultBundle::to_toml)
|
||||
.transpose()?
|
||||
.map(String::into_bytes);
|
||||
let proposed = SavedBytes {
|
||||
settings: installed.settings.clone(),
|
||||
server_config: self.draft.document.render().to_vec(),
|
||||
internal_state,
|
||||
vault,
|
||||
};
|
||||
self.store.validate_saved(&proposed)?;
|
||||
|
||||
// Reuse this parsed document after install; publication must not have a
|
||||
// fallible parse step after the filesystem has already advanced.
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
Ok((proposed, document, states))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn commit(
|
||||
application: &mut Application,
|
||||
create_backup: impl FnOnce(
|
||||
&std::path::Path,
|
||||
BackupReason,
|
||||
HistoricalApplyStatus,
|
||||
&SavedBytes,
|
||||
) -> Result<std::path::PathBuf, BackupError>,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let installed = application.store.read_saved()?;
|
||||
let historical_apply = historical_apply_from_saved(&installed)?;
|
||||
let (proposed, document, states) = application.prepare_commit(&installed)?;
|
||||
let prepared = PreparedCommit {
|
||||
proposed,
|
||||
document,
|
||||
states,
|
||||
};
|
||||
|
||||
// Passing BackupComplete is the only path to installation, and this point
|
||||
// is reached only after the full archive has been published successfully.
|
||||
let archive = create_backup(
|
||||
application.store.archives_directory(),
|
||||
BackupReason::PreCommit,
|
||||
historical_apply,
|
||||
&installed,
|
||||
)?;
|
||||
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
|
||||
retain_first_issue_rights_after_failed_install(application);
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
application.saved = prepared.proposed;
|
||||
application.draft.states = prepared.states;
|
||||
application.draft.document = prepared.document;
|
||||
application.invalidate_active_issue_authorizations();
|
||||
promote_matching_issue_map(
|
||||
&mut application.initial_issues,
|
||||
&application.draft.document,
|
||||
application.draft.vault.as_ref(),
|
||||
);
|
||||
application.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
application.verified_saved_projection = None;
|
||||
application.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
fn retain_first_issue_rights_after_failed_install(application: &mut Application) {
|
||||
match application.store.read_saved() {
|
||||
Ok(current) => {
|
||||
let document = ConfigDocument::parse(¤t.server_config).ok();
|
||||
let vault = current
|
||||
.vault
|
||||
.as_deref()
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.and_then(|text| VaultBundle::from_toml(text).ok());
|
||||
if let Some(document) = document {
|
||||
retain_matching_issue_map(
|
||||
&mut application.initial_issues,
|
||||
&document,
|
||||
vault.as_ref(),
|
||||
);
|
||||
} else {
|
||||
application.initial_issues.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => application.initial_issues.clear(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Previewed, saved-state-only restore scenario.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::backup::{self, BackupReason, SavedBytes};
|
||||
use crate::domain::{ApplyStatus, ClientStates};
|
||||
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
|
||||
use crate::vault::VaultBundle;
|
||||
use crate::wireguard::{ConfigDocument, PeerInspection};
|
||||
|
||||
use super::{
|
||||
AppError, Application, RestorePreview, historical_apply_from_saved, safe_archive_name,
|
||||
same_saved_bytes, snapshot_from_document,
|
||||
};
|
||||
|
||||
struct PreparedRestore {
|
||||
proposed: SavedBytes,
|
||||
resolved_settings: crate::config::ResolvedSettings,
|
||||
document: ConfigDocument,
|
||||
vault: Option<VaultBundle>,
|
||||
states: ClientStates,
|
||||
}
|
||||
|
||||
pub(super) fn preview_restore(
|
||||
application: &Application,
|
||||
file_name: &str,
|
||||
) -> Result<RestorePreview, AppError> {
|
||||
if !safe_archive_name(file_name) {
|
||||
return Err(AppError::InvalidArchiveSelection);
|
||||
}
|
||||
let installed_generation = application.store.read_saved()?;
|
||||
let current_document = ConfigDocument::parse(&installed_generation.server_config)?;
|
||||
let selected_path = application.store.archives_directory().join(file_name);
|
||||
let archive = backup::preview_archive(&selected_path)?;
|
||||
application.store.validate_saved(&archive.saved_bytes)?;
|
||||
let mut proposed = archive.saved_bytes;
|
||||
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
let mut states = ClientStates::new(
|
||||
snapshot_from_document(&document),
|
||||
None,
|
||||
ApplyStatus::Unknown,
|
||||
)?;
|
||||
|
||||
let mut carried_reservations = application
|
||||
.draft
|
||||
.states
|
||||
.address_reservations()
|
||||
.all()
|
||||
.collect::<BTreeSet<_>>();
|
||||
carried_reservations.extend(
|
||||
snapshot_from_document(¤t_document)
|
||||
.clients()
|
||||
.flat_map(|client| client.addresses.iter()),
|
||||
);
|
||||
// Saved peers outside the manageable client projection can still own
|
||||
// exact host routes. Keep those addresses if this restore removes them;
|
||||
// never enumerate broad routes or infer other addresses from a subnet.
|
||||
carried_reservations.extend(exact_host_route_reservations(¤t_document));
|
||||
|
||||
let archived_had_state = proposed.internal_state.is_some();
|
||||
let mut archived_reservations = BTreeSet::new();
|
||||
if let Some(state_bytes) = &proposed.internal_state {
|
||||
let archived = PersistedState::from_toml(state_bytes)?;
|
||||
archived_reservations = archived
|
||||
.reserved_addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
address
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::InvalidSavedState)
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
}
|
||||
let restored_exact_hosts = exact_host_routes(&document);
|
||||
let mut reservations = carried_reservations.clone();
|
||||
reservations.extend(archived_reservations.iter().copied());
|
||||
reservations.retain(|address| {
|
||||
let assigned_to_restored_client = states
|
||||
.saved()
|
||||
.clients()
|
||||
.any(|client| client.addresses.iter().any(|saved| saved == *address));
|
||||
!assigned_to_restored_client
|
||||
&& (archived_reservations.contains(address)
|
||||
|| !carried_reservations.contains(address)
|
||||
|| !restored_exact_hosts.contains(address))
|
||||
});
|
||||
if !reservations.is_empty() || archived_had_state {
|
||||
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
|
||||
state.apply_to(&mut states)?;
|
||||
proposed.internal_state = Some(state.to_toml()?);
|
||||
}
|
||||
|
||||
application.store.validate_saved(&proposed)?;
|
||||
Ok(RestorePreview {
|
||||
created_at_utc: archive.created_at_utc,
|
||||
reason: archive.reason,
|
||||
historical_apply: archive.historical_apply,
|
||||
installed_generation,
|
||||
saved_bytes: proposed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn confirm_restore(
|
||||
application: &mut Application,
|
||||
preview: RestorePreview,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let current = application.store.read_saved()?;
|
||||
if !same_saved_bytes(¤t, &preview.installed_generation) {
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
|
||||
let prepared = prepare_restore(application, preview.saved_bytes)?;
|
||||
let historical_apply = historical_apply_from_saved(¤t)?;
|
||||
let archive = backup::create_archive(
|
||||
application.store.archives_directory(),
|
||||
BackupReason::PreRestore,
|
||||
historical_apply,
|
||||
¤t,
|
||||
)?;
|
||||
|
||||
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
|
||||
// A partial journaled install may leave disk in a recovery-required
|
||||
// state. Revoke session-only delivery rights until the store is read
|
||||
// again and the actual installed generation is known.
|
||||
application.initial_issues.clear();
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
application.saved = prepared.proposed;
|
||||
application
|
||||
.store
|
||||
.set_resolved_settings(prepared.resolved_settings);
|
||||
application.draft.document = prepared.document;
|
||||
application.draft.vault = prepared.vault;
|
||||
application.draft.states = prepared.states;
|
||||
application.initial_issues.clear();
|
||||
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
|
||||
application.verified_saved_projection = None;
|
||||
application.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
fn prepare_restore(
|
||||
application: &Application,
|
||||
proposed: SavedBytes,
|
||||
) -> Result<PreparedRestore, AppError> {
|
||||
application.store.validate_saved(&proposed)?;
|
||||
// Check installation path compatibility and parse every in-memory value
|
||||
// before the precautionary archive or file replacement begins.
|
||||
let resolved_settings = application.store.resolved_settings_for(&proposed)?;
|
||||
let document = ConfigDocument::parse(&proposed.server_config)?;
|
||||
let vault = proposed
|
||||
.vault
|
||||
.as_deref()
|
||||
.map(std::str::from_utf8)
|
||||
.transpose()
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
let mut states = ClientStates::new(
|
||||
snapshot_from_document(&document),
|
||||
None,
|
||||
ApplyStatus::Unknown,
|
||||
)?;
|
||||
if let Some(bytes) = &proposed.internal_state {
|
||||
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
|
||||
}
|
||||
Ok(PreparedRestore {
|
||||
proposed,
|
||||
resolved_settings,
|
||||
document,
|
||||
vault,
|
||||
states,
|
||||
})
|
||||
}
|
||||
|
||||
fn exact_host_route_reservations(document: &ConfigDocument) -> BTreeSet<IpAddr> {
|
||||
let mut reservations = BTreeSet::new();
|
||||
for peer in document.peers() {
|
||||
if manageable_peer(document, peer) {
|
||||
continue;
|
||||
}
|
||||
for route in &peer.allowed_ips {
|
||||
match route {
|
||||
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||
reservations.insert(IpAddr::V4(network.addr()));
|
||||
}
|
||||
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||
reservations.insert(IpAddr::V6(network.addr()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
reservations
|
||||
}
|
||||
|
||||
fn exact_host_routes(document: &ConfigDocument) -> BTreeSet<IpAddr> {
|
||||
document
|
||||
.peers()
|
||||
.iter()
|
||||
.flat_map(|peer| peer.allowed_ips.iter())
|
||||
.filter_map(|route| match route {
|
||||
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||
Some(IpAddr::V4(network.addr()))
|
||||
}
|
||||
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
|
||||
Some(IpAddr::V6(network.addr()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn manageable_peer(document: &ConfigDocument, peer: &PeerInspection) -> bool {
|
||||
peer.identity
|
||||
.is_some_and(|identity| document.project_client(&identity).is_ok())
|
||||
}
|
||||
Reference in New Issue
Block a user