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 |
|
| 11 | Valid subnet routes shown as a capability limitation | 10 | accepted |
|
||||||
| 12 | Aligned, responsive client table | 11 | accepted |
|
| 12 | Aligned, responsive client table | 11 | accepted |
|
||||||
| 13 | Compact terminal QR disclosure | 12 | 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
|
### 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
|
Longer notices increase the required terminal height. Physical terminal and
|
||||||
phone/decoder scanning remain unverified; M9 systemd acceptance remains retired.
|
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
|
## 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.
|
- 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 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.
|
- 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 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.
|
- 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
|
## 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>,
|
vault: Option<VaultBundle>,
|
||||||
draft_unsupported_reservations: BTreeSet<IpAddr>,
|
draft_unsupported_reservations: BTreeSet<IpAddr>,
|
||||||
historical_apply: HistoricalApplyStatus,
|
historical_apply: HistoricalApplyStatus,
|
||||||
|
verified_saved_projection: Option<SavedPeerProjection>,
|
||||||
active_restart_attempt: Option<u64>,
|
active_restart_attempt: Option<u64>,
|
||||||
next_restart_attempt: u64,
|
next_restart_attempt: u64,
|
||||||
next_issue_authorization: u64,
|
next_issue_authorization: u64,
|
||||||
@@ -245,7 +246,7 @@ pub struct RestartPreview {
|
|||||||
unit: String,
|
unit: String,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
projection: SavedPeerProjection,
|
projection: SavedPeerProjection,
|
||||||
expected_clients: Snapshot,
|
expected_clients: Option<Snapshot>,
|
||||||
has_draft: bool,
|
has_draft: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +302,15 @@ pub enum RestartFailureKind {
|
|||||||
VerificationPersistenceFailed,
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct RestartReport {
|
pub struct RestartReport {
|
||||||
pub stage: RestartStage,
|
pub stage: RestartStage,
|
||||||
@@ -523,6 +533,7 @@ impl Application {
|
|||||||
vault,
|
vault,
|
||||||
draft_unsupported_reservations: BTreeSet::new(),
|
draft_unsupported_reservations: BTreeSet::new(),
|
||||||
historical_apply,
|
historical_apply,
|
||||||
|
verified_saved_projection: None,
|
||||||
active_restart_attempt: None,
|
active_restart_attempt: None,
|
||||||
next_restart_attempt: 1,
|
next_restart_attempt: 1,
|
||||||
next_issue_authorization: 1,
|
next_issue_authorization: 1,
|
||||||
@@ -964,15 +975,14 @@ impl Application {
|
|||||||
installed: &SavedBytes,
|
installed: &SavedBytes,
|
||||||
document: &ConfigDocument,
|
document: &ConfigDocument,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !same_saved_bytes(&self.saved, installed) {
|
if !same_saved_bytes(&self.saved, installed) || self.verified_saved_projection.is_none() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Ok(snapshot) = checked_snapshot_from_document(document) else {
|
let Ok((projection, _)) = restart_projection_from_document(document) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.states.applied() == &RuntimeObservation::Verified
|
self.states.applied() == &RuntimeObservation::Verified
|
||||||
&& self.states.saved() == &snapshot
|
&& self.verified_saved_projection.as_ref() == Some(&projection)
|
||||||
&& self.states.applied_snapshot() == Some(&snapshot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recheck both the one-use token and installed saved generation as the
|
/// 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
|
/// Drop all uncommitted edits and restore the in-memory drafts from the last
|
||||||
/// successfully installed session snapshot. No files are written.
|
/// successfully installed session snapshot. No files are written.
|
||||||
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
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.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||||
self.vault = self
|
self.vault = self
|
||||||
.saved
|
.saved
|
||||||
@@ -1422,8 +1436,9 @@ impl Application {
|
|||||||
if document.has_unsafe_save_config() {
|
if document.has_unsafe_save_config() {
|
||||||
return Err(AppError::UnsafeSaveConfig);
|
return Err(AppError::UnsafeSaveConfig);
|
||||||
}
|
}
|
||||||
let projection = SavedPeerProjection::from_document(&document);
|
let (projection, expected_clients) =
|
||||||
let expected_clients = checked_snapshot_from_document(&document)?;
|
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
|
||||||
|
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
||||||
Ok(RestartPreview {
|
Ok(RestartPreview {
|
||||||
saved: clone_saved(&installed),
|
saved: clone_saved(&installed),
|
||||||
interface: settings.interface_name,
|
interface: settings.interface_name,
|
||||||
@@ -1606,8 +1621,8 @@ impl Application {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let expected_clients = match checked_snapshot_from_document(&document) {
|
let (projection, expected_clients) = match restart_projection_from_document(&document) {
|
||||||
Ok(snapshot) => snapshot,
|
Ok(projection) => projection,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return restart_report(
|
return restart_report(
|
||||||
RestartStage::Preparation,
|
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
|
if settings.interface_name != preview.interface
|
||||||
|| settings.systemd_unit != preview.unit
|
|| settings.systemd_unit != preview.unit
|
||||||
|| settings.restart_timeout != preview.timeout
|
|| settings.restart_timeout != preview.timeout
|
||||||
@@ -1635,20 +1650,6 @@ impl Application {
|
|||||||
false,
|
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) {
|
let historical_apply = match historical_apply_from_saved(&installed) {
|
||||||
Ok(status) => status,
|
Ok(status) => status,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -1947,6 +1948,7 @@ impl Application {
|
|||||||
self.initial_issues.clear();
|
self.initial_issues.clear();
|
||||||
self.draft_unsupported_reservations.clear();
|
self.draft_unsupported_reservations.clear();
|
||||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||||
|
self.verified_saved_projection = None;
|
||||||
self.active_restart_attempt = None;
|
self.active_restart_attempt = None;
|
||||||
Ok(archive)
|
Ok(archive)
|
||||||
}
|
}
|
||||||
@@ -1956,7 +1958,7 @@ impl Application {
|
|||||||
fn mark_verified_with_attempt(
|
fn mark_verified_with_attempt(
|
||||||
&mut self,
|
&mut self,
|
||||||
attempt: RestartAttempt,
|
attempt: RestartAttempt,
|
||||||
observed: Snapshot,
|
observed_management_snapshot: Option<Snapshot>,
|
||||||
verified_at_utc: String,
|
verified_at_utc: String,
|
||||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
@@ -1970,7 +1972,7 @@ impl Application {
|
|||||||
return Err(AppError::SavedGenerationChanged);
|
return Err(AppError::SavedGenerationChanged);
|
||||||
}
|
}
|
||||||
let mut next_states = self.states.clone();
|
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 {
|
let mut persisted = match &installed.internal_state {
|
||||||
Some(bytes) => PersistedState::from_toml(bytes)?,
|
Some(bytes) => PersistedState::from_toml(bytes)?,
|
||||||
None => PersistedState::new(
|
None => PersistedState::new(
|
||||||
@@ -2019,6 +2021,7 @@ impl Application {
|
|||||||
}
|
}
|
||||||
self.saved = proposed;
|
self.saved = proposed;
|
||||||
self.states = next_states;
|
self.states = next_states;
|
||||||
|
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
|
||||||
self.historical_apply = historical_apply;
|
self.historical_apply = historical_apply;
|
||||||
self.active_restart_attempt = None;
|
self.active_restart_attempt = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2131,6 +2134,7 @@ impl Application {
|
|||||||
self.vault.as_ref(),
|
self.vault.as_ref(),
|
||||||
);
|
);
|
||||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||||
|
self.verified_saved_projection = None;
|
||||||
self.active_restart_attempt = None;
|
self.active_restart_attempt = None;
|
||||||
Ok(archive)
|
Ok(archive)
|
||||||
}
|
}
|
||||||
@@ -2304,20 +2308,96 @@ fn document_host_addresses(document: &ConfigDocument) -> Vec<IpAddr> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checked_snapshot_from_document(document: &ConfigDocument) -> Result<Snapshot, AppError> {
|
fn restart_projection_from_document(
|
||||||
if !document.issues().is_empty() {
|
document: &ConfigDocument,
|
||||||
return Err(AppError::UnsupportedSavedShape);
|
) -> 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());
|
let mut clients = Vec::with_capacity(document.peers().len());
|
||||||
for peer in document.peers() {
|
for peer in document.peers() {
|
||||||
let identity = peer.identity.ok_or(AppError::UnsupportedSavedShape)?;
|
let Some(identity) = peer.identity else {
|
||||||
clients.push(
|
return Err(vec![RestartDiagnostic {
|
||||||
document
|
identity: None,
|
||||||
.project_client(&identity)
|
section_line: Some(peer.section_line),
|
||||||
.map_err(|_| AppError::UnsupportedSavedShape)?,
|
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(
|
fn restart_report(
|
||||||
@@ -2375,10 +2455,10 @@ pub enum AppError {
|
|||||||
PreRestartBackupRequired,
|
PreRestartBackupRequired,
|
||||||
#[error("wg-quick SaveConfig may overwrite the saved server configuration during restart")]
|
#[error("wg-quick SaveConfig may overwrite the saved server configuration during restart")]
|
||||||
UnsafeSaveConfig,
|
UnsafeSaveConfig,
|
||||||
#[error(
|
#[error("saved peer cannot be represented by the client management model")]
|
||||||
"the complete saved peer set cannot be represented for durable applied-state recording"
|
|
||||||
)]
|
|
||||||
UnsupportedSavedShape,
|
UnsupportedSavedShape,
|
||||||
|
#[error("saved server configuration cannot be verified safely: {0:?}")]
|
||||||
|
UnsafeSavedPeers(Vec<RestartDiagnostic>),
|
||||||
#[error("installed saved bytes changed after this session opened")]
|
#[error("installed saved bytes changed after this session opened")]
|
||||||
SavedGenerationChanged,
|
SavedGenerationChanged,
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -2515,9 +2595,12 @@ restart_timeout_seconds = 30
|
|||||||
id,
|
id,
|
||||||
saved: clone_saved(&app.saved),
|
saved: clone_saved(&app.saved),
|
||||||
};
|
};
|
||||||
app.mark_verified_with_attempt(attempt, observed, verified_at, |store, saved, backup| {
|
app.mark_verified_with_attempt(
|
||||||
store.install(saved, backup)
|
attempt,
|
||||||
})
|
Some(observed),
|
||||||
|
verified_at,
|
||||||
|
|store, saved, backup| store.install(saved, backup),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_restartable(root: &Path) -> (Application, PublicKey) {
|
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]
|
#[test]
|
||||||
fn service_timeout_reports_submission_and_may_still_complete() {
|
fn service_timeout_reports_submission_and_may_still_complete() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -4553,7 +4863,7 @@ restart_timeout_seconds = 30
|
|||||||
};
|
};
|
||||||
let result = app.mark_verified_with_attempt(
|
let result = app.mark_verified_with_attempt(
|
||||||
attempt,
|
attempt,
|
||||||
observed,
|
Some(observed),
|
||||||
"2026-10-20T08:30:00Z".to_owned(),
|
"2026-10-20T08:30:00Z".to_owned(),
|
||||||
|store, saved, _backup| {
|
|store, saved, _backup| {
|
||||||
store.install_with_hook(saved, |point| {
|
store.install_with_hook(saved, |point| {
|
||||||
|
|||||||
@@ -270,6 +270,23 @@ impl ClientStates {
|
|||||||
Ok(())
|
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) {
|
pub fn mark_runtime_unavailable(&mut self) {
|
||||||
self.applied = RuntimeObservation::Unavailable;
|
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);
|
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]
|
#[test]
|
||||||
fn restore_changes_the_settings_used_by_the_next_restart_preview() {
|
fn restore_changes_the_settings_used_by_the_next_restart_preview() {
|
||||||
let fixture = fixture("wg-quick@wg0.service", 30);
|
let fixture = fixture("wg-quick@wg0.service", 30);
|
||||||
|
|||||||
Reference in New Issue
Block a user